Compare commits

...

18 Commits

Author SHA1 Message Date
a910320b16 delted unused imports 2025-08-16 21:03:35 +02:00
b8d609ac28 Added export button, but without any function
- The function comes later...
2025-08-16 21:03:12 +02:00
5c6373d16d rewrited documentation 2025-08-15 10:02:50 +02:00
7da66b52be added documentation 2025-08-14 16:29:51 +02:00
f2433e2d84 feat: enhance token handling in removeSelection and saveRow functions 2025-08-14 16:14:18 +02:00
40d5f35afb feat: optimize table component with deferred search and dynamic container height 2025-08-14 12:01:38 +02:00
9d64e7c274 Implement virtual scrolling in Table component for improved performance
- Added useRef to track container and first row for height measurement.
- Introduced rowHeight state to dynamically calculate row height.
- Implemented range state to manage visible rows based on scroll position.
- Added scroll event listener to compute visible range efficiently.
- Adjusted rendering logic to display only a subset of rows based on the computed range.
- Included top and bottom spacers to maintain table height during scrolling.
- Refactored input change handling to remain functional with new rendering logic.
2025-08-14 11:37:28 +02:00
214a3cb3c8 feat: enhance CSV upload handling with chunked requests and token support; improve form input limits 2025-08-14 10:38:35 +02:00
bb63388986 feat: update losnummer label to include input format instructions 2025-08-13 22:09:57 +02:00
c4c805083a feat: add maxLength validation to form inputs and improve saveRow feedback 2025-08-13 22:08:54 +02:00
d77302c532 added scheme 2025-08-13 22:03:20 +02:00
a9f26b1148 fix: update Beispiel.csv with correct test data 2025-08-13 21:52:56 +02:00
3096e4ab83 feat: add entry removal and row saving functionality, enhance admin components with localization 2025-08-13 21:48:19 +02:00
5792ce154f removed unnessearry stuff 2025-08-13 16:43:53 +02:00
dd4fcea1b0 feat: enhance CSV import functionality, integrate react-query for data fetching, and refactor admin components 2025-08-13 16:43:26 +02:00
42d68181f9 created backend to create lottery entries manually 2025-08-13 12:21:12 +02:00
42f46db2c9 feat: implement admin login form localization, add import functionality, and enhance file handling 2025-08-13 11:57:50 +02:00
9dd7b6641a added import lottery number gui, but currently only frontend. The backend is currently NaN. 2025-08-13 11:57:33 +02:00
22 changed files with 9224 additions and 128 deletions

View File

@@ -1 +1,83 @@
# MCS Lose # MCS TicketHub
[![Node.js](https://img.shields.io/badge/Node.js-%23339933?logo=node.js&logoColor=white)](https://nodejs.org/)
[![Express](https://img.shields.io/badge/Express-%236a6a6a?logo=express&logoColor=white)](https://expressjs.com/)
[![React](https://img.shields.io/badge/React-%2361DAFB?logo=react&logoColor=black)](https://reactjs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-%23007ACC?logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
[![Vite](https://img.shields.io/badge/Vite-%23646CFF?logo=vite&logoColor=white)](https://vitejs.dev/)
[![MySQL](https://img.shields.io/badge/MySQL-%2300758F?logo=mysql&logoColor=white)](https://www.mysql.com/)
[![Docker](https://img.shields.io/badge/Docker-%230db7ed?logo=docker&logoColor=white)](https://www.docker.com/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind%20CSS-%2338B2AC?logo=tailwindcss&logoColor=white)](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.

13
backend/scheme.sql Normal file
View 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
);

View File

@@ -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 = 8002;
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 });
@@ -25,12 +34,48 @@ app.post("/lose", async (req, res) => {
} }
}); });
// !!!!!!! AUTHORISATION HINZUFÜGEN - DENN GEHT NICHT !!!!!!!!
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 +90,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}`);
}); });

View File

@@ -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 }; 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 };
}
} }

View File

@@ -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",

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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>
)} )}
</> </>
); );

View 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;

View 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;

View File

@@ -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>

View 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;

View File

@@ -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
/> />

View File

@@ -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"

View File

@@ -1,9 +1,38 @@
import React from "react"; import React, { useEffect } from "react";
import { Sheet, WholeWord } from "lucide-react"; import { Sheet, WholeWord, Search, DatabaseZap, FileDown } 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";
import { myToast } from "../utils/toastify";
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);
const [exporting, setExporting] = useState(false);
useEffect(() => {
if (exporting) {
myToast("Export in Bearbeitung...", "info");
}
}, [exporting]);
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"> <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"> <div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2.5 md:px-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -19,7 +48,19 @@ const SubHeaderAdmin: React.FC = () => {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Search */}
<div className="relative hidden sm:block">
<Search className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={search}
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 <button
onClick={() => setShowImport(true)}
type="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" 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"
> >
@@ -30,6 +71,20 @@ const SubHeaderAdmin: React.FC = () => {
<span className="whitespace-nowrap">Losnummern importieren</span> <span className="whitespace-nowrap">Losnummern importieren</span>
</button> </button>
<button <button
onClick={() => setExporting(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"
>
<FileDown
className="h-4 w-4 shrink-0 text-gray-500 transition group-hover:text-gray-600"
strokeWidth={1.75}
/>
<span className="whitespace-nowrap">Losnummern exportieren</span>
</button>
<button
onClick={() => {
removeSelection(Cookies.get("token") || "");
}}
type="button" 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" 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"
> >
@@ -39,9 +94,32 @@ const SubHeaderAdmin: React.FC = () => {
/> />
<span className="whitespace-nowrap">Auswahl löschen</span> <span className="whitespace-nowrap">Auswahl löschen</span>
</button> </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)} />}
</>
); );
}; };

View File

@@ -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);
}; };
useEffect(() => {
// Initial lokale Daten laden (falls schon vorhanden)
loadFromCache();
// Frische Daten vom Backend holen
const token = Cookies.get("token") || ""; const token = Cookies.get("token") || "";
if (!token) return; // Kein Token => nur Cache anzeigen
setLoading(true); const tableQuery = useQuery({
getTableData(token) queryKey: ["table-data", token],
.then((data) => { enabled: !!token,
if (data === null) { queryFn: async () => {
setError("Fehler beim Laden der Daten."); const data = await getTableData(token);
} else {
setError(null); 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(() => {
loadFromCache(); loadFromCache();
}) Cookies.remove("removeArr");
.finally(() => setLoading(false)); }, [tableQuery.data]);
}, []);
// Fehleranzeige ist aktuell nicht sichtbar (auskommentierte UI).
// 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>

View File

@@ -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>
<QueryClientProvider client={queryClient}>
<App /> <App />
</StrictMode>, </QueryClientProvider>
) </StrictMode>
);

View File

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

View File

@@ -0,0 +1,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("http://localhost:8002/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;
}

View File

@@ -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;
}; };

View 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("http://localhost:8002/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("http://localhost:8002/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("http://localhost:8002/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");
}
});
};

View File

@@ -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");