Compare commits
29 Commits
8c2049fa24
...
debian12
Author | SHA1 | Date | |
---|---|---|---|
7868fe0fe0 | |||
4244d03384 | |||
4bb071cbb5 | |||
0fb46cb43f | |||
5188b0e4c0 | |||
a6dedd46ed | |||
772cb944a3 | |||
764ac9c38b | |||
58332ee8fc | |||
78497291a8 | |||
7ef52321d6 | |||
af30e87d96 | |||
725cd7e08a | |||
5c6373d16d | |||
7da66b52be | |||
f2433e2d84 | |||
40d5f35afb | |||
9d64e7c274 | |||
214a3cb3c8 | |||
bb63388986 | |||
c4c805083a | |||
d77302c532 | |||
a9f26b1148 | |||
3096e4ab83 | |||
5792ce154f | |||
dd4fcea1b0 | |||
42d68181f9 | |||
42f46db2c9 | |||
9dd7b6641a |
84
README.md
84
README.md
@@ -1 +1,83 @@
|
|||||||
# MCS Lose
|
# MCS TicketHub
|
||||||
|
|
||||||
|
[](https://nodejs.org/)
|
||||||
|
[](https://expressjs.com/)
|
||||||
|
[](https://reactjs.org/)
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
[](https://vitejs.dev/)
|
||||||
|
[](https://www.mysql.com/)
|
||||||
|
[](https://www.docker.com/)
|
||||||
|
[](https://tailwindcss.com/)
|
||||||
|
|
||||||
|
MCS TicketHub — my first project. I intend to sell this application. The app manages "Losnummer" (ticket) records: a React + TypeScript frontend provides CSV import, table view, and row editing; an Express backend exposes REST endpoints and JWT-protected admin actions; data is persisted in a MySQL `lose` table. The project is containerised with Docker Compose for easy local development.
|
||||||
|
|
||||||
|
## Quick overview
|
||||||
|
|
||||||
|
- Backend: Express.js (ES Modules), serves JSON APIs and EJS root view.
|
||||||
|
- Frontend: React + TypeScript + Vite (UI for importing CSVs and managing entries).
|
||||||
|
- Database: MySQL 8.0 with a simple schema (`lose` and `admin_user`).
|
||||||
|
- Orchestration: `docker-compose.yml` to run backend and MySQL (frontend is prepared but commented out in compose).
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Import and manage losnummer (ticket) entries.
|
||||||
|
- Allow admins to login and perform protected operations (create, delete, update rows).
|
||||||
|
- Keep the setup easy to run locally via Docker.
|
||||||
|
|
||||||
|
## API (backend)
|
||||||
|
|
||||||
|
All endpoints are served by the Express backend in `backend/server.js`.
|
||||||
|
|
||||||
|
Public
|
||||||
|
|
||||||
|
- GET / — renders `index.ejs` (simple root view)
|
||||||
|
- POST /lose — accepts an update payload and attempts a conditional UPDATE on `lose` rows (no auth)
|
||||||
|
- POST /login — admin login; returns a JWT token on success
|
||||||
|
|
||||||
|
Protected (require Authorization: Bearer <token>)
|
||||||
|
|
||||||
|
- GET /table-data — returns all rows from `lose`
|
||||||
|
- POST /create-entry — bulk-insert losnummer values (body: { losnummer: [...] })
|
||||||
|
- DELETE /remove-entries — delete rows by losnummer (body: { losnummern: [...] })
|
||||||
|
- PUT /save-row — update a single row (body contains row fields)
|
||||||
|
- DELETE /reset-data — delete all rows from `lose`
|
||||||
|
|
||||||
|
Authentication
|
||||||
|
|
||||||
|
- Tokens are generated using `jose` and signed with `SECRET_KEY` in `backend/services/tokenService.js`.
|
||||||
|
- The `authenticate` middleware expects an `Authorization` header with `Bearer <token>`.
|
||||||
|
|
||||||
|
## Database schema
|
||||||
|
|
||||||
|
Schema available in `backend/scheme.sql` — main tables:
|
||||||
|
|
||||||
|
- `lose` (columns):
|
||||||
|
- `losnummer` VARCHAR(255) UNIQUE
|
||||||
|
- `vorname`, `nachname`, `adresse`, `plz`, `email` (nullable)
|
||||||
|
- `admin_user` (columns): `username` (unique), `password` (plain text in current implementation)
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- The current SQL uses simple types and a single `losnummer` unique constraint. Consider adding an auto-increment id column if needed.
|
||||||
|
|
||||||
|
You can also see the database schema in `backend/scheme.sql`.
|
||||||
|
|
||||||
|
## Project structure (high level)
|
||||||
|
|
||||||
|
- backend/
|
||||||
|
|
||||||
|
- `server.js` — Express app, routes and middleware
|
||||||
|
- `services/database.js` — MySQL pool and query helpers
|
||||||
|
- `services/tokenService.js` — token generation and authentication middleware
|
||||||
|
- `scheme.sql` — DDL for tables
|
||||||
|
|
||||||
|
- frontend/
|
||||||
|
|
||||||
|
- React + TypeScript app scaffolded with Vite
|
||||||
|
- `src/components` — UI components (Admin, Table, Import GUI, Forms)
|
||||||
|
|
||||||
|
- docker-compose.yml — config for backend and MySQL containers
|
||||||
|
|
||||||
|
## Commercial intent
|
||||||
|
|
||||||
|
The project owner intends to sell this application commercially. If you are interested in licensing or purchasing the application, add contact details or a sales/licensing process to this README or repository metadata.
|
||||||
|
@@ -7,6 +7,6 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8002
|
EXPOSE 8502
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
CMD ["npm", "start"]
|
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "backend",
|
"name": "backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
@@ -18,4 +19,4 @@
|
|||||||
"jose": "^6.0.12",
|
"jose": "^6.0.12",
|
||||||
"mysql2": "^3.14.3"
|
"mysql2": "^3.14.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
13
backend/scheme.sql
Normal file
13
backend/scheme.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE lose (
|
||||||
|
losnummer VARCHAR(255) UNIQUE,
|
||||||
|
vorname VARCHAR(100) DEFAULT NULL,
|
||||||
|
nachname VARCHAR(100) DEFAULT NULL,
|
||||||
|
adresse VARCHAR(255) DEFAULT NULL,
|
||||||
|
plz VARCHAR(10) DEFAULT NULL,
|
||||||
|
email VARCHAR(255) DEFAULT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE admin_user (
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL
|
||||||
|
);
|
@@ -1,16 +1,25 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import env from "dotenv";
|
import env from "dotenv";
|
||||||
import { query, loginAdmin, getTableData } from "./services/database.js";
|
import {
|
||||||
|
query,
|
||||||
|
loginAdmin,
|
||||||
|
getTableData,
|
||||||
|
createEntry,
|
||||||
|
removeEntries,
|
||||||
|
saveRow,
|
||||||
|
resetData,
|
||||||
|
} from "./services/database.js";
|
||||||
import { generateToken, authenticate } from "./services/tokenService.js";
|
import { generateToken, authenticate } from "./services/tokenService.js";
|
||||||
env.config();
|
env.config();
|
||||||
const app = express();
|
const app = express();
|
||||||
const port = 8002;
|
const port = 8502;
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.urlencoded({ extended: true }));
|
// Increase body size limits to support large CSV JSON payloads
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||||
app.set("view engine", "ejs");
|
app.set("view engine", "ejs");
|
||||||
app.use(express.json());
|
app.use(express.json({ limit: "10mb" }));
|
||||||
|
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.render("index.ejs", { title: port });
|
res.render("index.ejs", { title: port });
|
||||||
@@ -27,10 +36,44 @@ app.post("/lose", async (req, res) => {
|
|||||||
|
|
||||||
app.get("/table-data", authenticate, async (req, res) => {
|
app.get("/table-data", authenticate, async (req, res) => {
|
||||||
const result = await getTableData();
|
const result = await getTableData();
|
||||||
if (result.success) {
|
if (result.success && result.data) {
|
||||||
res.status(200).json(result.data);
|
res.status(200).json(result.data);
|
||||||
|
} else if (result.success && !result.data) {
|
||||||
|
res.status(204).json({ success: true });
|
||||||
} else {
|
} else {
|
||||||
res.status(500);
|
res.status(500).json({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/create-entry", authenticate, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await createEntry(req.body.losnummer);
|
||||||
|
if (result) {
|
||||||
|
res.status(201).json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false, message: "Insert failed" });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("/create-entry error", err);
|
||||||
|
res.status(500).json({ success: false, message: String(err) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/remove-entries", authenticate, async (req, res) => {
|
||||||
|
const result = await removeEntries(req.body.losnummern);
|
||||||
|
if (result) {
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put("/save-row", authenticate, async (req, res) => {
|
||||||
|
const result = await saveRow(req.body);
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,6 +88,15 @@ app.post("/login", async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.delete("/reset-data", authenticate, async (req, res) => {
|
||||||
|
const result = await resetData();
|
||||||
|
if (result.success) {
|
||||||
|
res.status(200).json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ success: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.listen(port, () => {
|
app.listen(port, () => {
|
||||||
console.log(`Server is running on port: ${port}`);
|
console.log(`Server is running on port: ${port}`);
|
||||||
});
|
});
|
||||||
|
@@ -47,6 +47,82 @@ export async function getTableData() {
|
|||||||
|
|
||||||
if (result.length > 0) {
|
if (result.length > 0) {
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
|
} else if (result.entries.length === 0) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createEntry(data) {
|
||||||
|
if (!Array.isArray(data) || data.length === 0) return true;
|
||||||
|
|
||||||
|
// Normalize values to strings and unique them to reduce duplicates
|
||||||
|
const values = Array.from(
|
||||||
|
new Set(data.map((v) => String(v).trim()).filter(Boolean))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Prepare bulk insert values [[v1],[v2],...]
|
||||||
|
const rows = values.map((v) => [v]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use INSERT IGNORE to skip duplicates on UNIQUE(losnummer)
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"INSERT IGNORE INTO lose (losnummer) VALUES ?",
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
// result.affectedRows may be less than rows.length if duplicates existed
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Bulk insert failed", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeEntries(losnummern) {
|
||||||
|
let { status } = { status: true };
|
||||||
|
|
||||||
|
for (const losnummer of losnummern) {
|
||||||
|
const [result] = await pool.query("DELETE FROM lose WHERE losnummer = ?", [
|
||||||
|
losnummer,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
status = true;
|
||||||
|
} else {
|
||||||
|
status = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveRow(payload) {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
"UPDATE lose SET vorname = ?, nachname = ?, adresse = ?, plz = ?, email = ? WHERE losnummer = ?",
|
||||||
|
[
|
||||||
|
payload.vorname,
|
||||||
|
payload.nachname,
|
||||||
|
payload.adresse,
|
||||||
|
payload.plz,
|
||||||
|
payload.email,
|
||||||
|
payload.losnummer,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetData() {
|
||||||
|
const [result] = await pool.query("DELETE FROM lose");
|
||||||
|
if (result.affectedRows > 0) {
|
||||||
|
return { success: true };
|
||||||
|
} else {
|
||||||
|
return { success: false };
|
||||||
}
|
}
|
||||||
return { success: false };
|
|
||||||
}
|
}
|
||||||
|
@@ -1,11 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Backend</title>
|
<title>Backend</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
Backend
|
<h1>You have reached the backend views index page!</h1>
|
||||||
</body>
|
<p>Currently, there is nothing to display!</p>
|
||||||
</html>
|
</body>
|
||||||
|
</html>
|
||||||
|
@@ -1,21 +1,27 @@
|
|||||||
services:
|
services:
|
||||||
# mcs_lose-frontend:
|
mcs_lose-frontend:
|
||||||
# container_name: mcs_lose-frontend
|
container_name: mcs_lose-frontend
|
||||||
# build: ./frontend
|
build: ./frontend
|
||||||
# ports:
|
ports:
|
||||||
# - "8001:8001"
|
- "8501:8501"
|
||||||
# environment:
|
environment:
|
||||||
# - CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
# volumes:
|
networks:
|
||||||
# - ./frontend:/app
|
- proxynet
|
||||||
# - /app/node_modules
|
- lose-local
|
||||||
# restart: unless-stopped
|
volumes:
|
||||||
|
- ./frontend:/app
|
||||||
|
- /app/node_modules
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
mcs_lose-backend:
|
mcs_lose-backend:
|
||||||
container_name: mcs_lose-backend_express
|
container_name: mcs_lose-backend_express
|
||||||
build: ./backend
|
build: ./backend
|
||||||
ports:
|
ports:
|
||||||
- "8002:8002"
|
- "8502:8502"
|
||||||
|
networks:
|
||||||
|
- proxynet
|
||||||
|
- lose-local
|
||||||
environment:
|
environment:
|
||||||
DB_HOST: mysql
|
DB_HOST: mysql
|
||||||
DB_USER: root
|
DB_USER: root
|
||||||
@@ -31,13 +37,21 @@ services:
|
|||||||
container_name: mcs_lose-mysql
|
container_name: mcs_lose-mysql
|
||||||
image: mysql:8.0
|
image: mysql:8.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- lose-local
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: D7Ze0lwV9hMrNQHdz1Q8yi0MIQuOO8
|
MYSQL_ROOT_PASSWORD: D7Ze0lwV9hMrNQHdz1Q8yi0MIQuOO8
|
||||||
MYSQL_DATABASE: mcs_lose
|
MYSQL_DATABASE: mcs_lose
|
||||||
volumes:
|
volumes:
|
||||||
- mysql-data:/var/lib/mysql
|
- mysql-data:/var/lib/mysql
|
||||||
ports:
|
ports:
|
||||||
- "3308:3306"
|
- "8500:3306"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mysql-data:
|
mysql-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxynet:
|
||||||
|
external: true
|
||||||
|
lose-local:
|
||||||
|
external: false
|
||||||
|
@@ -7,6 +7,6 @@ RUN npm install
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
EXPOSE 8001
|
EXPOSE 8501
|
||||||
|
|
||||||
CMD ["npm", "run", "dev"]
|
CMD ["npm", "run", "dev"]
|
40
frontend/package-lock.json
generated
40
frontend/package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@tanstack/react-query": "^5.85.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.8.0",
|
"react-router-dom": "^7.8.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
"split-lines": "^3.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
@@ -1823,6 +1825,32 @@
|
|||||||
"vite": "^5.2.0 || ^6 || ^7"
|
"vite": "^5.2.0 || ^6 || ^7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/query-core": {
|
||||||
|
"version": "5.83.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.1.tgz",
|
||||||
|
"integrity": "sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/react-query": {
|
||||||
|
"version": "5.85.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.0.tgz",
|
||||||
|
"integrity": "sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/query-core": "5.83.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/babel__core": {
|
"node_modules/@types/babel__core": {
|
||||||
"version": "7.20.5",
|
"version": "7.20.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||||
@@ -4149,6 +4177,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split-lines": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split-lines/-/split-lines-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-d0TpRBL/VfKDXsk8JxPF7zgF5pCUDdBMSlEL36xBgVeaX448t+yGXcJaikUyzkoKOJ0l6KpMfygzJU9naIuivw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-json-comments": {
|
"node_modules/strip-json-comments": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||||
|
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@tanstack/react-query": "^5.85.0",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.539.0",
|
"lucide-react": "^0.539.0",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.8.0",
|
"react-router-dom": "^7.8.0",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
"split-lines": "^3.0.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.11",
|
"tailwindcss": "^4.1.11",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
8070
frontend/public/Beispiel.csv
Normal file
8070
frontend/public/Beispiel.csv
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,6 +10,8 @@ const Admin: React.FC = () => {
|
|||||||
() => Cookies.get("token") ?? null
|
() => Cookies.get("token") ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderAdmin
|
<HeaderAdmin
|
||||||
@@ -20,7 +22,7 @@ const Admin: React.FC = () => {
|
|||||||
{token ? (
|
{token ? (
|
||||||
<Table />
|
<Table />
|
||||||
) : (
|
) : (
|
||||||
<div className="p-4">Please log in as an admin.</div>
|
<div className="p-4">Bitte als Admin einloggen. Oder gehe <a className="text-blue-500 hover:underline" href="/">zurück</a>.</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
100
frontend/src/components/CircleUpload.tsx
Normal file
100
frontend/src/components/CircleUpload.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import React, { useRef, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
type CircleUploadProps = {
|
||||||
|
onFiles?: (files: File[]) => void;
|
||||||
|
accept?: string;
|
||||||
|
multiple?: boolean;
|
||||||
|
setFiles: (files: File[]) => void;
|
||||||
|
files?: File[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CircleUpload: React.FC<CircleUploadProps> = ({
|
||||||
|
onFiles,
|
||||||
|
accept = "",
|
||||||
|
multiple = true,
|
||||||
|
setFiles,
|
||||||
|
files = [],
|
||||||
|
}) => {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const processFiles = useCallback(
|
||||||
|
(fileList: FileList | null) => {
|
||||||
|
if (!fileList) return;
|
||||||
|
const arr = Array.from(fileList);
|
||||||
|
setFiles(arr);
|
||||||
|
onFiles?.(arr);
|
||||||
|
},
|
||||||
|
[onFiles]
|
||||||
|
);
|
||||||
|
|
||||||
|
const openFileDialog = () => inputRef.current?.click();
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
processFiles(e.dataTransfer.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === "dragenter" || e.type === "dragover") setIsDragging(true);
|
||||||
|
else if (e.type === "dragleave") setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={openFileDialog}
|
||||||
|
onKeyDown={(e) =>
|
||||||
|
(e.key === "Enter" || e.key === " ") && openFileDialog()
|
||||||
|
}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
className={[
|
||||||
|
"w-48 h-48 rounded-full border-2 border-dashed flex flex-col items-center justify-center text-center px-4 transition-colors cursor-pointer select-none",
|
||||||
|
"border-blue-400 text-blue-500 bg-blue-50/40 hover:bg-blue-50",
|
||||||
|
isDragging && "bg-blue-100 border-blue-500",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Dateien hierher ziehen
|
||||||
|
<br />
|
||||||
|
<span className="text-xs text-gray-500">oder klicken</span>
|
||||||
|
</span>
|
||||||
|
<span className="mt-2 inline-block rounded bg-blue-600 text-white text-xs px-2 py-1">
|
||||||
|
Upload
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept={accept}
|
||||||
|
multiple={multiple}
|
||||||
|
onChange={(e) => processFiles(e.target.files)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<ul className="w-56 max-h-32 overflow-auto text-xs bg-gray-50 border rounded p-2 space-y-1">
|
||||||
|
{files.map((f) => (
|
||||||
|
<li key={f.name} className="truncate">
|
||||||
|
{f.name}{" "}
|
||||||
|
<span className="text-gray-400">
|
||||||
|
({Math.round(f.size / 1024)} KB)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CircleUpload;
|
72
frontend/src/components/DangerZone.tsx
Normal file
72
frontend/src/components/DangerZone.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CircleX } from "lucide-react";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { resetData } from "../utils/tableActions";
|
||||||
|
|
||||||
|
type DangerZoneProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DangerZone: React.FC<DangerZoneProps> = ({ onClose }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-br from-black/60 via-black/40 to-rose-900/30 backdrop-blur-md" />
|
||||||
|
|
||||||
|
{/* Dialog Panel */}
|
||||||
|
<div className="relative z-10 w-11/12 max-w-lg rounded-3xl border border-rose-400/30 bg-white/95 p-8 shadow-2xl ring-2 ring-rose-400/20">
|
||||||
|
<div className="flex items-start justify-between gap-4 pb-4 border-b border-zinc-200">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-extrabold tracking-tight text-rose-700 mb-1">
|
||||||
|
Datenbank Einstellungen und Vitalwerte
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-zinc-500 font-medium">
|
||||||
|
Doppelklick um Befehl auszuführen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="group rounded-full p-2 text-rose-500 transition hover:bg-rose-100 hover:text-rose-700 active:scale-95 border border-rose-200 shadow-sm"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<CircleX className="h-7 w-7" strokeWidth={2.5} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-6 pt-8">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onDoubleClick={() => {
|
||||||
|
resetData(Cookies.get("token") || "");
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-2 justify-center rounded-xl bg-rose-800 px-6 py-4 text-base font-bold text-white shadow-lg transition hover:bg-rose-700 active:bg-rose-900 border-2 border-rose-800 focus:outline-none focus:ring-2 focus:ring-rose-500"
|
||||||
|
>
|
||||||
|
<span className="animate-pulse">⚠️</span> Alle Einträge der
|
||||||
|
Tabelle löschen
|
||||||
|
</button>
|
||||||
|
<div className="w-full text-center">
|
||||||
|
<span className="text-xs text-zinc-400">
|
||||||
|
Diese Aktion kann nicht rückgängig gemacht werden.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Actions */}
|
||||||
|
{/*
|
||||||
|
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-xl bg-zinc-300 px-5 py-3 text-sm font-bold text-zinc-800 shadow transition hover:bg-zinc-400 active:bg-zinc-500"
|
||||||
|
>
|
||||||
|
Einstellungen Speichern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DangerZone;
|
@@ -23,6 +23,7 @@ const HeaderAdmin: React.FC<HeaderAdminProps> = ({
|
|||||||
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 md:px-6">
|
<div className="mx-auto flex max-w-7xl items-center justify-between px-4 py-3 md:px-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Ticket className="h-8 w-8 text-black" strokeWidth={2.6} />
|
<Ticket className="h-8 w-8 text-black" strokeWidth={2.6} />
|
||||||
|
|
||||||
<h1 className="text-2xl font-black tracking-tight text-neutral-900 md:text-3xl">
|
<h1 className="text-2xl font-black tracking-tight text-neutral-900 md:text-3xl">
|
||||||
Admin Panel
|
Admin Panel
|
||||||
</h1>
|
</h1>
|
||||||
|
92
frontend/src/components/ImportGUI.tsx
Normal file
92
frontend/src/components/ImportGUI.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import React from "react";
|
||||||
|
import CircleUpload from "./CircleUpload";
|
||||||
|
import { CircleX } from "lucide-react";
|
||||||
|
import { postCSV } from "../utils/fileHandler";
|
||||||
|
|
||||||
|
type ImportGUIProps = {
|
||||||
|
onClose: () => void;
|
||||||
|
setFiles: (files: File[]) => void;
|
||||||
|
files?: File[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ImportGUI: React.FC<ImportGUIProps> = ({ onClose, setFiles, files }) => {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center pt-24">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" />
|
||||||
|
|
||||||
|
{/* Dialog Panel */}
|
||||||
|
<div className="relative z-10 w-11/12 max-w-lg rounded-2xl border border-black/10 bg-white/95 p-6 shadow-xl ring-1 ring-black/5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-extrabold tracking-tight text-zinc-900">
|
||||||
|
Losnummern importieren
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-600 leading-relaxed">
|
||||||
|
Importieren Sie Losnummern als strukturierte Datei. Unterstützte
|
||||||
|
Formate:
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{[".csv"].map((fileFormat) => (
|
||||||
|
<span
|
||||||
|
key={fileFormat}
|
||||||
|
className="inline-flex items-center rounded-full border border-black/10 bg-zinc-100 px-3 py-0.5 text-xs font-medium text-zinc-700 shadow-inner"
|
||||||
|
>
|
||||||
|
{fileFormat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<a href="/Beispiel.csv" download>
|
||||||
|
<button className="inline-flex justify-center rounded bg-zinc-300 px-1 py-1 text-xs text-zinc-800 shadow transition hover:bg-zinc-400 active:bg-zinc-500">
|
||||||
|
Beispiel (Download)
|
||||||
|
</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="group rounded-full p-1.5 text-zinc-500 transition hover:bg-zinc-200/70 hover:text-zinc-700 active:scale-95"
|
||||||
|
aria-label="Schließen"
|
||||||
|
>
|
||||||
|
<CircleX className="h-6 w-6" strokeWidth={2.25} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<CircleUpload accept=".csv" setFiles={setFiles} files={files} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder for optional file summary / mapping UI */}
|
||||||
|
<div className="mt-6 hidden rounded-xl border border-dashed border-black/15 bg-zinc-50/60 p-4 text-center text-xs text-zinc-500">
|
||||||
|
Ausgewählte Dateien werden hier aufgelistet.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-8 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex justify-center rounded-xl bg-zinc-300 px-5 py-3 text-sm font-bold text-zinc-800 shadow transition hover:bg-zinc-400 active:bg-zinc-500"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex justify-center rounded-xl bg-blue-600 px-6 py-3 text-sm font-bold text-white shadow transition hover:bg-blue-700 active:bg-blue-800 disabled:opacity-60 disabled:cursor-not-allowed"
|
||||||
|
disabled={!files || files.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (files && files.length) {
|
||||||
|
postCSV(files[0]);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImportGUI;
|
@@ -40,13 +40,13 @@ const LoginForm: React.FC<LoginFormProps> = ({ onClose, onLoginSuccess }) => {
|
|||||||
className="block text-sm font-medium text-zinc-800"
|
className="block text-sm font-medium text-zinc-800"
|
||||||
htmlFor="username"
|
htmlFor="username"
|
||||||
>
|
>
|
||||||
Username
|
Admin Benutzername
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="yourname"
|
placeholder="username"
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@@ -55,13 +55,13 @@ const LoginForm: React.FC<LoginFormProps> = ({ onClose, onLoginSuccess }) => {
|
|||||||
className="mt-3 block text-sm font-medium text-zinc-800"
|
className="mt-3 block text-sm font-medium text-zinc-800"
|
||||||
htmlFor="password"
|
htmlFor="password"
|
||||||
>
|
>
|
||||||
Password
|
Admin Passwort
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••"
|
placeholder="password"
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
@@ -25,13 +25,14 @@ const MainForm: React.FC = () => {
|
|||||||
htmlFor="losnummer"
|
htmlFor="losnummer"
|
||||||
className="text-sm font-medium text-zinc-800"
|
className="text-sm font-medium text-zinc-800"
|
||||||
>
|
>
|
||||||
Losnummer:
|
Losnummer, mit Bindestrichen eingeben:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="losnummer"
|
id="losnummer"
|
||||||
name="losnummer"
|
name="losnummer"
|
||||||
placeholder="XXXX-XXXX-XXXX-XXXX"
|
placeholder="XXXX-XXXX-XXXX-XXXX"
|
||||||
|
maxLength={255}
|
||||||
required
|
required
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none ring-0 transition focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none ring-0 transition focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
/>
|
/>
|
||||||
@@ -51,6 +52,7 @@ const MainForm: React.FC = () => {
|
|||||||
name="vorname"
|
name="vorname"
|
||||||
placeholder="Max"
|
placeholder="Max"
|
||||||
required
|
required
|
||||||
|
maxLength={100}
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,6 +70,7 @@ const MainForm: React.FC = () => {
|
|||||||
name="nachname"
|
name="nachname"
|
||||||
placeholder="Mustermann"
|
placeholder="Mustermann"
|
||||||
required
|
required
|
||||||
|
maxLength={100}
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,20 +89,22 @@ const MainForm: React.FC = () => {
|
|||||||
name="adresse"
|
name="adresse"
|
||||||
placeholder="Musterstraße 1"
|
placeholder="Musterstraße 1"
|
||||||
required
|
required
|
||||||
|
maxLength={255}
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label htmlFor="plz" className="text-sm font-medium text-zinc-800">
|
<label htmlFor="plz" className="text-sm font-medium text-zinc-800">
|
||||||
Postleitzahl + Ort:
|
Postleitzahl:
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="plz"
|
id="plz"
|
||||||
name="plz"
|
name="plz"
|
||||||
placeholder="12345 Musterstadt"
|
placeholder="12345"
|
||||||
required
|
required
|
||||||
|
maxLength={10}
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,6 +119,7 @@ const MainForm: React.FC = () => {
|
|||||||
name="email"
|
name="email"
|
||||||
placeholder="max@mustermann.de"
|
placeholder="max@mustermann.de"
|
||||||
required
|
required
|
||||||
|
maxLength={255}
|
||||||
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
className="w-full rounded-xl border border-black/25 bg-white/80 px-4 py-2.5 text-sm text-zinc-800 placeholder-zinc-400 shadow-inner outline-none focus:border-black/40 focus:ring-2 focus:ring-black/10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,7 +131,8 @@ const MainForm: React.FC = () => {
|
|||||||
Los registrieren
|
Los registrieren
|
||||||
</button>
|
</button>
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
Wenn Sie die Daten eines Loses bearbeiten möchten,{" "}
|
Wenn Sie die Daten eines bereits registrierten Loses bearbeiten
|
||||||
|
möchten,{" "}
|
||||||
<a
|
<a
|
||||||
className="text-blue-600 underline"
|
className="text-blue-600 underline"
|
||||||
href="mailto:example@example.com"
|
href="mailto:example@example.com"
|
||||||
|
@@ -1,47 +1,108 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Sheet, WholeWord } from "lucide-react";
|
import { Sheet, WholeWord, Search, DatabaseZap } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ImportGUI from "./ImportGUI";
|
||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { removeSelection } from "../utils/tableActions";
|
||||||
|
import DangerZone from "./DangerZone";
|
||||||
|
|
||||||
|
type SubHeaderAdminProps = {
|
||||||
|
setFiles: (files: File[]) => void;
|
||||||
|
files?: File[];
|
||||||
|
search: string;
|
||||||
|
setSearch: (value: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
// Sub navigation bar for admin views: provides import + clear selection actions
|
// Sub navigation bar for admin views: provides import + clear selection actions
|
||||||
const SubHeaderAdmin: React.FC = () => {
|
const SubHeaderAdmin: React.FC<SubHeaderAdminProps> = ({
|
||||||
|
setFiles,
|
||||||
|
files,
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
}) => {
|
||||||
|
const [showImport, setShowImport] = useState(false);
|
||||||
|
const [showDanger, setShowDanger] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-30 w-full border-b border-gray-200/70 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
<>
|
||||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2.5 md:px-6">
|
<header className="sticky top-0 z-30 w-full border-b border-gray-200/70 bg-white/90 backdrop-blur supports-[backdrop-filter]:bg-white/60">
|
||||||
<div className="flex items-center gap-3">
|
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2.5 md:px-6">
|
||||||
<h2 className="text-base font-semibold tracking-tight text-gray-800 sm:text-lg">
|
<div className="flex items-center gap-3">
|
||||||
Verwaltung
|
<h2 className="text-base font-semibold tracking-tight text-gray-800 sm:text-lg">
|
||||||
</h2>
|
Verwaltung
|
||||||
<span
|
</h2>
|
||||||
className="hidden h-5 w-px bg-gray-300 sm:inline-block"
|
<span
|
||||||
aria-hidden="true"
|
className="hidden h-5 w-px bg-gray-300 sm:inline-block"
|
||||||
/>
|
aria-hidden="true"
|
||||||
<p className="hidden text-sm text-gray-500 md:block">
|
|
||||||
Aktionen für Daten in der Datenbank
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="group inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<Sheet
|
|
||||||
className="h-4 w-4 shrink-0 text-gray-500 transition group-hover:text-gray-600"
|
|
||||||
strokeWidth={1.75}
|
|
||||||
/>
|
/>
|
||||||
<span className="whitespace-nowrap">Losnummern importieren</span>
|
<p className="hidden text-sm text-gray-500 md:block">
|
||||||
</button>
|
Aktionen für Daten in der Datenbank
|
||||||
<button
|
</p>
|
||||||
type="button"
|
</div>
|
||||||
className="group inline-flex items-center gap-2 rounded-md bg-rose-600 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/60 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
<div className="flex items-center gap-2">
|
||||||
>
|
{/* Search */}
|
||||||
<WholeWord
|
<div className="relative hidden sm:block">
|
||||||
className="h-4 w-4 shrink-0 text-white/90 transition group-hover:text-white"
|
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||||
strokeWidth={1.75}
|
<input
|
||||||
/>
|
type="text"
|
||||||
<span className="whitespace-nowrap">Auswahl löschen</span>
|
value={search}
|
||||||
</button>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Suchen… (Losnummer, Name, Adresse, PLZ, Email)"
|
||||||
|
className="w-72 rounded-md border border-gray-300 bg-white pl-9 pr-3 py-2 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowImport(true)}
|
||||||
|
type="button"
|
||||||
|
className="group inline-flex items-center gap-2 rounded-md border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/60 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Sheet
|
||||||
|
className="h-4 w-4 shrink-0 text-gray-500 transition group-hover:text-gray-600"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap">Losnummern importieren</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
removeSelection(Cookies.get("token") || "");
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="group inline-flex items-center gap-2 rounded-md bg-rose-600 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/60 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<WholeWord
|
||||||
|
className="h-4 w-4 shrink-0 text-white/90 transition group-hover:text-white"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap">Auswahl löschen</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowDanger(true);
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className="group inline-flex items-center gap-2 rounded-md bg-rose-800 px-3.5 py-2 text-sm font-medium text-white shadow-sm transition hover:bg-rose-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-500/60 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<DatabaseZap
|
||||||
|
className="h-4 w-4 shrink-0 text-white/90 transition group-hover:text-white"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
|
<span className="whitespace-nowrap">Datenbank Einstellungen</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
</header>
|
{showImport && (
|
||||||
|
<ImportGUI
|
||||||
|
onClose={() => setShowImport(false)}
|
||||||
|
setFiles={setFiles}
|
||||||
|
files={files}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDanger && (
|
||||||
|
<DangerZone onClose={() => setShowDanger(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,8 +1,16 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, {
|
||||||
|
useDeferredValue,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import Cookies from "js-cookie";
|
import Cookies from "js-cookie";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getTableData, readCachedTableData } from "../utils/userHandler";
|
import { getTableData, readCachedTableData } from "../utils/userHandler";
|
||||||
import { EllipsisVertical } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import SubHeaderAdmin from "./SubHeaderAdmin";
|
import SubHeaderAdmin from "./SubHeaderAdmin";
|
||||||
|
import { addToRemove, rmFromRemove, saveRow } from "../utils/tableActions";
|
||||||
|
|
||||||
interface DataPackage {
|
interface DataPackage {
|
||||||
losnummer: string;
|
losnummer: string;
|
||||||
@@ -11,13 +19,41 @@ interface DataPackage {
|
|||||||
adresse: string | null;
|
adresse: string | null;
|
||||||
plz: string | null;
|
plz: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
_search?: string;
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table: React.FC = () => {
|
const Table: React.FC = () => {
|
||||||
const [rows, setRows] = useState<DataPackage[]>([]);
|
const [rows, setRows] = useState<DataPackage[]>([]); // holds normalized cache view
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [search, setSearch] = useState("");
|
||||||
|
const deferredSearch = useDeferredValue(search);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const firstRowRef = useRef<HTMLTableRowElement | null>(null);
|
||||||
|
const [rowHeight, setRowHeight] = useState<number>(0);
|
||||||
|
const [containerHeight, setContainerHeight] = useState<number>();
|
||||||
|
const [range, setRange] = useState<{ start: number; end: number }>({
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
});
|
||||||
|
const OVERSCAN = 30; // Render 30 rows above and below the viewport
|
||||||
|
|
||||||
|
// Build a canonical lowercase search text for a row
|
||||||
|
const buildSearchText = (r: DataPackage) =>
|
||||||
|
[
|
||||||
|
r.losnummer,
|
||||||
|
r.vorname ?? "",
|
||||||
|
r.nachname ?? "",
|
||||||
|
r.adresse ?? "",
|
||||||
|
r.plz ?? "",
|
||||||
|
r.email ?? "",
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
// Einheitliche Input-Styles (nur Tailwind)
|
||||||
|
const inputClasses =
|
||||||
|
"w-full h-10 px-3 rounded-md border border-gray-300 bg-white text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
|
||||||
|
|
||||||
// Hilfsfunktion zum Einlesen & Normalisieren der LocalStorage-Daten
|
// Hilfsfunktion zum Einlesen & Normalisieren der LocalStorage-Daten
|
||||||
const loadFromCache = () => {
|
const loadFromCache = () => {
|
||||||
@@ -27,32 +63,33 @@ const Table: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Server könnte entweder ein Objekt oder ein Array liefern
|
// Server könnte entweder ein Objekt oder ein Array liefern
|
||||||
const normalized: DataPackage[] = Array.isArray(cached)
|
const normalized: DataPackage[] = (
|
||||||
? cached
|
Array.isArray(cached) ? cached : [cached]
|
||||||
: [cached];
|
).map((r: DataPackage) => ({ ...r, _search: buildSearchText(r) }));
|
||||||
setRows(normalized);
|
setRows(normalized);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const token = Cookies.get("token") || "";
|
||||||
|
|
||||||
|
const tableQuery = useQuery({
|
||||||
|
queryKey: ["table-data", token],
|
||||||
|
enabled: !!token,
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await getTableData(token);
|
||||||
|
|
||||||
|
if (data === null) throw new Error("Fehler beim Laden der Daten.");
|
||||||
|
return data as unknown as DataPackage[] | DataPackage; // server may send single object
|
||||||
|
},
|
||||||
|
refetchOnMount: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync normalized cached data into local state whenever query succeeds or cache changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Initial lokale Daten laden (falls schon vorhanden)
|
|
||||||
loadFromCache();
|
loadFromCache();
|
||||||
|
Cookies.remove("removeArr");
|
||||||
|
}, [tableQuery.data]);
|
||||||
|
|
||||||
// Frische Daten vom Backend holen
|
// Fehleranzeige ist aktuell nicht sichtbar (auskommentierte UI).
|
||||||
const token = Cookies.get("token") || "";
|
|
||||||
if (!token) return; // Kein Token => nur Cache anzeigen
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
getTableData(token)
|
|
||||||
.then((data) => {
|
|
||||||
if (data === null) {
|
|
||||||
setError("Fehler beim Laden der Daten.");
|
|
||||||
} else {
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
loadFromCache();
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Reagieren auf LocalStorage-Änderungen (z.B. in anderen Tabs)
|
// Reagieren auf LocalStorage-Änderungen (z.B. in anderen Tabs)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,120 +102,317 @@ const Table: React.FC = () => {
|
|||||||
return () => window.removeEventListener("storage", handler);
|
return () => window.removeEventListener("storage", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const formatValue = (v: any) =>
|
// Filter rows by search query (case-insensitive)
|
||||||
v === null || v === undefined || v === "" ? "-" : String(v);
|
const filteredRows = useMemo(() => {
|
||||||
|
const q = deferredSearch.trim().toLowerCase();
|
||||||
|
if (!q) return rows;
|
||||||
|
return rows.filter((r) => (r._search ?? buildSearchText(r)).includes(q));
|
||||||
|
}, [rows, deferredSearch]);
|
||||||
|
|
||||||
|
// Measure row height once the first visible row mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!firstRowRef.current) return;
|
||||||
|
const h = firstRowRef.current.offsetHeight;
|
||||||
|
if (h && h !== rowHeight) setRowHeight(h);
|
||||||
|
}, [firstRowRef.current, rowHeight, range.start]);
|
||||||
|
|
||||||
|
// Compute the current visible range based on scroll position and container size
|
||||||
|
const recomputeRange = () => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const total = filteredRows.length;
|
||||||
|
if (total === 0) {
|
||||||
|
if (range.start !== 0 || range.end !== 0) setRange({ start: 0, end: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rh = rowHeight || 52; // Fallback estimate until measured
|
||||||
|
const scrollTop = container.scrollTop;
|
||||||
|
const containerHeight = container.clientHeight || 0;
|
||||||
|
const firstVisible = Math.floor(scrollTop / rh);
|
||||||
|
const visibleCount = Math.max(1, Math.ceil(containerHeight / rh));
|
||||||
|
const start = Math.max(0, firstVisible - OVERSCAN);
|
||||||
|
const end = Math.min(total, firstVisible + visibleCount + OVERSCAN);
|
||||||
|
if (start !== range.start || end !== range.end) setRange({ start, end });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Attach scroll listener
|
||||||
|
useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const onScroll = () => {
|
||||||
|
// use requestAnimationFrame to keep it smooth
|
||||||
|
requestAnimationFrame(recomputeRange);
|
||||||
|
};
|
||||||
|
el.addEventListener("scroll", onScroll, { passive: true });
|
||||||
|
// Initial compute
|
||||||
|
recomputeRange();
|
||||||
|
return () => el.removeEventListener("scroll", onScroll);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [rowHeight, filteredRows.length]);
|
||||||
|
|
||||||
|
// Make the scroll container fill to the bottom of the viewport
|
||||||
|
useEffect(() => {
|
||||||
|
const compute = () => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const available = Math.max(0, window.innerHeight - rect.top - 16); // 16px bottom breathing room
|
||||||
|
if (!containerHeight || Math.abs(available - containerHeight) > 1) {
|
||||||
|
setContainerHeight(available);
|
||||||
|
requestAnimationFrame(recomputeRange);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
compute();
|
||||||
|
window.addEventListener("resize", compute);
|
||||||
|
return () => window.removeEventListener("resize", compute);
|
||||||
|
// Re-evaluate when content above changes size noticeably
|
||||||
|
}, [deferredSearch, rows.length]);
|
||||||
|
|
||||||
|
// Recompute range whenever data set changes significantly (search, data load)
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset scroll position when filter changes to show top of list
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (el) el.scrollTop = 0;
|
||||||
|
// Next frame compute range for new data
|
||||||
|
requestAnimationFrame(recomputeRange);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [deferredSearch, rows.length]);
|
||||||
|
|
||||||
|
// Handles input changes for table rows
|
||||||
|
const handleInputChange = (
|
||||||
|
losnummer: string,
|
||||||
|
field: keyof DataPackage,
|
||||||
|
value: string
|
||||||
|
) => {
|
||||||
|
setRows((prevRows) =>
|
||||||
|
prevRows.map((row) =>
|
||||||
|
row.losnummer === losnummer
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
[field]: value,
|
||||||
|
_search: buildSearchText({
|
||||||
|
...(row as DataPackage),
|
||||||
|
[field]: value,
|
||||||
|
} as DataPackage),
|
||||||
|
}
|
||||||
|
: row
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// (filteredRows defined above)
|
||||||
|
|
||||||
|
const start = range.start;
|
||||||
|
const end =
|
||||||
|
range.end > 0 ? range.end : Math.min(filteredRows.length, 2 * OVERSCAN);
|
||||||
|
const visibleSlice = filteredRows.slice(start, end);
|
||||||
|
const rh = rowHeight || 52;
|
||||||
|
const topPadding = start * rh;
|
||||||
|
const bottomPadding = Math.max(0, (filteredRows.length - end) * rh);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SubHeaderAdmin />
|
<SubHeaderAdmin
|
||||||
|
setFiles={setFiles}
|
||||||
|
files={files}
|
||||||
|
search={search}
|
||||||
|
setSearch={setSearch}
|
||||||
|
/>
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
|
{/*
|
||||||
<div className="mb-4 flex items-center gap-3">
|
<div className="mb-4 flex items-center gap-3">
|
||||||
{loading && (
|
{(tableQuery.isLoading || tableQuery.isFetching) && (
|
||||||
<span className="text-xs text-blue-600 animate-pulse">
|
<span className="text-xs text-blue-600 animate-pulse">
|
||||||
Laden...
|
Laden...
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{error && <span className="text-xs text-red-600">{error}</span>}
|
{error && <span className="text-xs text-red-600">{error}</span>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => tableQuery.refetch()}
|
||||||
|
className="text-xs rounded border px-2 py-1 text-gray-600 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-x-auto rounded-lg shadow ring-1 ring-black/5">
|
*/}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="overflow-auto rounded-lg shadow ring-1 ring-black/5"
|
||||||
|
style={{ height: containerHeight }}
|
||||||
|
>
|
||||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left invisible font-medium uppercase tracking-wide text-gray-600 w-10 min-w-[2.5rem]"
|
||||||
>
|
>
|
||||||
<input type="checkbox" name="" id="" />
|
<input type="checkbox" name="" id="" />
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-[10rem] min-w-[10rem]"
|
||||||
>
|
>
|
||||||
Losnummer
|
Losnummer
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-[14rem] min-w-[14rem]"
|
||||||
>
|
>
|
||||||
Vorname
|
Vorname
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-[14rem] min-w-[14rem]"
|
||||||
>
|
>
|
||||||
Nachname
|
Nachname
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-[14rem] min-w-[14rem]"
|
||||||
>
|
>
|
||||||
Adresse
|
Adresse
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-[14rem] min-w-[14rem]"
|
||||||
>
|
>
|
||||||
PLZ
|
PLZ
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-[14rem] min-w-[14rem]"
|
||||||
>
|
>
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th
|
||||||
scope="col"
|
scope="col"
|
||||||
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600"
|
className="px-4 py-2 text-left font-medium uppercase tracking-wide text-gray-600 w-12 min-w-[3rem]"
|
||||||
></th>
|
></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 bg-white">
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
{rows.length === 0 && !loading && (
|
{filteredRows.length === 0 && !tableQuery.isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={6}
|
colSpan={8}
|
||||||
className="px-4 py-6 text-center text-gray-500"
|
className="px-4 py-6 text-center text-gray-500"
|
||||||
>
|
>
|
||||||
Keine Daten vorhanden.
|
Keine Daten vorhanden.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{rows.map((row, idx) => (
|
{filteredRows.length > 0 && (
|
||||||
|
<tr key="top-spacer" style={{ height: topPadding }}>
|
||||||
|
<td colSpan={8} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{visibleSlice.map((row, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={row.losnummer ?? idx}
|
key={row.losnummer ?? start + i}
|
||||||
className="hover:bg-gray-50 transition-colors"
|
className="hover:bg-gray-50 transition-colors"
|
||||||
|
ref={i === 0 ? firstRowRef : undefined}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-2 font-mono text-xs text-gray-900">
|
<td className="px-4 py-2 font-mono text-xs text-gray-900 w-10 min-w-[2.5rem]">
|
||||||
<input type="checkbox" name="" id={row.losnummer} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
addToRemove(row.losnummer);
|
||||||
|
} else {
|
||||||
|
rmFromRemove(row.losnummer);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
id={row.losnummer}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 font-mono text-xs text-gray-900">
|
<td className="px-4 py-2 font-mono text-xs text-gray-900 w-[10rem] min-w-[10rem]">
|
||||||
{formatValue(row.losnummer)}
|
{row.losnummer}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 w-[14rem] min-w-[14rem]">
|
||||||
<input type="text" value={formatValue(row.vorname)} />
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClasses}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleInputChange(
|
||||||
|
row.losnummer,
|
||||||
|
"vorname",
|
||||||
|
e.target.value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={row.vorname ?? ""}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 w-[14rem] min-w-[14rem]">
|
||||||
<input type="text" value={formatValue(row.nachname)} />
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClasses}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleInputChange(
|
||||||
|
row.losnummer,
|
||||||
|
"nachname",
|
||||||
|
e.target.value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={row.nachname ?? ""}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
className="px-4 py-2 max-w-[16rem] truncate"
|
className="px-4 py-2 w-[14rem] min-w-[14rem]"
|
||||||
title={formatValue(row.adresse)}
|
title={row.adresse ?? ""}
|
||||||
>
|
>
|
||||||
<input type="text" value={formatValue(row.adresse)} />
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClasses}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleInputChange(
|
||||||
|
row.losnummer,
|
||||||
|
"adresse",
|
||||||
|
e.target.value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={row.adresse ?? ""}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 w-[14rem] min-w-[14rem]">
|
||||||
<input type="text" value={formatValue(row.plz)} />
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClasses}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleInputChange(row.losnummer, "plz", e.target.value);
|
||||||
|
}}
|
||||||
|
value={row.plz ?? ""}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 w-[14rem] min-w-[14rem]">
|
||||||
<input type="text" value={formatValue(row.email)} />
|
<input
|
||||||
|
type="text"
|
||||||
|
className={inputClasses}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleInputChange(
|
||||||
|
row.losnummer,
|
||||||
|
"email",
|
||||||
|
e.target.value
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
value={row.email ?? ""}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2 w-12 min-w-[3rem]">
|
||||||
<button>
|
<button onClick={() => saveRow(row, token)}>
|
||||||
<EllipsisVertical />
|
<Save />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{filteredRows.length > 0 && (
|
||||||
|
<tr key="bottom-spacer" style={{ height: bottomPadding }}>
|
||||||
|
<td colSpan={8} />
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,10 +1,14 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from "react-dom/client";
|
||||||
import './index.css'
|
import "./index.css";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { queryClient } from "./queryClient";
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<QueryClientProvider client={queryClient}>
|
||||||
</StrictMode>,
|
<App />
|
||||||
)
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
11
frontend/src/queryClient.ts
Normal file
11
frontend/src/queryClient.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Central QueryClient instance so utilities (e.g. file upload) can invalidate queries.
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
76
frontend/src/utils/fileHandler.ts
Normal file
76
frontend/src/utils/fileHandler.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { myToast } from "./toastify";
|
||||||
|
import { queryClient } from "../queryClient";
|
||||||
|
|
||||||
|
export function downloadHandler(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postCSV(file: File): Promise<boolean> {
|
||||||
|
// Reads a CSV file client-side and returns a Promise<string[]> where each
|
||||||
|
// entry is one non-empty trimmed line from the file. Uses the lightweight
|
||||||
|
// split-lines library to normalize different newline styles.
|
||||||
|
const readerLines = new Promise<string[]>((resolve, reject) => {
|
||||||
|
if (!file) {
|
||||||
|
resolve([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onerror = () =>
|
||||||
|
reject(reader.error ?? new Error("Failed to read file"));
|
||||||
|
reader.onload = () => {
|
||||||
|
try {
|
||||||
|
// Lazy import to avoid bundling if unused elsewhere
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
import("split-lines")
|
||||||
|
.then(({ default: splitLines }) => {
|
||||||
|
const text = String(reader.result ?? "");
|
||||||
|
// split-lines preserves empty lines if keepNewlines = false; we filter afterwards
|
||||||
|
const lines = splitLines(text, { preserveNewlines: false })
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l.length > 0);
|
||||||
|
resolve(lines);
|
||||||
|
})
|
||||||
|
.catch((err) => reject(err));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file, "utf-8");
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = await readerLines;
|
||||||
|
|
||||||
|
// Send token if available
|
||||||
|
const token = (await import("js-cookie")).default.get("token");
|
||||||
|
|
||||||
|
// Chunk uploads to avoid huge single payloads
|
||||||
|
const chunkSize = 2000; // ~2k per request => 25 requests for 50k
|
||||||
|
for (let i = 0; i < lines.length; i += chunkSize) {
|
||||||
|
const chunk = lines.slice(i, i + chunkSize);
|
||||||
|
const res = await fetch("https://backend.lose.the1s.de/create-entry", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ losnummer: chunk }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
myToast(`Fehler beim Importieren (Batch ${i / chunkSize + 1}).`, "error");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
myToast("CSV Datei erfolgreich importiert!", "success");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["table-data"] });
|
||||||
|
return true;
|
||||||
|
}
|
@@ -20,7 +20,7 @@ export const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|||||||
|
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch("http://localhost:8002/login", {
|
response = await fetch("https://backend.lose.the1s.de/login", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
@@ -44,6 +44,6 @@ export const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Cookies.set("token", data.token, { sameSite: "strict" });
|
Cookies.set("token", data.token, { sameSite: "strict" });
|
||||||
myToast("Login successful", "success");
|
myToast("Login successful!", "success");
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
import { myToast } from "./toastify";
|
import { myToast } from "./toastify";
|
||||||
|
|
||||||
export const registerLos = async (data: any) => {
|
export const registerLos = async (data: any) => {
|
||||||
await fetch("http://localhost:8002/lose", {
|
await fetch("https://backend.lose.the1s.de/lose", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
79
frontend/src/utils/tableActions.ts
Normal file
79
frontend/src/utils/tableActions.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Cookies from "js-cookie";
|
||||||
|
import { myToast } from "./toastify";
|
||||||
|
import { queryClient } from "../queryClient";
|
||||||
|
|
||||||
|
let removeArr: string[] = [];
|
||||||
|
|
||||||
|
export const addToRemove = (losnummer: string) => {
|
||||||
|
removeArr.push(losnummer);
|
||||||
|
const rawCookies = Cookies.withConverter({
|
||||||
|
write: (value: string, _name: string) => value,
|
||||||
|
});
|
||||||
|
rawCookies.set("removeArr", JSON.stringify(removeArr));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const rmFromRemove = (losnummer: string) => {
|
||||||
|
removeArr = removeArr.filter((item) => item !== losnummer);
|
||||||
|
const rawCookies = Cookies.withConverter({
|
||||||
|
write: (value: string, _name: string) => value,
|
||||||
|
});
|
||||||
|
rawCookies.set("removeArr", JSON.stringify(removeArr));
|
||||||
|
};
|
||||||
|
|
||||||
|
function createHeaders(token: string) {
|
||||||
|
return {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const removeSelection = (token: string) => {
|
||||||
|
const selection = Cookies.get("removeArr");
|
||||||
|
if (selection && selection !== "[]") {
|
||||||
|
fetch("https://backend.lose.the1s.de/remove-entries", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: createHeaders(token),
|
||||||
|
body: `{
|
||||||
|
"losnummern": ${selection}
|
||||||
|
}`,
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
myToast("Einträge erfolgreich entfernt.", "success");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["table-data"] });
|
||||||
|
} else {
|
||||||
|
myToast("Fehler beim Entfernen der Einträge.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
myToast("Keine Einträge zum Entfernen ausgewählt.", "info");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveRow = (data: any, token: string) => {
|
||||||
|
fetch("https://backend.lose.the1s.de/save-row", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: createHeaders(token),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
myToast("Eintrag erfolgreich gespeichert.", "success");
|
||||||
|
} else {
|
||||||
|
myToast("Fehler beim Speichern des Eintrags.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resetData = (token: string) => {
|
||||||
|
fetch("https://backend.lose.the1s.de/reset-data", {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: createHeaders(token),
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) {
|
||||||
|
myToast("Daten erfolgreich zurückgesetzt.", "success");
|
||||||
|
localStorage.removeItem("tableData");
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["table-data"] });
|
||||||
|
} else {
|
||||||
|
myToast("Fehler beim Zurücksetzen der Daten.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@@ -10,7 +10,7 @@ export const logoutAdmin = () => {
|
|||||||
// Fetch table data and store it in localStorage. Returns the parsed data or null on failure.
|
// Fetch table data and store it in localStorage. Returns the parsed data or null on failure.
|
||||||
export const getTableData = async (token: string) => {
|
export const getTableData = async (token: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch("http://localhost:8002/table-data", {
|
const response = await fetch("https://backend.lose.the1s.de/table-data", {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
@@ -20,19 +20,22 @@ export const getTableData = async (token: string) => {
|
|||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
myToast("Session expired. Please log in again.", "error");
|
myToast("Session expired. Please log in again.", "error");
|
||||||
logoutAdmin();
|
logoutAdmin();
|
||||||
return null;
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const text = await response.text().catch(() => "");
|
const text = await response.text().catch(() => "");
|
||||||
myToast(`Error fetching table data! ${text}`, "error");
|
myToast(`Error fetching table data! ${text}`, "error");
|
||||||
return null;
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure we parse JSON
|
// Ensure we parse JSON
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
localStorage.setItem("tableData", JSON.stringify(data));
|
localStorage.setItem("tableData", JSON.stringify(data));
|
||||||
myToast("Table data fetched successfully!", "success");
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
myToast(`Error fetching table data! ${error?.message || error}`, "error");
|
myToast(`Error fetching table data! ${error?.message || error}`, "error");
|
||||||
|
@@ -1,15 +1,18 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import svgr from "vite-plugin-svgr";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), svgr(), tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
port: 8001,
|
allowedHosts: ["lose.the1s.de"],
|
||||||
watch: {
|
port: 8501,
|
||||||
usePolling: true,
|
https: false,
|
||||||
|
watch: { usePolling: true },
|
||||||
|
hmr: {
|
||||||
|
host: "lose.the1s.de",
|
||||||
|
port: 8501,
|
||||||
|
protocol: "wss",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
Reference in New Issue
Block a user