Compare commits

..

55 Commits

Author SHA1 Message Date
titver968
838a38fc18 prisma migration 2026-01-09 09:37:31 +01:00
titver968
0e21b78a4c Felder Validierung für E-Mail, PLZ und Telefonnummer. Eingabe akzeptieren wenn wenniger als 3 Plätze vorhanden sind. 2026-01-09 09:34:08 +01:00
titver968
a99ddf6fa9 praktikum, Notfallkontakt und ein Platz pro Dienstelle bei neuer Zeitraum 2025-12-29 12:09:55 +01:00
titver968
07824f2b6a excel export und Plätze vorhanden Prüfung 2025-12-08 12:35:25 +01:00
titver968
b240c7ab12 E-Mail generierung 2025-12-08 11:11:28 +01:00
titver968
2a052478f0 vite.config prisma 2025-11-27 16:55:26 +01:00
titver968
77efdda5f3 neue prisma.schema 2025-11-27 15:29:40 +01:00
titver968
0f9fbbf177 prisma.ts und vile.config 2025-11-27 15:26:22 +01:00
titver968
9173229ad1 debug 2025-11-27 14:33:46 +01:00
titver968
3d31167987 IGS und KGS Hinweistest 2025-11-27 14:30:48 +01:00
titver968
89bf0298ce praktikum refinemend Plaetze pro Dienstelle und Pro Zeitraum 2025-11-26 15:27:21 +01:00
titver968
be9228b71d Hinweiss wehen IGS und Klasse 7. 2025-07-27 10:23:21 +02:00
titver968
84f9aab3c0 pdf erforderlich, decrement wenn Dienstelle zugewiesen wurde 2025-07-26 16:48:46 +02:00
titver968
043704d0a4 Anmeldungen Bearbeitung hinzugefuegt, leider die Anzeige ist noch nicht voll funktionsfaehig 2025-07-26 12:10:45 +02:00
titver968
1b4f37ec87 prisma, abgelaufende Zeitraueme nicht anzeigen 2025-07-26 11:42:13 +02:00
titver968
62a46fb0de E-Mail config in die DB 2025-07-26 09:47:46 +02:00
titver968
f1eeac6934 cookie aktualisiert 2025-07-26 09:34:32 +02:00
titver968
6e7a71c150 svelte update tailwindcss v 3.4.17 2025-07-26 09:21:45 +02:00
titver968
38602fa4e6 E-Mail Versand hinzugefuegt. 2025-07-24 09:45:55 +02:00
titver968
aeabd91d2d Admin Bereich an das neuen layout geaendert. 2025-07-24 09:31:02 +02:00
titver968
24dd912f77 Dienstellen auf die neue cookie 2025-07-23 17:53:07 +02:00
titver968
1e6c3b1703 admin Bereich korrigiert 2025-07-23 13:30:06 +02:00
titver968
5516acb840 Seite umbauen mit Komponenten und admin-auth 2025-07-23 08:52:53 +02:00
titver968
6fb118ac5b Punkte 3 und 4 erledigt 2025-07-16 11:17:34 +02:00
titver968
ad65207313 selektion vorhanden 2025-06-17 15:10:36 +02:00
titver968
d118a7e831 schoenheit 2025-06-17 13:38:52 +02:00
titver968
fd52b33ca2 annehmen ohne selektion eingeichtet 2025-06-17 13:25:16 +02:00
titver968
beac2582ef sche.prisma und anmeldung 2025-06-17 11:14:34 +02:00
titver968
0bd4d7be96 noch mehr leerzeile 2025-06-17 10:14:18 +02:00
titver968
da087bfa8b Ablehnen Anzeige korrigiert 2025-06-17 10:13:32 +02:00
titver968
ec0a2f41e2 knoepfe ueberinander 2025-06-17 10:12:06 +02:00
titver968
e5f51f6c6c anhenmen, ablehnen und loeschen 2025-06-17 10:11:10 +02:00
titver968
7ed885e603 breite vom 7 auf 8 2025-06-17 09:36:11 +02:00
titver968
55b286b03c Anzeige korrigiert 2025-06-17 09:34:47 +02:00
titver968
215fbb1786 noteMathe corrected 2025-06-17 09:32:11 +02:00
titver968
2c8ab5538b admin anmeldungen 2025-06-17 09:29:32 +02:00
titver968
f817ddda3a gitignore corrected 2025-06-17 09:19:31 +02:00
titver968
fcd296d402 deutschNote and MatheNote replaced with noteDeutsch und noteMathe 2025-06-17 09:13:52 +02:00
titver968
badc902fc9 console log deleted 2025-06-17 09:01:39 +02:00
titver968
6b84d7f8bd console.log und noten In Deutsch un Mathe als Nummer pruefen 2025-06-17 08:49:09 +02:00
titver968
835f13b8ff neue Upload file 2025-06-17 08:39:43 +02:00
titver968
352e119a60 gitignore aktualisiert 2025-06-17 08:39:09 +02:00
titver968
466a5cde1f logging prisma.anmeldung in anmeldung/+server.ts 2025-06-17 08:32:11 +02:00
titver968
4feb6e4da7 schma.prisma restored 2025-06-17 08:27:15 +02:00
titver968
ae6849eee6 schema.prisma 2025-06-17 08:26:04 +02:00
titver968
85a9f39f4a schema.prisma 2025-06-17 08:21:07 +02:00
titver968
355dbf8bf6 Anmeldung noch Fehlerhaft 2025-06-04 16:32:38 +02:00
titver968
177eb03179 Zeitraeume Verwaltung in admin Bereich 2025-06-04 14:28:50 +02:00
titver968
72055bfb4b Zeitraeume Verwaltung in Admin Bereich 2025-05-21 17:27:07 +02:00
titver968
78942e95e1 schulart und Noten in fronend. Erste versuch Zeitraum in backend 2025-05-21 08:31:07 +02:00
titver968
4d7d330e93 Wekan #15 "Schulart" 2025-05-19 15:38:15 +02:00
titver968
a87cfe7858 Submit success mit own windows 2025-05-12 15:05:31 +02:00
titver968
d2a0f8fdc5 git cache deleted 2025-05-12 14:21:20 +02:00
titver968
268847efdb upload deleted 2025-05-12 14:16:21 +02:00
titver968
171c9feeec Backend schönner gemacht 2025-05-12 14:15:47 +02:00
51 changed files with 7344 additions and 2012 deletions

2
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules node_modules
.idea
# Output # Output
.output .output
@@ -9,6 +10,7 @@ node_modules
/build /build
/data /data
/prisma/*.db /prisma/*.db
/static/uploads/*
# OS # OS
.DS_Store .DS_Store

View File

@@ -1,38 +0,0 @@
[
{
"anrede": "Herr",
"vorname": "Max",
"nachname": "Mustermann",
"geburtsdatum": "2005-03-15",
"strasse": "Tannenbergallee",
"hausnummer": "11",
"ort": "Hannover",
"plz": "30163",
"telefon": "0511 9695 1234",
"schulart": "Gymnasium",
"zeitraum": "sofort",
"motivation": "Ich will umbedingt",
"wunsch1": "Bauhof",
"wunsch2": "Rathaus",
"wunsch3": "Bibliothek",
"timestamp": "2025-04-15T12:01:23.224Z"
},
{
"anrede": "Frau",
"vorname": "Maria",
"nachname": "Mann",
"geburtsdatum": "2020-11-22",
"strasse": "Distelkamp",
"hausnummer": "6",
"ort": "Hannover",
"plz": "30459",
"telefon": "0511 2151505",
"schulart": "Berufschule",
"zeitraum": "ab september",
"motivation": "gar keine, meine Eltern zwingen mich das zu tun",
"wunsch1": "Kindergarten",
"wunsch2": "Bauhof",
"wunsch3": "Schule",
"timestamp": "2025-04-15T12:03:41.691Z"
}
]

3453
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,9 @@
"name": "praktikum", "name": "praktikum",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"overrides": {
"cookie": "^0.7.0"
},
"type": "module", "type": "module",
"prisma": { "prisma": {
"seed": "node prisma/seed.cjs" "seed": "node prisma/seed.cjs"
@@ -17,34 +20,35 @@
"lint": "prettier --check . && eslint ." "lint": "prettier --check . && eslint ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.3.1",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.32.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^6.0.1",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.26.1",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^6.1.0",
"@tailwindcss/postcss": "^4.1.4", "@tailwindcss/postcss": "^4.1.11",
"@types/node": "^22.14.1", "@types/node": "^24.1.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.18.0", "eslint": "^9.32.0",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.0.0", "eslint-plugin-svelte": "^3.11.0",
"globals": "^16.0.0", "globals": "^16.3.0",
"postcss": "^8.5.3", "postcss": "^8.5.6",
"prettier": "^3.4.2", "prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.4.0",
"prisma": "^6.7.0", "prisma": "^6.19.0",
"svelte": "^5.0.0", "svelte": "^5.36.17",
"svelte-check": "^4.0.0", "svelte-check": "^4.3.0",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"typescript-eslint": "^8.20.0", "typescript-eslint": "^8.38.0",
"vite": "^6.2.6", "vite": "^7.0.6",
"vite-plugin": "^0.0.0" "vite-plugin": "^0.0.0"
}, },
"dependencies": { "dependencies": {
"@prisma/client": "^6.7.0", "@prisma/client": "^6.19.0",
"@sveltejs/adapter-node": "^5.2.12", "@sveltejs/adapter-node": "^5.2.13",
"bcryptjs": "^3.0.2" "bcryptjs": "^3.0.2",
"exceljs": "^4.4.0"
} }
} }

View File

@@ -1,21 +0,0 @@
-- CreateTable
CREATE TABLE "Anmeldung" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"anrede" TEXT NOT NULL,
"vorname" TEXT NOT NULL,
"nachname" TEXT NOT NULL,
"geburtsdatum" TEXT NOT NULL,
"strasse" TEXT NOT NULL,
"hausnummer" TEXT NOT NULL,
"ort" TEXT NOT NULL,
"plz" TEXT NOT NULL,
"telefon" TEXT NOT NULL,
"email" TEXT NOT NULL,
"schulart" TEXT NOT NULL,
"zeitraum" TEXT NOT NULL,
"motivation" TEXT NOT NULL,
"wunsch1" TEXT NOT NULL,
"wunsch2" TEXT NOT NULL,
"wunsch3" TEXT NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -1,8 +0,0 @@
/*
Warnings:
- A unique constraint covering the columns `[email]` on the table `Anmeldung` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "Anmeldung_email_key" ON "Anmeldung"("email");

View File

@@ -1,52 +0,0 @@
/*
Warnings:
- You are about to drop the column `wunsch1` on the `Anmeldung` table. All the data in the column will be lost.
- You are about to drop the column `wunsch2` on the `Anmeldung` table. All the data in the column will be lost.
- You are about to drop the column `wunsch3` on the `Anmeldung` table. All the data in the column will be lost.
- Added the required column `wunsch1Id` to the `Anmeldung` table without a default value. This is not possible if the table is not empty.
- Added the required column `wunsch2Id` to the `Anmeldung` table without a default value. This is not possible if the table is not empty.
- Added the required column `wunsch3Id` to the `Anmeldung` table without a default value. This is not possible if the table is not empty.
*/
-- CreateTable
CREATE TABLE "Dienststelle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Anmeldung" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"anrede" TEXT NOT NULL,
"vorname" TEXT NOT NULL,
"nachname" TEXT NOT NULL,
"geburtsdatum" TEXT NOT NULL,
"strasse" TEXT NOT NULL,
"hausnummer" TEXT NOT NULL,
"ort" TEXT NOT NULL,
"plz" TEXT NOT NULL,
"telefon" TEXT NOT NULL,
"email" TEXT NOT NULL,
"schulart" TEXT NOT NULL,
"zeitraum" TEXT NOT NULL,
"motivation" TEXT NOT NULL,
"wunsch1Id" INTEGER NOT NULL,
"wunsch2Id" INTEGER NOT NULL,
"wunsch3Id" INTEGER NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Anmeldung_wunsch1Id_fkey" FOREIGN KEY ("wunsch1Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Anmeldung_wunsch2Id_fkey" FOREIGN KEY ("wunsch2Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Anmeldung_wunsch3Id_fkey" FOREIGN KEY ("wunsch3Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Anmeldung" ("anrede", "email", "geburtsdatum", "hausnummer", "id", "motivation", "nachname", "ort", "plz", "schulart", "strasse", "telefon", "timestamp", "vorname", "zeitraum") SELECT "anrede", "email", "geburtsdatum", "hausnummer", "id", "motivation", "nachname", "ort", "plz", "schulart", "strasse", "telefon", "timestamp", "vorname", "zeitraum" FROM "Anmeldung";
DROP TABLE "Anmeldung";
ALTER TABLE "new_Anmeldung" RENAME TO "Anmeldung";
CREATE UNIQUE INDEX "Anmeldung_email_key" ON "Anmeldung"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE UNIQUE INDEX "Dienststelle_name_key" ON "Dienststelle"("name");

View File

@@ -1,5 +0,0 @@
-- CreateTable
CREATE TABLE "Admin" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
"password" TEXT NOT NULL
);

View File

@@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "Anmeldung" ADD COLUMN "pdfdatei" TEXT;

View File

@@ -1,39 +0,0 @@
/*
Warnings:
- You are about to drop the column `pdfdatei` on the `Anmeldung` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Anmeldung" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"anrede" TEXT NOT NULL,
"vorname" TEXT NOT NULL,
"nachname" TEXT NOT NULL,
"geburtsdatum" TEXT NOT NULL,
"strasse" TEXT NOT NULL,
"hausnummer" TEXT NOT NULL,
"ort" TEXT NOT NULL,
"plz" TEXT NOT NULL,
"telefon" TEXT NOT NULL,
"email" TEXT NOT NULL,
"schulart" TEXT NOT NULL,
"zeitraum" TEXT NOT NULL,
"motivation" TEXT NOT NULL,
"pdfDatei" TEXT,
"wunsch1Id" INTEGER NOT NULL,
"wunsch2Id" INTEGER NOT NULL,
"wunsch3Id" INTEGER NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Anmeldung_wunsch1Id_fkey" FOREIGN KEY ("wunsch1Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Anmeldung_wunsch2Id_fkey" FOREIGN KEY ("wunsch2Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Anmeldung_wunsch3Id_fkey" FOREIGN KEY ("wunsch3Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Anmeldung" ("anrede", "email", "geburtsdatum", "hausnummer", "id", "motivation", "nachname", "ort", "plz", "schulart", "strasse", "telefon", "timestamp", "vorname", "wunsch1Id", "wunsch2Id", "wunsch3Id", "zeitraum") SELECT "anrede", "email", "geburtsdatum", "hausnummer", "id", "motivation", "nachname", "ort", "plz", "schulart", "strasse", "telefon", "timestamp", "vorname", "wunsch1Id", "wunsch2Id", "wunsch3Id", "zeitraum" FROM "Anmeldung";
DROP TABLE "Anmeldung";
ALTER TABLE "new_Anmeldung" RENAME TO "Anmeldung";
CREATE UNIQUE INDEX "Anmeldung_email_key" ON "Anmeldung"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,46 +0,0 @@
/*
Warnings:
- You are about to drop the column `pdfDatei` on the `Anmeldung` table. All the data in the column will be lost.
*/
-- CreateTable
CREATE TABLE "PdfDatei" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pfad" TEXT NOT NULL,
"anmeldungId" INTEGER NOT NULL,
CONSTRAINT "PdfDatei_anmeldungId_fkey" FOREIGN KEY ("anmeldungId") REFERENCES "Anmeldung" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Anmeldung" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"anrede" TEXT NOT NULL,
"vorname" TEXT NOT NULL,
"nachname" TEXT NOT NULL,
"geburtsdatum" TEXT NOT NULL,
"strasse" TEXT NOT NULL,
"hausnummer" TEXT NOT NULL,
"ort" TEXT NOT NULL,
"plz" TEXT NOT NULL,
"telefon" TEXT NOT NULL,
"email" TEXT NOT NULL,
"schulart" TEXT NOT NULL,
"zeitraum" TEXT NOT NULL,
"motivation" TEXT NOT NULL,
"wunsch1Id" INTEGER NOT NULL,
"wunsch2Id" INTEGER NOT NULL,
"wunsch3Id" INTEGER NOT NULL,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Anmeldung_wunsch1Id_fkey" FOREIGN KEY ("wunsch1Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Anmeldung_wunsch2Id_fkey" FOREIGN KEY ("wunsch2Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Anmeldung_wunsch3Id_fkey" FOREIGN KEY ("wunsch3Id") REFERENCES "Dienststelle" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Anmeldung" ("anrede", "email", "geburtsdatum", "hausnummer", "id", "motivation", "nachname", "ort", "plz", "schulart", "strasse", "telefon", "timestamp", "vorname", "wunsch1Id", "wunsch2Id", "wunsch3Id", "zeitraum") SELECT "anrede", "email", "geburtsdatum", "hausnummer", "id", "motivation", "nachname", "ort", "plz", "schulart", "strasse", "telefon", "timestamp", "vorname", "wunsch1Id", "wunsch2Id", "wunsch3Id", "zeitraum" FROM "Anmeldung";
DROP TABLE "Anmeldung";
ALTER TABLE "new_Anmeldung" RENAME TO "Anmeldung";
CREATE UNIQUE INDEX "Anmeldung_email_key" ON "Anmeldung"("email");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -1,20 +0,0 @@
/*
Warnings:
- Added the required column `plaetze` to the `Dienststelle` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Dienststelle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"plaetze" INTEGER NOT NULL
);
INSERT INTO "new_Dienststelle" ("id", "name") SELECT "id", "name" FROM "Dienststelle";
DROP TABLE "Dienststelle";
ALTER TABLE "new_Dienststelle" RENAME TO "Dienststelle";
CREATE UNIQUE INDEX "Dienststelle_name_key" ON "Dienststelle"("name");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -0,0 +1,114 @@
-- CreateTable
CREATE TABLE "Admin" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
"password" TEXT NOT NULL
);
-- CreateTable
CREATE TABLE "email_config" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
"subject" TEXT NOT NULL DEFAULT 'Praktikumsplatz-Zusage',
"template" TEXT NOT NULL DEFAULT 'Sehr geehrte/r {anrede} {nachname},
wir freuen uns, Ihnen mitteilen zu können, dass Ihre Bewerbung für ein Praktikum erfolgreich war.
Sie wurden für das Praktikum bei folgender Dienststelle angenommen:
{dienststelle}
Weitere Informationen erhalten Sie in den kommenden Tagen.
Mit freundlichen Grüßen
Ihr Praktikumsteam'
);
-- CreateTable
CREATE TABLE "Dienststelle" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"plaetze" INTEGER NOT NULL DEFAULT 0
);
-- CreateTable
CREATE TABLE "Praktikumszeitraum" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"bezeichnung" TEXT NOT NULL,
"startDatum" DATETIME NOT NULL,
"endDatum" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "zeitraum_plaetze" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"zeitraumId" INTEGER NOT NULL,
"dienststelleId" INTEGER NOT NULL,
"plaetze" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "zeitraum_plaetze_zeitraumId_fkey" FOREIGN KEY ("zeitraumId") REFERENCES "Praktikumszeitraum" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "zeitraum_plaetze_dienststelleId_fkey" FOREIGN KEY ("dienststelleId") REFERENCES "Dienststelle" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "anmeldungen" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"anrede" TEXT NOT NULL,
"vorname" TEXT NOT NULL,
"nachname" TEXT NOT NULL,
"geburtsdatum" TEXT NOT NULL,
"strasse" TEXT NOT NULL,
"hausnummer" TEXT NOT NULL,
"ort" TEXT NOT NULL,
"plz" TEXT NOT NULL,
"telefon" TEXT NOT NULL,
"email" TEXT NOT NULL,
"schulart" TEXT NOT NULL,
"schulklasse" TEXT,
"noteDeutsch" INTEGER NOT NULL,
"noteMathe" INTEGER NOT NULL,
"sozialverhalten" TEXT,
"motivation" TEXT,
"alter" INTEGER,
"status" TEXT NOT NULL DEFAULT 'OFFEN',
"processedAt" DATETIME,
"praktikumId" INTEGER,
"zugewiesenId" INTEGER,
"wunsch1Id" INTEGER,
"wunsch2Id" INTEGER,
"wunsch3Id" INTEGER,
"timestamp" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "anmeldungen_praktikumId_fkey" FOREIGN KEY ("praktikumId") REFERENCES "Praktikumszeitraum" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "anmeldungen_zugewiesenId_fkey" FOREIGN KEY ("zugewiesenId") REFERENCES "Dienststelle" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "anmeldungen_wunsch1Id_fkey" FOREIGN KEY ("wunsch1Id") REFERENCES "Dienststelle" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "anmeldungen_wunsch2Id_fkey" FOREIGN KEY ("wunsch2Id") REFERENCES "Dienststelle" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "anmeldungen_wunsch3Id_fkey" FOREIGN KEY ("wunsch3Id") REFERENCES "Dienststelle" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "PdfDatei" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"pfad" TEXT NOT NULL,
"anmeldungId" INTEGER NOT NULL,
CONSTRAINT "PdfDatei_anmeldungId_fkey" FOREIGN KEY ("anmeldungId") REFERENCES "anmeldungen" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Dienststelle_name_key" ON "Dienststelle"("name");
-- CreateIndex
CREATE UNIQUE INDEX "Praktikumszeitraum_bezeichnung_key" ON "Praktikumszeitraum"("bezeichnung");
-- CreateIndex
CREATE INDEX "zeitraum_plaetze_zeitraumId_idx" ON "zeitraum_plaetze"("zeitraumId");
-- CreateIndex
CREATE INDEX "zeitraum_plaetze_dienststelleId_idx" ON "zeitraum_plaetze"("dienststelleId");
-- CreateIndex
CREATE UNIQUE INDEX "zeitraum_plaetze_zeitraumId_dienststelleId_key" ON "zeitraum_plaetze"("zeitraumId", "dienststelleId");
-- CreateIndex
CREATE INDEX "anmeldungen_status_idx" ON "anmeldungen"("status");
-- CreateIndex
CREATE INDEX "anmeldungen_processedAt_idx" ON "anmeldungen"("processedAt");
-- CreateIndex
CREATE INDEX "anmeldungen_zugewiesenId_idx" ON "anmeldungen"("zugewiesenId");

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "anmeldungen" ADD COLUMN "notfallNachname" TEXT;
ALTER TABLE "anmeldungen" ADD COLUMN "notfallTelefon" TEXT;
ALTER TABLE "anmeldungen" ADD COLUMN "notfallVorname" TEXT;

Binary file not shown.

BIN
prisma/prisma/praktika.db Normal file

Binary file not shown.

View File

@@ -1,12 +1,11 @@
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["darwin-arm64", "linux-arm64-openssl-3.0.x", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"] binaryTargets = ["darwin-arm64", "linux-arm64-openssl-3.0.x", "debian-openssl-3.0.x", "linux-musl-openssl-3.0.x"]
output = "../node_modules/.prisma/client"
} }
datasource db { datasource db {
provider = "sqlite" provider = "sqlite"
url = "file:./praktika.db" url = env("DATABASE_URL")
} }
model Admin { model Admin {
@@ -14,47 +13,111 @@ model Admin {
password String password String
} }
model EmailConfig {
id Int @id @default(1)
subject String @default("Praktikumsplatz-Zusage")
template String @default("Sehr geehrte/r {anrede} {nachname},\n\nwir freuen uns, Ihnen mitteilen zu können, dass Ihre Bewerbung für ein Praktikum erfolgreich war.\n\nSie wurden für das Praktikum bei folgender Dienststelle angenommen:\n{dienststelle}\n\nWeitere Informationen erhalten Sie in den kommenden Tagen.\n\nMit freundlichen Grüßen\nIhr Praktikumsteam")
@@map("email_config")
}
model Dienststelle { model Dienststelle {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
plaetze Int plaetze Int @default(0)
anmeldungenWunsch1 Anmeldung[] @relation("Wunsch1") anmeldungenWunsch1 Anmeldung[] @relation("Wunsch1")
anmeldungenWunsch2 Anmeldung[] @relation("Wunsch2") anmeldungenWunsch2 Anmeldung[] @relation("Wunsch2")
anmeldungenWunsch3 Anmeldung[] @relation("Wunsch3") anmeldungenWunsch3 Anmeldung[] @relation("Wunsch3")
zugewiesene Anmeldung[] @relation("Zugewiesen")
zeitraumPlaetze ZeitraumPlaetze[]
}
model Praktikumszeitraum {
id Int @id @default(autoincrement())
bezeichnung String @unique
startDatum DateTime
endDatum DateTime
anmeldungen Anmeldung[] @relation("PraktikumszeitraumAnmeldungen")
zeitraumPlaetze ZeitraumPlaetze[]
}
model ZeitraumPlaetze {
id Int @id @default(autoincrement())
zeitraumId Int
dienststelleId Int
plaetze Int @default(0)
zeitraum Praktikumszeitraum @relation(fields: [zeitraumId], references: [id], onDelete: Cascade)
dienststelle Dienststelle @relation(fields: [dienststelleId], references: [id], onDelete: Cascade)
@@unique([zeitraumId, dienststelleId])
@@index([zeitraumId])
@@index([dienststelleId])
@@map("zeitraum_plaetze")
}
enum Status {
OFFEN
BEARBEITUNG
ANGENOMMEN
ABGELEHNT
} }
model Anmeldung { model Anmeldung {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
anrede String anrede String
vorname String vorname String
nachname String nachname String
geburtsdatum String geburtsdatum String
strasse String strasse String
hausnummer String hausnummer String
ort String ort String
plz String plz String
telefon String telefon String
email String @unique email String
schulart String schulart String
zeitraum String schulklasse String?
motivation String noteDeutsch Int
noteMathe Int
sozialverhalten String?
motivation String?
alter Int?
wunsch1Id Int // Notfallkontakt
wunsch2Id Int notfallVorname String?
wunsch3Id Int notfallNachname String?
notfallTelefon String?
wunsch1 Dienststelle @relation("Wunsch1", fields: [wunsch1Id], references: [id]) status Status @default(OFFEN)
wunsch2 Dienststelle @relation("Wunsch2", fields: [wunsch2Id], references: [id])
wunsch3 Dienststelle @relation("Wunsch3", fields: [wunsch3Id], references: [id])
timestamp DateTime @default(now()) processedAt DateTime?
pdfs PdfDatei[] @relation("AnmeldungPdfs") praktikumId Int?
praktikum Praktikumszeitraum? @relation("PraktikumszeitraumAnmeldungen", fields: [praktikumId], references: [id])
zugewiesenId Int?
zugewiesen Dienststelle? @relation("Zugewiesen", fields: [zugewiesenId], references: [id])
wunsch1Id Int?
wunsch1 Dienststelle? @relation("Wunsch1", fields: [wunsch1Id], references: [id])
wunsch2Id Int?
wunsch2 Dienststelle? @relation("Wunsch2", fields: [wunsch2Id], references: [id])
wunsch3Id Int?
wunsch3 Dienststelle? @relation("Wunsch3", fields: [wunsch3Id], references: [id])
timestamp DateTime @default(now())
pdfs PdfDatei[]
@@index([status])
@@index([processedAt])
@@index([zugewiesenId])
@@map("anmeldungen")
} }
model PdfDatei { model PdfDatei {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
pfad String pfad String
anmeldung Anmeldung @relation("AnmeldungPdfs", fields: [anmeldungId], references: [id]) anmeldung Anmeldung @relation(fields: [anmeldungId], references: [id], onDelete: Cascade)
anmeldungId Int anmeldungId Int
} }

View File

@@ -0,0 +1,57 @@
<!-- src/lib/components/AdminHeader.svelte -->
<script lang="ts">
import { goto } from '$app/navigation';
export let title: string;
export let showBackButton: boolean = false;
function goBack() {
goto('/admin');
}
async function logout() {
try {
const res = await fetch('/api/admin/logout', {
method: 'POST'
});
if (res.ok) {
goto('/admin');
}
} catch (err) {
console.error('Logout fehler:', err);
}
}
</script>
<header class="bg-white shadow-sm border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex items-center">
{#if showBackButton}
<button
on:click={goBack}
class="mr-4 p-2 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
aria-label="Zurück"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
{/if}
<h1 class="text-xl font-semibold text-gray-900">{title}</h1>
</div>
<div class="flex items-center space-x-4">
<span class="text-sm text-gray-500">Admin-Bereich</span>
<button
on:click={logout}
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors"
>
Abmelden
</button>
</div>
</div>
</div>
</header>

View File

@@ -0,0 +1,78 @@
<!-- src/lib/components/AdminNavigation.svelte -->
<script lang="ts">
interface NavigationItem {
href: string;
title: string;
icon: string;
description: string;
color: string;
}
const navigationItems: NavigationItem[] = [
{
href: '/admin/anmeldungen',
title: 'Anmeldungen',
icon: '📝',
description: 'Praktikumsanmeldungen anzeigen und verwalten',
color: 'bg-blue-600 hover:bg-blue-700'
},
{
href: '/admin/dienststellen',
title: 'Dienststellen',
icon: '🏢',
description: 'Dienststellen hinzufügen und bearbeiten',
color: 'bg-green-600 hover:bg-green-700'
},
{
href: '/admin/zeitraeume',
title: 'Zeiträume',
icon: '🗓️',
description: 'Praktikumszeiträume verwalten',
color: 'bg-purple-600 hover:bg-purple-700'
},
{
href: '/admin/plaetze',
title: 'Plätze verwalten',
icon: '📊',
description: 'Praktikumsplätze pro Zeitraum und Dienststelle festlegen',
color: 'bg-indigo-600 hover:bg-indigo-700'
},
{
href: '/admin/change-password',
title: 'Passwort ändern',
icon: '🔒',
description: 'Admin-Passwort aktualisieren',
color: 'bg-orange-600 hover:bg-orange-700'
}
];
</script>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#each navigationItems as item}
<a
href={item.href}
class="group {item.color} text-white p-6 rounded-lg shadow-md transition-all duration-200 hover:shadow-lg hover:transform hover:scale-105"
>
<div class="flex items-start space-x-4">
<div class="text-3xl">
{item.icon}
</div>
<div>
<h3 class="text-xl font-semibold mb-2">
{item.title}
</h3>
<p class="text-white/90 text-sm">
{item.description}
</p>
</div>
</div>
</a>
{/each}
</div>
<style>
/* Zusätzliche Hover-Effekte */
.group:hover {
transform: translateY(-2px);
}
</style>

View File

@@ -0,0 +1,355 @@
<!-- src/lib/components/AnmeldungenTable.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let anmeldungen: any[];
export let getStatusColor: (status: string) => string;
export let getStatusText: (status: string) => string;
const dispatch = createEventDispatcher<{
accept: { id: number };
reject: { id: number };
delete: { id: number };
}>();
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatGeburtsdatum(dateString: string | undefined): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
function formatProcessedDate(timestamp: number | undefined): string {
if (!timestamp) return '-';
return new Date(timestamp).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatZeitraum(zeitraum: any): string {
if (!zeitraum) return '-';
const start = new Date(zeitraum.startDatum).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const end = new Date(zeitraum.endDatum).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
return `${zeitraum.bezeichnung} (${start} - ${end})`;
}
// Schulart formatieren
function formatSchulart(schulart: string | undefined): string {
if (!schulart) return '-';
const mapping: Record<string, string> = {
'Gymnasium': 'Gymnasium',
'KGS_Gymnasialzweig': 'KGS Gymnasialzweig',
'Fachoberschule': 'Fachoberschule',
'Realschule': 'Realschule',
'KGSR': 'KGS Realschulzweig',
'IGSR': 'IGS Realschulzweig'
};
return mapping[schulart] || schulart;
}
// Sozialverhalten kürzen
function formatSozialverhalten(sv: string | undefined): string {
if (!sv) return '-';
if (sv === 'Entspricht den Erwartungen in vollem Umfang') return 'Voll entspr.';
if (sv === 'Entspricht den Erwartungen') return 'Entsprechend';
if (sv === 'Entspricht den Erwartungen mit Einschränkungen') return 'Mit Einschr.';
return sv;
}
function canBeAccepted(status: string): boolean {
return status === 'pending';
}
function canBeRejected(status: string): boolean {
return status === 'pending';
}
</script>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Persönliche Daten
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kontakt & Adresse
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Schule & Noten
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Praktikum & Wünsche
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dokumente
</th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each anmeldungen as anmeldung (anmeldung.id)}
<tr class="hover:bg-gray-50 align-top">
<!-- Status -->
<td class="px-3 py-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {getStatusColor(anmeldung.status || 'pending')}">
{getStatusText(anmeldung.status || 'pending')}
</span>
<div class="text-xs text-gray-400 mt-2">
Eingang:<br>
{formatDate(anmeldung.timestamp)}
</div>
</td>
<!-- Persönliche Daten -->
<td class="px-3 py-4">
<div class="text-sm font-medium text-gray-900">
{anmeldung.anrede} {anmeldung.vorname} {anmeldung.nachname}
</div>
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium">Geb.:</span> {formatGeburtsdatum(anmeldung.geburtsdatum)}
</div>
{#if anmeldung.alter}
<div class="text-xs text-gray-500">
<span class="font-medium">Alter:</span> {anmeldung.alter} Jahre
</div>
{/if}
</td>
<!-- Kontakt & Adresse -->
<td class="px-3 py-4 text-sm">
<div class="text-xs space-y-1">
<div>
<span class="font-medium text-gray-700">Adresse:</span><br>
<span class="text-gray-600">
{anmeldung.strasse || '-'} {anmeldung.hausnummer || ''}<br>
{anmeldung.plz || ''} {anmeldung.ort || ''}
</span>
</div>
<div>
<span class="font-medium text-gray-700">Tel:</span>
{#if anmeldung.telefon}
<a href="tel:{anmeldung.telefon}" class="text-blue-600 hover:text-blue-800">{anmeldung.telefon}</a>
{:else}
<span class="text-gray-400">-</span>
{/if}
</div>
<div>
<span class="font-medium text-gray-700">E-Mail:</span><br>
<a href="mailto:{anmeldung.email}" class="text-blue-600 hover:text-blue-800 break-all text-xs">{anmeldung.email}</a>
</div>
<!-- Notfallkontakt -->
{#if anmeldung.notfallVorname || anmeldung.notfallNachname || anmeldung.notfallTelefon}
<div class="mt-2 pt-2 border-t border-gray-100">
<span class="font-medium text-orange-700">Notfallkontakt:</span>
<div class="text-gray-600">
{anmeldung.notfallVorname || ''} {anmeldung.notfallNachname || ''}
</div>
{#if anmeldung.notfallTelefon}
<a href="tel:{anmeldung.notfallTelefon}" class="text-blue-600 hover:text-blue-800">
{anmeldung.notfallTelefon}
</a>
{/if}
</div>
{/if}
</div>
</td>
<!-- Schule & Noten -->
<td class="px-3 py-4 text-sm">
<div class="text-xs space-y-1">
<div>
<span class="font-medium text-gray-700">Schulart:</span><br>
<span class="text-gray-900">{formatSchulart(anmeldung.schulart)}</span>
</div>
{#if anmeldung.schulklasse}
<div>
<span class="font-medium text-gray-700">Klasse:</span>
<span class="text-gray-900">{anmeldung.schulklasse}. Klasse</span>
</div>
{/if}
<div class="pt-1 border-t border-gray-100">
<span class="font-medium text-gray-700">Noten:</span>
<div class="flex gap-2 mt-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100">
D: <span class="font-bold ml-1">{anmeldung.noteDeutsch || '-'}</span>
</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100">
M: <span class="font-bold ml-1">{anmeldung.noteMathe || '-'}</span>
</span>
</div>
</div>
{#if anmeldung.sozialverhalten}
<div>
<span class="font-medium text-gray-700">Sozialverh.:</span>
<span class="text-gray-900 text-xs">{formatSozialverhalten(anmeldung.sozialverhalten)}</span>
</div>
{/if}
</div>
</td>
<!-- Praktikum & Wünsche -->
<td class="px-3 py-4 text-sm">
<div class="text-xs space-y-2">
<!-- Zeitraum -->
{#if anmeldung.zeitraum}
<div>
<span class="font-medium text-gray-700">Zeitraum:</span><br>
<span class="text-gray-900">{formatZeitraum(anmeldung.zeitraum)}</span>
</div>
{/if}
<!-- Zugewiesene Dienststelle -->
{#if anmeldung.assignedDienststelle}
<div class="p-2 bg-green-50 border border-green-200 rounded">
<div class="flex items-center text-green-700">
<svg class="w-4 h-4 mr-1 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
<span class="font-semibold">Zugewiesen:</span>
</div>
<div class="text-green-800 mt-1">{anmeldung.assignedDienststelle.name}</div>
{#if anmeldung.processedAt}
<div class="text-xs text-green-600 mt-1">
{formatProcessedDate(anmeldung.processedAt)}
</div>
{/if}
</div>
{/if}
<!-- Alle 3 Wünsche -->
<div>
<span class="font-medium text-gray-700">Wünsche:</span>
<div class="space-y-1 mt-1">
{#if anmeldung.wunsch1}
<div class="flex items-start">
<span class="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-600 rounded-full mr-1 flex-shrink-0">1</span>
<span class="text-gray-900 leading-tight">{anmeldung.wunsch1.name}</span>
</div>
{/if}
{#if anmeldung.wunsch2}
<div class="flex items-start">
<span class="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full mr-1 flex-shrink-0">2</span>
<span class="text-gray-900 leading-tight">{anmeldung.wunsch2.name}</span>
</div>
{/if}
{#if anmeldung.wunsch3}
<div class="flex items-start">
<span class="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-400 rounded-full mr-1 flex-shrink-0">3</span>
<span class="text-gray-900 leading-tight">{anmeldung.wunsch3.name}</span>
</div>
{/if}
{#if !anmeldung.wunsch1 && !anmeldung.wunsch2 && !anmeldung.wunsch3}
<span class="text-gray-400">Keine Wünsche</span>
{/if}
</div>
</div>
<!-- Motivation -->
{#if anmeldung.motivation}
<div>
<span class="font-medium text-gray-700">Motivation:</span>
<p class="text-gray-600 mt-1 text-xs whitespace-pre-wrap line-clamp-3">{anmeldung.motivation}</p>
</div>
{/if}
</div>
</td>
<!-- Dokumente / PDFs -->
<td class="px-3 py-4">
{#if anmeldung.pdfs && anmeldung.pdfs.length > 0}
<div class="space-y-1">
{#each anmeldung.pdfs as pdf, index}
<a
href="/api/admin/pdf?path={encodeURIComponent(pdf.pfad)}"
target="_blank"
class="flex items-center text-xs text-blue-600 hover:text-blue-800 hover:bg-blue-50 p-1 rounded"
>
<svg class="w-4 h-4 mr-1 text-red-500 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd" />
</svg>
PDF {index + 1}
<svg class="w-3 h-3 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
{/each}
</div>
{:else}
<span class="text-xs text-gray-400">Keine Dokumente</span>
{/if}
</td>
<!-- Aktionen -->
<td class="px-3 py-4">
<div class="flex flex-col space-y-2">
{#if canBeAccepted(anmeldung.status || 'pending')}
<button
on:click={() => dispatch('accept', { id: anmeldung.id })}
class="text-green-600 hover:text-green-900 px-3 py-1.5 rounded text-xs font-medium border border-green-300 hover:bg-green-50 text-center whitespace-nowrap"
>
✓ Annehmen
</button>
{/if}
{#if canBeRejected(anmeldung.status || 'pending')}
<button
on:click={() => dispatch('reject', { id: anmeldung.id })}
class="text-red-600 hover:text-red-900 px-3 py-1.5 rounded text-xs font-medium border border-red-300 hover:bg-red-50 text-center whitespace-nowrap"
>
✗ Ablehnen
</button>
{/if}
<button
on:click={() => dispatch('delete', { id: anmeldung.id })}
class="text-gray-600 hover:text-gray-900 px-3 py-1.5 rounded text-xs font-medium border border-gray-300 hover:bg-gray-50 text-center whitespace-nowrap"
>
🗑 Löschen
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,138 @@
<!-- src/lib/components/DienststellenDialog.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
confirm: { dienststelleId: number };
cancel: void;
}>();
interface Wish {
id: number;
name: string;
}
export let wishes: Wish[];
export let selectedId: number | null;
let isLoading = false;
function handleConfirm() {
if (selectedId !== null) {
isLoading = true;
dispatch('confirm', { dienststelleId: selectedId });
}
}
function handleCancel() {
dispatch('cancel');
}
function handleBackdropClick(event: MouseEvent) {
if (event.target === event.currentTarget) {
handleCancel();
}
}
function handleBackdropKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
handleCancel();
}
}
</script>
<div
class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 flex items-center justify-center p-4"
on:click={handleBackdropClick}
on:keydown={handleBackdropKeydown}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabindex="0"
>
<!-- Modal Content -->
<div class="relative bg-white rounded-lg shadow-xl max-w-md w-full">
<!-- Header -->
<div class="flex items-start justify-between p-6 border-b border-gray-200 rounded-t">
<h3 class="text-lg font-semibold text-gray-900" id="modal-title">
Dienststelle für Praktikum auswählen
</h3>
<button
type="button"
class="text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm w-8 h-8 ml-auto inline-flex justify-center items-center"
on:click={handleCancel}
disabled={isLoading}
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
<span class="sr-only">Modal schließen</span>
</button>
</div>
<!-- Body -->
<div class="p-6">
<p class="text-sm text-gray-600 mb-4">
Wählen Sie eine der Wunsch-Dienststellen für diese Anmeldung aus:
</p>
<div class="space-y-3">
{#each wishes as wish}
<label class="flex items-center p-3 border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer">
<input
type="radio"
bind:group={selectedId}
value={wish.id}
disabled={isLoading}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300"
/>
<span class="ml-3 text-sm font-medium text-gray-900">
{wish.name}
</span>
</label>
{/each}
</div>
{#if wishes.length === 0}
<div class="text-center py-4">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<p class="mt-2 text-sm text-gray-500">Keine Wünsche verfügbar</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="flex items-center justify-end space-x-3 p-6 border-t border-gray-200 rounded-b">
<button
type="button"
on:click={handleCancel}
disabled={isLoading}
class="text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 disabled:opacity-50 disabled:cursor-not-allowed"
>
Abbrechen
</button>
<button
type="button"
on:click={handleConfirm}
disabled={selectedId === null || isLoading || wishes.length === 0}
class="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center"
>
{#if isLoading}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Wird zugewiesen...
{:else}
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Praktikum zuweisen
{/if}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,91 @@
<!-- src/lib/components/LoginForm.svelte -->
<script lang="ts">
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher<{
loginResult: { success: boolean; error?: string };
}>();
let password = '';
let isLoading = false;
let error = '';
async function handleSubmit() {
if (!password.trim()) {
error = 'Bitte Passwort eingeben';
return;
}
isLoading = true;
error = '';
try {
const res = await fetch('/api/admin/login', {
method: 'POST',
body: JSON.stringify({ passwort: password }),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
dispatch('loginResult', { success: true });
} else {
const data = await res.json().catch(() => ({}));
error = data.message || 'Falsches Passwort';
dispatch('loginResult', { success: false, error });
}
} catch (err) {
error = 'Verbindungsfehler. Bitte versuchen Sie es erneut.';
dispatch('loginResult', { success: false, error });
} finally {
isLoading = false;
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') {
handleSubmit();
}
}
</script>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-2">
Passwort
</label>
<input
id="password"
type="password"
bind:value={password}
on:keydown={handleKeydown}
placeholder="Admin-Passwort eingeben"
disabled={isLoading}
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100"
required
/>
</div>
{#if error}
<div class="bg-red-50 border border-red-200 rounded-md p-3">
<p class="text-sm text-red-800">{error}</p>
</div>
{/if}
<button
type="submit"
disabled={isLoading || !password.trim()}
class="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white font-medium py-2 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
{#if isLoading}
<span class="flex items-center justify-center">
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Wird eingeloggt...
</span>
{:else}
Anmelden
{/if}
</button>
</form>

15
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV !== 'production' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

16
src/lib/server/prisma.ts Normal file
View File

@@ -0,0 +1,16 @@
// src/lib/server/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: ['error', 'warn'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}

View File

@@ -16,6 +16,11 @@
let zeitraum = ''; let zeitraum = '';
let motivation = ''; let motivation = '';
// Notfallkontakt
let notfallVorname = '';
let notfallNachname = '';
let notfallTelefon = '';
let wunsch1Id = ''; let wunsch1Id = '';
let wunsch2Id = ''; let wunsch2Id = '';
let wunsch3Id = ''; let wunsch3Id = '';
@@ -23,14 +28,313 @@
let fehler = ''; let fehler = '';
let success = false; let success = false;
let dienststellen: any[]; let dienststellen: any[] = [];
let isLoadingDienststellen = false;
onMount(async () => { let fileInputKey = 0;
const res = await fetch('/api/dienststellen'); let noteDeutsch = '';
dienststellen = await res.json(); let noteMathe = '';
let sozialverhalten = '';
let schulklasse = '';
let ablehnungHinweis = '';
let showAblehnungModal = false;
let alter = '';
// Validierungsfehler für Echtzeit-Anzeige
let alterFehler = '';
let notenFehler = '';
let sozialverhaltenFehler = '';
let emailFehler = '';
let telefonFehler = '';
let notfallTelefonFehler = '';
let plzFehler = '';
// Hinweis für IGS/KGS mit Lernentwicklungsbericht
$: zeigeIgsKgsHinweis =
['IGSR', 'KGSR'].includes(schulart) &&
schulklasse &&
parseInt(schulklasse) < 10;
// Berechnung des Alters
$: {
if (geburtsdatum && zeitraum && zeitraeume.length > 0) {
const gewaehlterZeitraum = zeitraeume.find(z => z.id == zeitraum);
if (gewaehlterZeitraum) {
const geburt = new Date(geburtsdatum);
const praktikumStart = new Date(gewaehlterZeitraum.startDatum);
let altersberechnung = praktikumStart.getFullYear() - geburt.getFullYear();
const monthDiff = praktikumStart.getMonth() - geburt.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && praktikumStart.getDate() < geburt.getDate())) {
altersberechnung--;
}
alter = altersberechnung.toString();
}
}
}
// Echtzeit-Validierung: Alter
$: {
const altersWert = parseInt(alter);
if (alter && !isNaN(altersWert) && altersWert < 14) {
alterFehler = 'Du musst mindestens 14 Jahre alt sein, um ein Praktikum beginnen zu können.';
} else {
alterFehler = '';
}
}
// Echtzeit-Validierung: E-Mail
$: {
if (email) {
// RFC 5322 konforme E-Mail-Validierung (vereinfacht)
const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;
if (!emailRegex.test(email)) {
emailFehler = 'Bitte gib eine gültige E-Mail-Adresse ein.';
} else {
emailFehler = '';
}
} else {
emailFehler = '';
}
}
// Echtzeit-Validierung: Telefonnummer
// Deutsche Telefonnummern: Festnetz 10-11 Ziffern, Mobil 11-12 Ziffern (mit führender 0)
// Erlaubt: Ziffern, Leerzeichen, Bindestriche, Schrägstriche, Klammern, + am Anfang
$: {
if (telefon) {
// Entferne alle Formatierungszeichen für die Ziffernzählung
const nurZiffern = telefon.replace(/[\s\-\/\(\)\+]/g, '');
// Prüfe ob nur erlaubte Zeichen enthalten sind
const erlaubteZeichenRegex = /^[\d\s\-\/\(\)\+]+$/;
if (!erlaubteZeichenRegex.test(telefon)) {
telefonFehler = 'Die Telefonnummer enthält ungültige Zeichen.';
} else if (!/^\d+$/.test(nurZiffern)) {
telefonFehler = 'Die Telefonnummer muss Ziffern enthalten.';
} else if (nurZiffern.length < 10) {
telefonFehler = 'Die Telefonnummer ist zu kurz (mindestens 10 Ziffern).';
} else if (nurZiffern.length > 15) {
telefonFehler = 'Die Telefonnummer ist zu lang (maximal 15 Ziffern).';
} else {
telefonFehler = '';
}
} else {
telefonFehler = '';
}
}
// Echtzeit-Validierung: Notfall-Telefonnummer
$: {
if (notfallTelefon) {
const nurZiffern = notfallTelefon.replace(/[\s\-\/\(\)\+]/g, '');
const erlaubteZeichenRegex = /^[\d\s\-\/\(\)\+]+$/;
if (!erlaubteZeichenRegex.test(notfallTelefon)) {
notfallTelefonFehler = 'Die Telefonnummer enthält ungültige Zeichen.';
} else if (!/^\d+$/.test(nurZiffern)) {
notfallTelefonFehler = 'Die Telefonnummer muss Ziffern enthalten.';
} else if (nurZiffern.length < 10) {
notfallTelefonFehler = 'Die Telefonnummer ist zu kurz (mindestens 10 Ziffern).';
} else if (nurZiffern.length > 15) {
notfallTelefonFehler = 'Die Telefonnummer ist zu lang (maximal 15 Ziffern).';
} else {
notfallTelefonFehler = '';
}
} else {
notfallTelefonFehler = '';
}
}
// Echtzeit-Validierung: PLZ (genau 5 Ziffern für deutsche PLZ)
$: {
if (plz) {
const plzRegex = /^\d{5}$/;
if (!plzRegex.test(plz)) {
if (!/^\d*$/.test(plz)) {
plzFehler = 'Die Postleitzahl darf nur Ziffern enthalten.';
} else if (plz.length < 5) {
plzFehler = 'Die Postleitzahl muss genau 5 Ziffern haben.';
} else if (plz.length > 5) {
plzFehler = 'Die Postleitzahl darf maximal 5 Ziffern haben.';
} else {
plzFehler = 'Bitte gib eine gültige Postleitzahl ein.';
}
} else {
plzFehler = '';
}
} else {
plzFehler = '';
}
}
// Echtzeit-Validierung: Noten
$: {
const deutsch = parseInt(noteDeutsch);
const mathe = parseInt(noteMathe);
const klasse = parseInt(schulklasse);
if (noteDeutsch && noteMathe && schulart) {
// Gymnasium oder KGS_Gymnasialzweig: mindestens 4 in Deutsch UND Mathe
if (['Gymnasium', 'KGS_Gymnasialzweig'].includes(schulart)) {
if (!isNaN(deutsch) && !isNaN(mathe) && (deutsch > 4 || mathe > 4)) {
notenFehler = 'Du brauchst mindestens eine 4 in Deutsch und Mathematik.';
} else {
notenFehler = '';
}
}
// Fachoberschule Klasse 11 oder 12: mindestens 4 in Deutsch UND Mathe
else if (schulart === 'Fachoberschule' && (klasse === 11 || klasse === 12)) {
if (!isNaN(deutsch) && !isNaN(mathe) && (deutsch > 4 || mathe > 4)) {
notenFehler = 'Du brauchst mindestens eine 4 in Deutsch und Mathematik.';
} else {
notenFehler = '';
}
}
// Alle anderen: mindestens 3 in Deutsch UND Mathe
else {
if (!isNaN(deutsch) && !isNaN(mathe) && (deutsch > 3 || mathe > 3)) {
notenFehler = 'Du brauchst mindestens eine 3 in Deutsch und Mathematik.';
} else {
notenFehler = '';
}
}
} else {
notenFehler = '';
}
}
// Echtzeit-Validierung: Sozialverhalten
$: {
if (sozialverhalten === 'Entspricht den Erwartungen mit Einschränkungen') {
sozialverhaltenFehler = 'Dein Sozialverhalten muss mindestens den Erwartungen entsprechen.';
} else {
sozialverhaltenFehler = '';
}
}
// Prüfen ob alle erforderlichen Wünsche ausgewählt wurden (abhängig von verfügbaren Dienststellen)
$: wuenscheVollstaendig = (() => {
const anzahl = filteredDienststellen.length;
if (anzahl === 0) return false;
if (anzahl === 1) return !!wunsch1Id;
if (anzahl === 2) return !!wunsch1Id && !!wunsch2Id;
return !!wunsch1Id && !!wunsch2Id && !!wunsch3Id;
})();
// Prüfen ob Formular gültig ist (erweitert um neue Validierungen)
$: formHatFehler = alterFehler !== '' || notenFehler !== '' || sozialverhaltenFehler !== '' ||
emailFehler !== '' || telefonFehler !== '' || notfallTelefonFehler !== '' || plzFehler !== '';
// Dienststellen laden wenn Zeitraum sich ändert
async function ladeDienststellen(zeitraumId: string) {
if (!zeitraumId) {
dienststellen = [];
return;
}
try {
isLoadingDienststellen = true;
const res = await fetch(`/api/dienststellen?zeitraumId=${zeitraumId}`);
dienststellen = await res.json();
} catch (err) {
console.error('Fehler beim Laden der Dienststellen:', err);
dienststellen = [];
} finally {
isLoadingDienststellen = false;
}
}
// Reaktiv: Wenn Zeitraum sich ändert, Dienststellen neu laden und Wünsche zurücksetzen
$: if (zeitraum) {
ladeDienststellen(zeitraum);
wunsch1Id = '';
wunsch2Id = '';
wunsch3Id = '';
}
// Filter: Nur Dienststellen mit freien Plätzen und Alterscheck für PK Mitte
$: filteredDienststellen = (dienststellen ?? []).filter(d => {
if (d.plaetze <= 0) return false;
if (d.name.includes('PK Mitte') || d.name.toLowerCase().includes('polizeikommissariat mitte')) {
return parseInt(alter) >= 18;
}
return true;
}); });
$: filteredZeitraeume = (zeitraeume ?? []).filter(zeitraum => {
const heute = new Date();
const startDatum = new Date(zeitraum.startDatum);
return startDatum > heute;
});
let startDatum = '';
$: hideSozialVerhalten =
Number(schulklasse) >= 11 &&
["Gymnasium", "KGS_Gymnasialzweig", "Fachoberschule"].includes(schulart);
onMount(async () => {
const resZeitraeume = await fetch('/api/zeitraeume');
zeitraeume = await resZeitraeume.json();
});
interface Zeitraum {
id: number;
bezeichnung: string;
startDatum: string;
endDatum: string;
}
let zeitraeume: Zeitraum[] = [];
let fehlermeldung = '';
let bearbeiteId: number | null = null;
function resetForm() {
anrede = '';
vorname = '';
nachname = '';
geburtsdatum = '';
strasse = '';
hausnummer = '';
ort = '';
plz = '';
telefon = '';
email = '';
schulart = '';
zeitraum = '';
wunsch1Id = '';
wunsch2Id = '';
wunsch3Id = '';
motivation = '';
noteDeutsch = '';
noteMathe = '';
sozialverhalten = '';
schulklasse = '';
notfallVorname = '';
notfallNachname = '';
notfallTelefon = '';
pdfDateien = [];
fileInputKey += 1;
success = false;
alterFehler = '';
notenFehler = '';
sozialverhaltenFehler = '';
emailFehler = '';
telefonFehler = '';
notfallTelefonFehler = '';
plzFehler = '';
dienststellen = [];
}
async function anmelden() { async function anmelden() {
// Abbrechen wenn Validierungsfehler vorhanden
if (formHatFehler) {
return;
}
const data = new FormData(); const data = new FormData();
data.append('anrede', anrede); data.append('anrede', anrede);
@@ -49,6 +353,14 @@
data.append('wunsch1Id', wunsch1Id); data.append('wunsch1Id', wunsch1Id);
data.append('wunsch2Id', wunsch2Id); data.append('wunsch2Id', wunsch2Id);
data.append('wunsch3Id', wunsch3Id); data.append('wunsch3Id', wunsch3Id);
data.append('noteDeutsch', noteDeutsch);
data.append('noteMathe', noteMathe);
data.append('sozialverhalten', sozialverhalten);
data.append('schulklasse', schulklasse);
data.append('alter', alter);
data.append('notfallVorname', notfallVorname);
data.append('notfallNachname', notfallNachname);
data.append('notfallTelefon', notfallTelefon);
for (const pdf of pdfDateien) { for (const pdf of pdfDateien) {
data.append('pdfs', pdf); data.append('pdfs', pdf);
@@ -57,7 +369,7 @@
const res = await fetch('/api/anmelden', { const res = await fetch('/api/anmelden', {
method: 'POST', method: 'POST',
body: data body: data
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) { if (!res.ok) {
@@ -72,7 +384,7 @@
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-6"> <div class="min-h-screen bg-gray-100 flex items-center justify-center p-6">
<form on:submit|preventDefault={anmelden} <form on:submit|preventDefault={anmelden}
class="bg-white shadow-md rounded-2xl p-8 max-w-2xl w-full space-y-4"> class="bg-white shadow-md rounded-2xl p-8 max-w-2xl w-full space-y-4">
<h1 class="text-2xl font-bold text-gray-800 mb-4 text-center">Praktikumsanmeldung</h1> <h1 class="text-2xl font-bold text-gray-800 mb-4 text-center">Praktikumsanmeldung</h1>
@@ -88,67 +400,328 @@
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<input bind:value={vorname} placeholder="Vorname" required class="input" /> <input bind:value={vorname} placeholder="Vorname" required class="input" />
<input bind:value={nachname} placeholder="Nachname" required class="input" /> <input bind:value={nachname} placeholder="Nachname" required class="input" />
<input bind:value={geburtsdatum} type="date" placeholder="Geburtsdatum" required class="input col-span-2" /> <div class="flex items-center">Geburtsdatum</div>
<input bind:value={geburtsdatum} type="date" placeholder="Geburtsdatum" required class="input" />
<input bind:value={strasse} placeholder="Straße" required class="input" /> <input bind:value={strasse} placeholder="Straße" required class="input" />
<input bind:value={hausnummer} placeholder="Hausnummer" required class="input" /> <input bind:value={hausnummer} placeholder="Hausnummer" required class="input" />
<input bind:value={plz} placeholder="Postleitzahl" required class="input" />
<!-- PLZ mit Validierung -->
<div>
<input
bind:value={plz}
placeholder="Postleitzahl"
required
class="input"
class:input-error={plzFehler}
maxlength="5"
inputmode="numeric"
/>
{#if plzFehler}
<p class="text-red-600 text-sm mt-1">{plzFehler}</p>
{/if}
</div>
<input bind:value={ort} placeholder="Ort" required class="input" /> <input bind:value={ort} placeholder="Ort" required class="input" />
<input bind:value={telefon} placeholder="Telefonnummer" required class="input col-span-2" />
<input bind:value={email} type="email" placeholder="E-Mail-Adresse" required class="input col-span-2" /> <!-- Telefonnummer mit Validierung -->
<input bind:value={schulart} placeholder="Schulart" required class="input col-span-2" /> <div class="col-span-2">
<input bind:value={zeitraum} placeholder="Wunschzeitraum fürs Praktikum" required class="input col-span-2" /> <input
bind:value={telefon}
type="tel"
placeholder="Telefonnummer (z.B. 0511 1234567 oder 0171 1234567)"
required
class="input"
class:input-error={telefonFehler}
/>
{#if telefonFehler}
<p class="text-red-600 text-sm mt-1">{telefonFehler}</p>
{/if}
</div>
<!-- E-Mail mit Validierung -->
<div class="col-span-2">
<input
bind:value={email}
type="email"
placeholder="E-Mail-Adresse"
required
class="input"
class:input-error={emailFehler}
/>
{#if emailFehler}
<p class="text-red-600 text-sm mt-1">{emailFehler}</p>
{/if}
</div>
<select bind:value={schulart} required class="input">
<option value="" disabled selected hidden>Schulart wählen</option>
<option value="Gymnasium">Gymnasium</option>
<option value="KGS_Gymnasialzweig">KGS Gymnasialzweig</option>
<option value="Fachoberschule">Fachoberschule</option>
<option value="Realschule">Realschule</option>
<option value="KGSR">Kooperative Gesamtschule Realschulzweg</option>
<option value="IGSR">Integrierte Gesamtschule Realschulzweig</option>
</select>
<!-- Schulklasse -->
<select bind:value={schulklasse} required class="input">
<option value="" disabled selected hidden>Schulklasse</option>
<option value="7">7. Klasse</option>
<option value="8">8. Klasse</option>
<option value="9">9. Klasse</option>
<option value="10">10. Klasse</option>
<option value="11">11. Klasse</option>
<option value="12">12. Klasse</option>
<option value="13">13. Klasse</option>
</select>
<!-- Sozialverhalten mit Echtzeit-Validierung -->
{#if !hideSozialVerhalten}
<div class="col-span-2">
<select
bind:value={sozialverhalten}
required
class="input"
class:input-error={sozialverhaltenFehler}
>
<option value="" disabled selected hidden>Sozialverhalten auswählen</option>
<option value="Entspricht den Erwartungen in vollem Umfang">Entspricht den Erwartungen in vollem Umfang</option>
<option value="Entspricht den Erwartungen">Entspricht den Erwartungen</option>
<option value="Entspricht den Erwartungen mit Einschränkungen">Entspricht den Erwartungen mit Einschränkungen</option>
</select>
{#if sozialverhaltenFehler}
<p class="text-red-600 text-sm mt-1">{sozialverhaltenFehler}</p>
{/if}
</div>
{/if}
<!-- Noten mit Echtzeit-Validierung -->
<div class="col-span-2">
<!-- Hinweis für IGS/KGS bei Klasse < 10 -->
{#if zeigeIgsKgsHinweis}
<div class="mb-3 p-3 bg-blue-50 border border-blue-200 rounded-xl">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<p class="text-sm text-blue-700">
Sollte Dir nur ein Lernentwicklungsbericht vorliegen, lass Dir bitte die Noten für Mathe und Deutsch von Deiner Schule bescheinigen. Die integrierten Gesamtschulen stellen die Bescheinigung auf Anfrage aus.
</p>
</div>
</div>
{/if}
<div class="grid grid-cols-2 gap-4">
<input
bind:value={noteDeutsch}
type="number"
min="1"
max="6"
placeholder="Note in Deutsch"
required
class="input"
class:input-error={notenFehler}
/>
<input
bind:value={noteMathe}
type="number"
min="1"
max="6"
placeholder="Note in Mathe"
required
class="input"
class:input-error={notenFehler}
/>
</div>
{#if notenFehler}
<p class="text-red-600 text-sm mt-1">{notenFehler}</p>
{/if}
</div>
</div> </div>
<!-- Wunschdienststellen --> <!-- Zeitraum-Auswahl -->
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<select bind:value={wunsch1Id} required> <select bind:value={zeitraum} required class="input">
<option value="" disabled selected>1. Wunschdienststelle</option> <option value="" disabled selected hidden>Wunschzeitraum fürs Praktikum</option>
{#each dienststellen as d} {#each filteredZeitraeume as d}
<option value={d.id}>{d.name}</option> <option
value={d.id}>{d.bezeichnung} ({new Date(d.startDatum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })} - {new Date(d.endDatum).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })})
</option>
{/each} {/each}
</select> </select>
</div>
<select bind:value={wunsch2Id} required> <!-- Alter-Anzeige mit Echtzeit-Validierung -->
<option value="" disabled selected>2. Wunschdienststelle</option> {#if alter}
{#each dienststellen as d} <div class="text-sm" class:text-gray-600={!alterFehler} class:text-red-600={alterFehler}>
<option value={d.id}>{d.name}</option> Alter zu Praktikumsbeginn: {alter} Jahre
{/each} {#if alterFehler}
</select> <p class="font-semibold mt-1">{alterFehler}</p>
{/if}
</div>
{/if}
<select bind:value={wunsch3Id} required> <!-- Wunschdienststellen - erst auswählbar wenn Zeitraum gewählt -->
<option value="" disabled selected>3. Wunschdienststelle</option> <div class="grid grid-cols-1 gap-4">
{#each dienststellen as d} {#if isLoadingDienststellen}
<option value={d.id}>{d.name}</option> <div class="flex items-center justify-center py-4 text-gray-500">
{/each} <svg class="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
</select> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Lade verfügbare Dienststellen...
</div>
{:else}
<!-- Hinweis wenn nur begrenzte Auswahl möglich -->
{#if zeitraum && filteredDienststellen.length > 0 && filteredDienststellen.length < 3}
<div class="p-3 bg-blue-50 border border-blue-200 rounded-xl">
<div class="flex items-start">
<svg class="w-5 h-5 text-blue-500 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
<p class="text-sm text-blue-700">
Für diesen Zeitraum {filteredDienststellen.length === 1 ? 'ist nur noch 1 Dienststelle' : 'sind nur noch ' + filteredDienststellen.length + ' Dienststellen'} verfügbar.
</p>
</div>
</div>
{/if}
<!-- 1. Wunsch - immer anzeigen wenn Dienststellen vorhanden -->
{#if filteredDienststellen.length >= 1}
<select bind:value={wunsch1Id} required disabled={!zeitraum} class="input" class:opacity-50={!zeitraum}>
<option value="" disabled selected>
{zeitraum ? '1. Wunschdienststelle' : 'Bitte zuerst Zeitraum wählen'}
</option>
{#each filteredDienststellen as d}
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
{/each}
</select>
{/if}
<!-- 2. Wunsch - nur anzeigen wenn mindestens 2 Dienststellen verfügbar -->
{#if filteredDienststellen.length >= 2}
<select bind:value={wunsch2Id} required disabled={!zeitraum || !wunsch1Id} class="input" class:opacity-50={!zeitraum || !wunsch1Id}>
<option value="" disabled selected>2. Wunschdienststelle</option>
{#each filteredDienststellen.filter(d => d.id != wunsch1Id) as d}
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
{/each}
</select>
{/if}
<!-- 3. Wunsch - nur anzeigen wenn mindestens 3 Dienststellen verfügbar -->
{#if filteredDienststellen.length >= 3}
<select bind:value={wunsch3Id} required disabled={!zeitraum || !wunsch2Id} class="input" class:opacity-50={!zeitraum || !wunsch2Id}>
<option value="" disabled selected>3. Wunschdienststelle</option>
{#each filteredDienststellen.filter(d => d.id != wunsch1Id && d.id != wunsch2Id) as d}
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
{/each}
</select>
{/if}
{/if}
<!-- Hinweis wenn keine Dienststellen verfügbar -->
{#if zeitraum && !isLoadingDienststellen && filteredDienststellen.length === 0}
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded-xl">
<div class="flex items-start">
<svg class="w-5 h-5 text-yellow-500 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<p class="text-sm text-yellow-700">
Für den gewählten Zeitraum sind leider keine Praktikumsplätze mehr verfügbar. Bitte wähle einen anderen Zeitraum.
</p>
</div>
</div>
{/if}
</div> </div>
<!-- Motivation --> <!-- Motivation -->
<textarea bind:value={motivation} placeholder="Motivation (optional)" <textarea bind:value={motivation} placeholder="Motivation (optional)"
class="w-full border border-gray-300 rounded-xl p-3 focus:outline-none focus:ring-2 focus:ring-blue-500 h-32 resize-none" > class="w-full border border-gray-300 rounded-xl p-3 focus:outline-none focus:ring-2 focus:ring-blue-500 h-32 resize-none" >
</textarea> </textarea>
<!-- Mehrere PDF Upload --> <!-- Mehrere PDF Upload -->
<div> {#key fileInputKey}
<label for="pdf-upload" class="block text-gray-700 font-medium mb-1">PDFs hochladen (optional):</label> <div>
<input <label for="pdf-upload" class="block text-gray-700 font-medium mb-1">Zeugnis hochladen:</label>
id="pdf-upload" <input
type="file" id="pdf-upload"
accept="application/pdf" type="file"
multiple accept="application/pdf"
on:change={(e) => pdfDateien = Array.from((e.target as HTMLInputElement).files || [])} multiple
class="input" on:change={(e) => pdfDateien = Array.from((e.target as HTMLInputElement).files || [])}
/> class="input"
/>
</div>
{/key}
<!-- Notfallkontakt -->
<div class="border-t pt-4 mt-4">
<h2 class="text-lg font-semibold text-gray-700 mb-3">Notfallkontakt</h2>
<div class="grid grid-cols-2 gap-4">
<input bind:value={notfallVorname} placeholder="Vorname Notfallkontakt" required class="input" />
<input bind:value={notfallNachname} placeholder="Nachname Notfallkontakt" required class="input" />
<!-- Notfall-Telefonnummer mit Validierung -->
<div class="col-span-2">
<input
bind:value={notfallTelefon}
type="tel"
placeholder="Mobilnummer Notfallkontakt (z.B. 0171 1234567)"
required
class="input"
class:input-error={notfallTelefonFehler}
/>
{#if notfallTelefonFehler}
<p class="text-red-600 text-sm mt-1">{notfallTelefonFehler}</p>
{/if}
</div>
</div>
</div> </div>
<!-- Button --> {#if showAblehnungModal}
<button type="submit" <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
class="w-full bg-blue-600 text-white py-3 rounded-xl hover:bg-blue-700 transition-all"> <div class="bg-white p-6 rounded shadow-lg text-center space-y-4 max-w-sm w-full">
<p class="text-red-600 font-semibold">{ablehnungHinweis}</p>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={() => { resetForm(); showAblehnungModal = false; }}
>
OK
</button>
</div>
</div>
{/if}
<!-- Button - deaktiviert bei Validierungsfehlern oder fehlenden Pflichtfeldern -->
<button
type="submit"
disabled={formHatFehler || !zeitraum || !wuenscheVollstaendig}
class="w-full py-3 rounded-xl transition-all"
class:bg-blue-600={!formHatFehler && zeitraum && wuenscheVollstaendig}
class:hover:bg-blue-700={!formHatFehler && zeitraum && wuenscheVollstaendig}
class:text-white={!formHatFehler && zeitraum && wuenscheVollstaendig}
class:bg-gray-400={formHatFehler || !zeitraum || !wuenscheVollstaendig}
class:cursor-not-allowed={formHatFehler || !zeitraum || !wuenscheVollstaendig}
>
Jetzt anmelden Jetzt anmelden
</button> </button>
{#if formHatFehler}
<p class="text-red-600 text-sm text-center">Bitte korrigiere die markierten Fehler, um fortzufahren.</p>
{/if}
{#if success} {#if success}
<p class="text-green-600 font-semibold text-center">Anmeldung erfolgreich gesendet!</p> <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-6 rounded shadow-lg text-center space-y-4 max-w-sm w-full">
<p class="text-green-600 font-semibold">Anmeldung erfolgreich gesendet!</p>
<button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={resetForm}
>
OK
</button>
</div>
</div>
{/if} {/if}
{#if fehler} {#if fehler}
@@ -160,15 +733,27 @@
<style> <style>
.input { .input {
width: 100%; width: 100%;
border: 1px solid #d1d5db; /* border-gray-300 */ border: 1px solid #d1d5db;
border-radius: 0.75rem; /* rounded-xl */ border-radius: 0.75rem;
padding: 0.75rem; /* p-3 */ padding: 0.75rem;
outline: none; outline: none;
transition: box-shadow 0.2s; transition: box-shadow 0.2s, border-color 0.2s;
} }
.input:focus { .input:focus {
outline: none; outline: none;
box-shadow: 0 0 0 2px #3b82f6; /* focus:ring-2 focus:ring-blue-500 */ box-shadow: 0 0 0 2px #3b82f6;
border-color: #3b82f6; border-color: #3b82f6;
} }
.input:disabled {
background-color: #f3f4f6;
cursor: not-allowed;
}
.input-error {
border-color: #dc2626;
background-color: #fef2f2;
}
.input-error:focus {
box-shadow: 0 0 0 2px #dc2626;
border-color: #dc2626;
}
</style> </style>

View File

@@ -1,62 +1,67 @@
<!-- src/routes/admin/+page.svelte -->
<script lang="ts"> <script lang="ts">
let passwort = ''; import { onMount } from 'svelte';
let eingeloggt = false; import { goto } from '$app/navigation';
let fehler = false; import LoginForm from '$lib/components/LoginForm.svelte';
import AdminNavigation from '$lib/components/AdminNavigation.svelte';
async function login() { let isAuthenticated = false;
const res = await fetch('/api/admin/login', { let isLoading = true;
method: 'POST',
body: JSON.stringify({ passwort }),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) { onMount(async () => {
eingeloggt = true; // Prüfe ob bereits eingeloggt
fehler = false; try {
} else { const res = await fetch('/api/admin/check-auth');
fehler = true; isAuthenticated = res.ok;
} catch (error) {
console.error('Auth check failed:', error);
isAuthenticated = false;
}
isLoading = false;
});
async function handleLogin(event: CustomEvent<{success: boolean}>) {
if (event.detail.success) {
isAuthenticated = true;
}
}
async function handleLogout() {
try {
await fetch('/api/admin/logout', { method: 'POST' });
isAuthenticated = false;
goto('/admin');
} catch (error) {
console.error('Logout failed:', error);
} }
} }
</script> </script>
<div class="p-6 max-w-lg mx-auto"> <div class="min-h-screen bg-gray-50 py-8">
{#if !eingeloggt} <div class="max-w-2xl mx-auto px-4">
<div class="space-y-4"> {#if isLoading}
<h1 class="text-2xl font-bold">Admin Login</h1> <div class="flex justify-center items-center h-64">
<input type="password" bind:value={passwort} placeholder="Passwort" class="input w-full" /> <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<button on:click={login} class="bg-blue-600 text-white px-4 py-2 rounded">Login</button>
{#if fehler}
<p class="text-red-600">Falsches Passwort</p>
{/if}
</div>
{:else}
<div class="space-y-4">
<h1 class="text-2xl font-bold mb-4">Admin-Bereich</h1>
<div class="flex flex-col gap-4">
<a href="/admin/anmeldungen" class="bg-blue-600 text-white px-4 py-3 rounded text-center hover:bg-blue-700">
📝 Anmeldungen anzeigen
</a>
<a href="/admin/dienststellen" class="bg-green-600 text-white px-4 py-3 rounded text-center hover:bg-green-700">
🏢 Dienststellen verwalten
</a>
<a href="/admin/change-password" class="bg-cyan-600 text-white px-4 py-3 rounded text-center hover:bg-green-700">
👨‍💼 Passwort ädern
</a>
</div> </div>
<button {:else if !isAuthenticated}
on:click={async () => { <div class="bg-white rounded-lg shadow-md p-6">
await fetch('/api/admin/logout', { method: 'POST' }); <h1 class="text-3xl font-bold text-gray-900 mb-6 text-center">Admin Login</h1>
location.reload(); <LoginForm on:loginResult={handleLogin} />
}} </div>
class="bg-red-600 text-white px-4 py-3 rounded text-center hiver:bg-red-700"> {:else}
<div class="bg-white rounded-lg shadow-md p-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900">Admin-Bereich</h1>
<button
on:click={handleLogout}
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md transition-colors"
>
Logout Logout
</button> </button>
</div> </div>
{/if}
</div>
<style> <AdminNavigation />
.input { </div>
@apply border rounded px-3 py-2 w-full; {/if}
} </div>
</style> </div>

View File

@@ -1,8 +1,16 @@
// src/routes/admin/anmeldungen/+page.server.ts
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
if (cookies.get('admin_session') !== 'true') { // Korrigiere Cookie-Name um konsistent zu sein
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
throw redirect(303, '/admin'); throw redirect(303, '/admin');
} }
return {
title: 'Anmeldungen verwalten'
};
}; };

View File

@@ -1,94 +1,880 @@
<script lang="ts"> <!-- src/routes/admin/anmeldungen/+page.svelte -->
import { onMount } from 'svelte'; <script lang="ts">
interface Anmeldung { import { onMount } from 'svelte';
pdfs: any; import { goto } from '$app/navigation';
anrede: string; import AnmeldungenTable from '$lib/components/AnmeldungenTable.svelte';
vorname: string; import DienststellenDialog from '$lib/components/DienststellenDialog.svelte';
nachname: string; import AdminHeader from '$lib/components/AdminHeader.svelte';
email: string;
wunsch1?: { name: string }; interface Anmeldung {
wunsch2?: { name: string }; pdfs: { pfad: string }[];
wunsch3?: { name: string }; anrede: string;
timestamp: number; vorname: string;
id: number; nachname: string;
email: string;
noteDeutsch?: string;
noteMathe?: string;
sozialverhalten?: string;
notfallVorname?: string;
notfallNachname?: string;
notfallTelefon?: string;
wunsch1?: { id: number; name: string };
wunsch2?: { id: number; name: string };
wunsch3?: { id: number; name: string };
zeitraum?: { id: number; bezeichnung: string };
timestamp: number;
id: number;
status?: 'pending' | 'accepted' | 'rejected';
assignedDienststelle?: { id: number; name: string };
processedBy?: string;
processedAt?: number;
}
interface Zeitraum {
id: number;
bezeichnung: string;
startDatum: string;
endDatum: string;
}
interface EmailConfig {
subject: string;
template: string;
}
let anmeldungen: Anmeldung[] = [];
let zeitraeume: Zeitraum[] = [];
let isLoading = true;
let error = '';
// Filter für Status
let statusFilter: 'all' | 'pending' | 'accepted' | 'rejected' = 'all';
let filteredAnmeldungen: Anmeldung[] = [];
// Dialog state
let showDialog = false;
let selectedAnmeldungId: number | null = null;
let selectedDienststelleId: number | null = null;
let availableWishes: { id: number, name: string }[] = [];
// E-Mail Konfiguration
let emailSubject = 'Praktikumsplatz-Zusage';
let emailTemplate = `Sehr geehrte/r \{anrede\} \{nachname\},
wir freuen uns, Ihnen mitteilen zu können, dass Ihre Bewerbung für ein Praktikum erfolgreich war.
Sie wurden für das Praktikum bei folgender Dienststelle angenommen:
\{dienststelle\}
Weitere Informationen erhalten Sie in den kommenden Tagen.
Mit freundlichen Grüßen
Ihr Praktikumsteam`;
let showEmailConfig = false;
let isLoadingEmailConfig = false;
let isSavingEmailConfig = false;
// E-Mail Preview Modal State
let showEmailPreview = false;
let emailPreviewData: {
to: string;
subject: string;
body: string;
} | null = null;
let emailCopied = false;
// Export Modal State
let showExportModal = false;
let selectedExportZeitraum = '';
let isExporting = false;
// Status-Badge Funktionen
function getStatusColor(status: string): string {
switch (status) {
case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'accepted': return 'bg-green-100 text-green-800';
case 'rejected': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
}
function getStatusText(status: string): string {
switch (status) {
case 'pending': return 'Offen';
case 'accepted': return 'Angenommen';
case 'rejected': return 'Abgelehnt';
default: return 'Unbekannt';
}
}
// Filter-Funktionen
function filterAnmeldungen() {
if (statusFilter === 'all') {
filteredAnmeldungen = anmeldungen;
} else {
filteredAnmeldungen = anmeldungen.filter(a => (a.status || 'pending') === statusFilter);
}
}
$: {
anmeldungen, statusFilter, filterAnmeldungen();
}
// Zähle angenommene Anmeldungen pro Zeitraum
function getAcceptedCountForZeitraum(zeitraumId: number): number {
return anmeldungen.filter(a =>
a.status === 'accepted' &&
a.zeitraum?.id === zeitraumId
).length;
}
async function loadAnmeldungen() {
try {
isLoading = true;
error = '';
const res = await fetch('/api/admin/anmeldungen');
if (!res.ok) {
const errorText = await res.text();
console.error('❌ API Fehler:', res.status, errorText);
throw new Error(`Fehler beim Laden: ${res.status} - ${errorText}`);
}
const data = await res.json();
if (!Array.isArray(data)) {
console.error('❌ Antwort ist kein Array:', data);
throw new Error('Antwort vom Server ist kein Array');
}
anmeldungen = data.map(a => {
return { ...a, status: a.status || 'pending' };
});
} catch (err) {
error = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('❌ Frontend Fehler beim Laden der Anmeldungen:', err);
} finally {
isLoading = false;
}
}
async function loadZeitraeume() {
try {
const res = await fetch('/api/zeitraeume');
if (res.ok) {
zeitraeume = await res.json();
}
} catch (err) {
console.error('Fehler beim Laden der Zeiträume:', err);
}
}
async function loadEmailConfig() {
try {
isLoadingEmailConfig = true;
const res = await fetch('/api/admin/email-config');
if (!res.ok) {
throw new Error(`Fehler beim Laden der E-Mail-Konfiguration: ${res.status}`);
}
const config: EmailConfig = await res.json();
emailSubject = config.subject;
emailTemplate = config.template;
} catch (err) {
console.error('Fehler beim Laden der E-Mail-Konfiguration:', err);
} finally {
isLoadingEmailConfig = false;
}
}
async function saveEmailTemplate() {
try {
isSavingEmailConfig = true;
const res = await fetch('/api/admin/email-config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subject: emailSubject,
template: emailTemplate
})
});
if (!res.ok) {
throw new Error(`Fehler beim Speichern: ${res.status}`);
}
showEmailConfig = false;
alert('E-Mail-Vorlage erfolgreich gespeichert!');
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Speichern der E-Mail-Vorlage';
console.error(err);
} finally {
isSavingEmailConfig = false;
}
}
function handleAccept(event: CustomEvent<{id: number}>) {
const anmeldung = anmeldungen.find(a => a.id === event.detail.id);
if (!anmeldung) return;
availableWishes = [
anmeldung.wunsch1 && { id: anmeldung.wunsch1.id, name: `1. Wunsch: ${anmeldung.wunsch1.name}` },
anmeldung.wunsch2 && { id: anmeldung.wunsch2.id, name: `2. Wunsch: ${anmeldung.wunsch2.name}` },
anmeldung.wunsch3 && { id: anmeldung.wunsch3.id, name: `3. Wunsch: ${anmeldung.wunsch3.name}` }
].filter(Boolean) as { id: number, name: string }[];
selectedDienststelleId = availableWishes[0]?.id ?? null;
selectedAnmeldungId = event.detail.id;
showDialog = true;
}
async function handleConfirmAccept(event: CustomEvent<{dienststelleId: number}>) {
if (!selectedAnmeldungId) return;
try {
const res = await fetch(`/api/admin/anmeldungen?id=${selectedAnmeldungId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dienststelleId: event.detail.dienststelleId })
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || `Fehler beim Annehmen (${res.status})`);
}
showDialog = false;
// E-Mail Vorschau öffnen nach erfolgreichem Annehmen
openEmailPreview(selectedAnmeldungId, event.detail.dienststelleId);
selectedAnmeldungId = null;
await loadAnmeldungen();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Annehmen';
console.error(err);
}
}
function openEmailPreview(anmeldungId: number, dienststelleId: number) {
const anmeldung = anmeldungen.find(a => a.id === anmeldungId);
if (!anmeldung) return;
// Dienststelle finden
const dienststelle = availableWishes.find(w => w.id === dienststelleId);
const dienststelleName = dienststelle
? dienststelle.name.replace(/^\d+\.\s*Wunsch:\s*/, '')
: 'Unbekannte Dienststelle';
// E-Mail Text personalisieren
const personalizedEmail = emailTemplate
.replace(/\{anrede\}/g, anmeldung.anrede)
.replace(/\{vorname\}/g, anmeldung.vorname)
.replace(/\{nachname\}/g, anmeldung.nachname)
.replace(/\{dienststelle\}/g, dienststelleName);
// Modal mit Vorschau öffnen
emailPreviewData = {
to: anmeldung.email,
subject: emailSubject,
body: personalizedEmail
};
emailCopied = false;
showEmailPreview = true;
}
async function copyAndOpenMail() {
if (!emailPreviewData) return;
try {
await navigator.clipboard.writeText(emailPreviewData.body);
emailCopied = true;
// Kurz warten, dann Mail öffnen
setTimeout(() => {
const mailtoLink = `mailto:${emailPreviewData!.to}?subject=${encodeURIComponent(emailPreviewData!.subject)}`;
window.location.href = mailtoLink;
}, 500);
// Modal nach 2 Sekunden schließen
setTimeout(() => {
closeEmailPreview();
}, 2000);
} catch (err) {
console.error('Clipboard-Fehler:', err);
error = 'Konnte Text nicht kopieren. Bitte manuell markieren und kopieren.';
}
}
function closeEmailPreview() {
showEmailPreview = false;
emailPreviewData = null;
emailCopied = false;
}
// Export-Funktionen
function openExportModal() {
selectedExportZeitraum = '';
showExportModal = true;
}
function closeExportModal() {
showExportModal = false;
selectedExportZeitraum = '';
}
async function exportToExcel() {
if (!selectedExportZeitraum) {
error = 'Bitte wählen Sie einen Zeitraum aus.';
return;
} }
let anmeldungen: Anmeldung[] = []; try {
isExporting = true;
error = '';
async function ladeAnmeldungen() { const res = await fetch(`/api/admin/export?zeitraumId=${selectedExportZeitraum}`);
const res = await fetch('/api/admin/anmeldungen');
anmeldungen = await res.json(); if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Fehler beim Exportieren');
} }
async function loeschen(id: number) { // Download auslösen
if (!confirm('Diese Anmeldung wirklich löschen?')) return; const blob = await res.blob();
try { const contentDisposition = res.headers.get('Content-Disposition');
const res = await fetch(`/api/admin/anmeldungen?id=${id}`, { method: 'DELETE' }); let filename = 'export.xlsx';
if (!res.ok) {
const errorText = await res.text(); if (contentDisposition) {
throw new Error(`Fehler beim Löschen (${res.status}): ${errorText}`); const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
filename = match[1];
} }
await ladeAnmeldungen();
} catch (error) {
console.error(error);
alert('Fehler beim Löschen der Anmeldung.\n' + (error as Error).message);
} }
}
onMount(ladeAnmeldungen); const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
closeExportModal();
<div class="p-6 max-w-7xl mx-auto"> } catch (err) {
<h1 class="text-2xl font-bold mb-4 text-center">Alle Anmeldungen</h1> error = err instanceof Error ? err.message : 'Fehler beim Exportieren';
<table class="w-full border text-sm"> console.error(err);
<thead> } finally {
<tr class="bg-gray-200"> isExporting = false;
<th class="p-2 text-left">Name</th> }
<th class="p-2 text-left">E-Mail</th> }
<th class="p-2 text-left">Wunsch 13</th>
<th class="p-2 text-left">Datum</th> async function handleReject(event: CustomEvent<{id: number}>) {
<th class="p-2 text-left">Dateien</th> if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
<th class="p-2 text-left">Aktionen</th>
</tr> try {
</thead> const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, {
<tbody> method: 'PATCH',
{#each anmeldungen as a} headers: { 'Content-Type': 'application/json' },
<tr class="border-t"> body: JSON.stringify({ action: 'reject' })
<td class="p-2">{a.anrede} {a.vorname} {a.nachname}</td> });
<td class="p-2">{a.email}</td>
<td class="p-2"> if (!res.ok) {
{a.wunsch1?.name}<br> throw new Error(`Fehler beim Ablehnen: ${res.status}`);
{a.wunsch2?.name}<br> }
{a.wunsch3?.name}
</td> await loadAnmeldungen();
<td class="p-2">{new Date(a.timestamp).toLocaleDateString()}</td> } catch (err) {
<td class="p-2"> error = err instanceof Error ? err.message : 'Fehler beim Ablehnen';
{#each a.pdfs as pdf} console.error(err);
<li> }
<a href={pdf.pfad} target="_blank" class="text-blue-600 hover:underline"> }
PDF ansehen
</a> async function handleDelete(event: CustomEvent<{id: number}>) {
if (!confirm('Diese Anmeldung wirklich löschen?')) return;
try {
const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, {
method: 'DELETE'
});
if (!res.ok) {
throw new Error(`Fehler beim Löschen: ${res.status}`);
}
await loadAnmeldungen();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen';
console.error(err);
}
}
function closeDialog() {
showDialog = false;
selectedAnmeldungId = null;
}
onMount(() => {
loadAnmeldungen();
loadZeitraeume();
loadEmailConfig();
});
</script>
<svelte:head>
<title>Anmeldungen verwalten - Admin</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<AdminHeader
title="Anmeldungen verwalten"
showBackButton={true}
/>
<main class="max-w-7xl mx-auto px-4 py-6">
<!-- Filter, Export und E-Mail Konfiguration -->
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
<!-- Status Filter -->
<div class="flex items-center space-x-4">
<label for="status-filter" class="text-sm font-medium text-gray-700">Filter:</label>
<select
id="status-filter"
bind:value={statusFilter}
class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500"
>
<option value="all">Alle ({anmeldungen.length})</option>
<option value="pending">Offen ({anmeldungen.filter(a => (a.status || 'pending') === 'pending').length})</option>
<option value="accepted">Angenommen ({anmeldungen.filter(a => a.status === 'accepted').length})</option>
<option value="rejected">Abgelehnt ({anmeldungen.filter(a => a.status === 'rejected').length})</option>
</select>
</div>
<!-- Buttons -->
<div class="flex items-center space-x-3">
<!-- Excel Export Button -->
<button
on:click={openExportModal}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
disabled={anmeldungen.filter(a => a.status === 'accepted').length === 0}
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Excel-Export
</button>
<!-- E-Mail Konfiguration Button -->
<button
on:click={() => showEmailConfig = !showEmailConfig}
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
disabled={isLoadingEmailConfig}
>
{#if isLoadingEmailConfig}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{:else}
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
{/if}
E-Mail-Vorlage
</button>
</div>
</div>
<!-- Status Übersicht -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-yellow-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-yellow-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Offen</p>
<p class="text-2xl font-semibold text-gray-900">
{anmeldungen.filter(a => (a.status || 'pending') === 'pending').length}
</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-green-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Angenommen</p>
<p class="text-2xl font-semibold text-gray-900">
{anmeldungen.filter(a => a.status === 'accepted').length}
</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg border border-gray-200 p-4">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">Abgelehnt</p>
<p class="text-2xl font-semibold text-gray-900">
{anmeldungen.filter(a => a.status === 'rejected').length}
</p>
</div>
</div>
</div>
</div>
<!-- E-Mail Konfiguration Panel -->
{#if showEmailConfig}
<div class="bg-white shadow-sm rounded-lg p-6 mb-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">E-Mail-Vorlage konfigurieren</h3>
<div class="space-y-4">
<div>
<label for="email-subject" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail Betreff
</label>
<input
id="email-subject"
type="text"
bind:value={emailSubject}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isSavingEmailConfig}
/>
</div>
<div>
<label for="email-template" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail Text
</label>
<textarea
id="email-template"
bind:value={emailTemplate}
rows="10"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isSavingEmailConfig}
></textarea>
<p class="mt-2 text-sm text-gray-500">
Verfügbare Platzhalter: &#123;anrede&#125;, &#123;vorname&#125;, &#123;nachname&#125;, &#123;dienststelle&#125;
</p>
</div>
<div class="flex justify-end space-x-3">
<button
on:click={() => showEmailConfig = false}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
disabled={isSavingEmailConfig}
>
Abbrechen
</button>
<button
on:click={saveEmailTemplate}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
disabled={isSavingEmailConfig}
>
{#if isSavingEmailConfig}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Speichere...
{:else}
Speichern
{/if}
</button>
</div>
</div>
</div>
{/if}
{#if error}
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{error}</p>
</div>
<button
on:click={() => error = ''}
class="ml-auto text-red-400 hover:text-red-600"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/if}
{#if isLoading}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Lade Anmeldungen...</span>
</div>
{:else if filteredAnmeldungen.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">
{statusFilter === 'all' ? 'Keine Anmeldungen' : `Keine ${getStatusText(statusFilter).toLowerCase()}en Anmeldungen`}
</h3>
<p class="mt-1 text-sm text-gray-500">
{statusFilter === 'all'
? 'Es sind noch keine Praktikumsanmeldungen eingegangen.'
: `Es gibt keine Anmeldungen mit dem Status "${getStatusText(statusFilter).toLowerCase()}".`}
</p>
</div>
{:else}
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<AnmeldungenTable
anmeldungen={filteredAnmeldungen}
{getStatusColor}
{getStatusText}
on:accept={handleAccept}
on:reject={handleReject}
on:delete={handleDelete}
/>
</div>
{/if}
</main>
</div>
<!-- Dienststellen Dialog -->
{#if showDialog}
<DienststellenDialog
wishes={availableWishes}
selectedId={selectedDienststelleId}
on:confirm={handleConfirmAccept}
on:cancel={closeDialog}
/>
{/if}
<!-- Export Modal -->
{#if showExportModal}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
<!-- Backdrop -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
on:click={closeExportModal}
></div>
<!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl max-w-md w-full">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900">Excel-Export</h3>
</div>
<button
on:click={closeExportModal}
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 py-4">
<p class="text-sm text-gray-600 mb-4">
Wählen Sie einen Praktikumszeitraum aus, um alle angenommenen Anmeldungen als Excel-Datei zu exportieren.
</p>
<div>
<label for="export-zeitraum" class="block text-sm font-medium text-gray-700 mb-2">
Praktikumszeitraum
</label>
<select
id="export-zeitraum"
bind:value={selectedExportZeitraum}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500"
disabled={isExporting}
>
<option value="" disabled>Zeitraum auswählen...</option>
{#each zeitraeume as z}
{@const count = getAcceptedCountForZeitraum(z.id)}
<option value={z.id} disabled={count === 0}>
{z.bezeichnung} ({count} angenommen)
</option>
{/each} {/each}
{/each} </select>
</td> </div>
<td class="p-2 text-right"> </div>
<button
class="text-red-600 hover:underline"
on:click={() => loeschen(a.id)}>
Löschen
</button>
</td>
</tr>
{/each}
</tbody>
<!-- Footer -->
<button <div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end space-x-3">
on:click={async () => { <button
await fetch('/api/admin/logout', { method: 'POST' }); on:click={closeExportModal}
location.reload(); class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
}} disabled={isExporting}
class="bg-red-600 text-white px-4 py-3 rounded text-center hover:bg-red-700"> >
Logout Abbrechen
</button>
<button
on:click={exportToExcel}
class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded-md text-sm font-medium inline-flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedExportZeitraum || isExporting}
>
{#if isExporting}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Exportiere...
{:else}
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Exportieren
{/if}
</button>
</div>
</div>
</div>
</div> </div>
{/if}
<!-- E-Mail Vorschau Modal -->
{#if showEmailPreview && emailPreviewData}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
<!-- Backdrop -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
on:click={closeEmailPreview}
></div>
<!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-white">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900">E-Mail Vorschau</h3>
</div>
<button
on:click={closeEmailPreview}
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 py-4 overflow-y-auto max-h-[60vh]">
<div class="space-y-4">
<!-- Empfänger -->
<div class="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<span class="text-sm font-medium text-gray-500 w-16">An:</span>
<span class="text-sm text-gray-900">{emailPreviewData.to}</span>
</div>
<!-- Betreff -->
<div class="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg">
<span class="text-sm font-medium text-gray-500 w-16">Betreff:</span>
<span class="text-sm text-gray-900">{emailPreviewData.subject}</span>
</div>
<!-- Nachricht (editierbar) -->
<div>
<label for="email-body" class="block text-sm font-medium text-gray-700 mb-2">
Nachricht
<span class="font-normal text-gray-500">(kann bearbeitet werden)</span>
</label>
<textarea
id="email-body"
bind:value={emailPreviewData.body}
rows="14"
class="w-full border border-gray-300 rounded-lg px-4 py-3 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
disabled={emailCopied}
></textarea>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
{#if emailCopied}
<div class="flex items-center justify-center py-3">
<div class="flex items-center space-x-3 text-green-600">
<div class="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<span class="font-medium">Text kopiert Outlook öffnet sich...</span>
</div>
</div>
{:else}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div class="flex items-start space-x-2 text-sm text-gray-600">
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Der Text wird kopiert und Outlook geöffnet.<br class="hidden sm:inline" />
Dann mit <kbd class="px-1.5 py-0.5 bg-gray-200 rounded text-xs font-mono">Strg</kbd> + <kbd class="px-1.5 py-0.5 bg-gray-200 rounded text-xs font-mono">V</kbd> einfügen.
</span>
</div>
<div class="flex space-x-3 w-full sm:w-auto">
<button
on:click={closeEmailPreview}
class="flex-1 sm:flex-none px-4 py-2.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
on:click={copyAndOpenMail}
class="flex-1 sm:flex-none bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg text-sm font-medium inline-flex items-center justify-center transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
Kopieren & E-Mail öffnen
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}

View File

@@ -1,18 +1,41 @@
<!-- src/routes/admin/change-password/+page.svelte -->
<script lang="ts"> <script lang="ts">
let oldPassword = ''; import AdminHeader from '$lib/components/AdminHeader.svelte';
let newPassword = '';
let confirmPassword = '';
let message = '';
let error = '';
async function changePassword() { let oldPassword = '';
message = ''; let newPassword = '';
error = ''; let confirmPassword = '';
let message = '';
let error = '';
let isLoading = false;
if (newPassword !== confirmPassword) { async function changePassword() {
error = 'Die neuen Passwörter stimmen nicht überein.'; message = '';
return; error = '';
}
// Validierung
if (!oldPassword.trim()) {
error = 'Altes Passwort ist erforderlich.';
return;
}
if (!newPassword.trim()) {
error = 'Neues Passwort ist erforderlich.';
return;
}
if (newPassword.length < 6) {
error = 'Das neue Passwort muss mindestens 6 Zeichen lang sein.';
return;
}
if (newPassword !== confirmPassword) {
error = 'Die neuen Passwörter stimmen nicht überein.';
return;
}
try {
isLoading = true;
const res = await fetch('/api/admin/change-password', { const res = await fetch('/api/admin/change-password', {
method: 'POST', method: 'POST',
@@ -26,57 +49,180 @@
if (!res.ok) { if (!res.ok) {
error = data.error || 'Fehler beim Ändern des Passworts.'; error = data.error || 'Fehler beim Ändern des Passworts.';
} else { } else {
message = 'Passwort erfolgreich geändert.'; message = 'Passwort erfolgreich geändert.';
oldPassword = newPassword = confirmPassword = ''; oldPassword = '';
newPassword = '';
confirmPassword = '';
} }
} catch (err) {
error = err instanceof Error ? err.message : 'Unbekannter Fehler beim Ändern des Passworts.';
console.error('Fehler beim Passwort ändern:', err);
} finally {
isLoading = false;
} }
}
function resetForm() {
<div class="max-w-lg mx-auto bg-white p-6 rounded-2xl shadow-md space-y-6 border border-gray-200"> oldPassword = '';
newPassword = '';
confirmPassword = '';
message = '';
error = '';
}
</script>
<svelte:head>
<div class="space-y-4"> <title>Passwort ändern - Admin</title>
<div> </svelte:head>
<label class="block text-sm font-medium text-gray-700">Altes Passwort</label>
<input
type="password"
bind:value={oldPassword}
class="mt-1 w-full px-4 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div class="min-h-screen bg-gray-50">
<div> <AdminHeader
<label class="block text-sm font-medium text-gray-700">Neues Passwort</label> title="Admin-Passwort ändern"
<input showBackButton={true}
type="password" />
bind:value={newPassword}
class="mt-1 w-full px-4 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Neues Passwort wiederholen</label>
<input
type="password"
bind:value={confirmPassword}
class="mt-1 w-full px-4 py-2 border rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<main class="max-w-7xl mx-auto px-4 py-6">
{#if error} {#if error}
{#if error} <div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div class="text-red-600 text-sm font-medium">{error}</div> <div class="flex">
{/if} <div class="flex-shrink-0">
{#if message} <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{error}</p>
</div>
</div>
</div>
{/if} {/if}
{#if message}
<div class="pt-4"> <div class="bg-green-50 border border-green-200 rounded-md p-4 mb-6">
<button <div class="flex">
on:click={changePassword} <div class="flex-shrink-0">
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded-xl transition duration-150" <svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
> <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
Passwort ändern </svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-green-800">Erfolg</h3>
<p class="mt-1 text-sm text-green-700">{message}</p>
</div>
</div>
</div>
{/if}
<!-- Passwort ändern Formular -->
<div class="max-w-2xl mx-auto">
<div class="bg-white shadow-sm rounded-lg p-6">
<div class="flex items-center mb-6">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
</div>
<div class="ml-4">
<h2 class="text-lg font-medium text-gray-900">Passwort ändern</h2>
<p class="text-sm text-gray-500">Aus Sicherheitsgründen sollten Sie Ihr Passwort regelmäßig ändern.</p>
</div>
</div>
<div class="space-y-6">
<div>
<label for="old-password" class="block text-sm font-medium text-gray-700 mb-2">
Aktuelles Passwort
</label>
<input
id="old-password"
type="password"
bind:value={oldPassword}
placeholder="Geben Sie Ihr aktuelles Passwort ein"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-2">
Neues Passwort
</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
placeholder="Geben Sie Ihr neues Passwort ein (mindestens 6 Zeichen)"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
/>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 mb-2">
Neues Passwort wiederholen
</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
placeholder="Wiederholen Sie Ihr neues Passwort"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isLoading}
/>
</div>
<div class="flex items-center justify-between pt-4">
<button
type="button"
on:click={resetForm}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:underline"
disabled={isLoading}
>
Formular zurücksetzen
</button>
<button
type="button"
on:click={changePassword}
disabled={isLoading}
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2 rounded-md text-sm font-medium inline-flex items-center"
>
{#if isLoading}
<svg class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Wird geändert...
{:else}
Passwort ändern
{/if}
</button>
</div>
</div>
<!-- Sicherheitshinweise -->
<div class="mt-8 p-4 bg-blue-50 rounded-md">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">Sicherheitshinweise</h3>
<div class="mt-2 text-sm text-blue-700">
<ul class="list-disc pl-5 space-y-1">
<li>Verwenden Sie ein starkes Passwort mit mindestens 6 Zeichen</li>
<li>Kombinieren Sie Buchstaben, Zahlen und Sonderzeichen</li>
<li>Verwenden Sie dieses Passwort nicht für andere Dienste</li>
<li>Ändern Sie Ihr Passwort regelmäßig</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </main>
</div>

View File

@@ -1,8 +1,16 @@
// src/routes/admin/dienstellen/+page.server.ts
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => { export const load: PageServerLoad = async ({ cookies }) => {
if (cookies.get('admin_session') !== 'true') { // Korrigiere Cookie-Name um konsistent zu sein
throw redirect(303, '/admin'); // zurück zur Login-Seite const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
throw redirect(303, '/admin');
} }
return {
title: 'Dienstellen verwalten'
};
}; };

View File

@@ -0,0 +1,236 @@
<!-- src/routes/admin/dienststellen/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import AdminHeader from '$lib/components/AdminHeader.svelte';
let dienststellen: { id: number; name: string }[] = [];
let neuerName = '';
let fehlermeldung = '';
let bearbeiteId: number | null = null;
let isLoading = true;
async function ladeDienststellen() {
try {
isLoading = true;
fehlermeldung = '';
const res = await fetch('/api/admin/dienststellen');
if (!res.ok) {
throw new Error(`Fehler beim Laden: ${res.status}`);
}
dienststellen = await res.json();
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Fehler beim Laden der Dienststellen:', err);
} finally {
isLoading = false;
}
}
function bearbeiten(d: { id: number; name: string }) {
neuerName = d.name;
bearbeiteId = d.id;
}
async function speichern() {
fehlermeldung = '';
if (!neuerName.trim()) {
fehlermeldung = 'Name ist erforderlich';
return;
}
try {
const method = bearbeiteId ? 'PATCH' : 'POST';
const body = bearbeiteId
? { id: bearbeiteId, name: neuerName }
: { name: neuerName };
const res = await fetch('/api/admin/dienststellen', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
neuerName = '';
bearbeiteId = null;
await ladeDienststellen();
} else {
const err = await res.json();
fehlermeldung = err.error || 'Fehler beim Speichern';
}
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Speichern';
console.error(err);
}
}
async function loeschen(id: number) {
if (!confirm('Diese Dienststelle wirklich löschen? Alle zugehörigen Platzangaben werden ebenfalls gelöscht.')) return;
try {
const res = await fetch(`/api/admin/dienststellen?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
fehlermeldung = err.error || 'Fehler beim Löschen';
return;
}
await ladeDienststellen();
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Löschen';
console.error(err);
}
}
function resetForm() {
neuerName = '';
bearbeiteId = null;
}
onMount(ladeDienststellen);
</script>
<svelte:head>
<title>Dienststellen verwalten - Admin</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<AdminHeader
title="Dienststellen verwalten"
showBackButton={true}
/>
<main class="max-w-5xl mx-auto px-4 py-6">
{#if fehlermeldung}
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{fehlermeldung}</p>
</div>
</div>
</div>
{/if}
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700">
Hier werden nur die Dienststellen verwaltet. Die Anzahl der Praktikumsplätze pro Zeitraum können Sie unter
<a href="/admin/plaetze" class="font-medium underline hover:text-blue-800">Plätze verwalten</a> festlegen.
</p>
</div>
</div>
</div>
<!-- Eingabeformular -->
<div class="bg-white shadow-sm rounded-lg p-6 mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
{bearbeiteId !== null ? 'Dienststelle bearbeiten' : 'Neue Dienststelle hinzufügen'}
</h2>
<div class="flex gap-4">
<div class="flex-1">
<label for="name" class="block text-sm font-medium text-gray-700 mb-2">
Dienststelle
</label>
<input
id="name"
type="text"
bind:value={neuerName}
placeholder="Name der Dienststelle"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div class="flex items-end gap-2">
<button
on:click={resetForm}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:underline"
>
Zurücksetzen
</button>
<button
on:click={speichern}
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md text-sm font-medium"
>
{bearbeiteId !== null ? 'Ändern' : 'Hinzufügen'}
</button>
</div>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Lade Dienststellen...</span>
</div>
{:else if dienststellen.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-4m-5 0H9m0 0H7m2 0v-5a2 2 0 012-2h2a2 2 0 012 2v5M7 7h.01M7 11h.01M11 7h.01M11 11h.01" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Keine Dienststellen</h3>
<p class="mt-1 text-sm text-gray-500">Erstellen Sie Ihre erste Dienststelle über das Formular oben.</p>
</div>
{:else}
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Alle Dienststellen ({dienststellen.length})</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dienststelle
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each dienststellen as d}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{d.name}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
on:click={() => bearbeiten(d)}
class="text-blue-600 hover:text-blue-900 mr-4"
>
Bearbeiten
</button>
<button
on:click={() => loeschen(d.id)}
class="text-red-600 hover:text-red-900"
>
Löschen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</main>
</div>

View File

@@ -0,0 +1,15 @@
// src/routes/admin/plaetze/+page.server.ts
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
throw redirect(303, '/admin');
}
return {
title: 'Plätze verwalten'
};
};

View File

@@ -0,0 +1,259 @@
<!-- src/routes/admin/plaetze/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import AdminHeader from '$lib/components/AdminHeader.svelte';
type ZeitraumPlaetzeData = {
zeitraumId: number;
zeitraumBezeichnung: string;
dienststellen: {
dienststelleId: number;
dienststelleName: string;
plaetze: number;
id: number; // ZeitraumPlaetze-ID
}[];
};
let data: ZeitraumPlaetzeData[] = [];
let isLoading = true;
let fehlermeldung = '';
let erfolgsmeldung = '';
let editingCell: { zeitraumId: number; dienststelleId: number } | null = null;
let editValue = '';
async function ladeDaten() {
try {
isLoading = true;
fehlermeldung = '';
const res = await fetch('/api/admin/plaetze');
if (!res.ok) {
throw new Error(`Fehler beim Laden: ${res.status}`);
}
data = await res.json();
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Fehler beim Laden der Plätze:', err);
} finally {
isLoading = false;
}
}
function startEdit(zeitraumId: number, dienststelleId: number, currentValue: number) {
editingCell = { zeitraumId, dienststelleId };
editValue = currentValue.toString();
}
async function speicherePlaetze(zeitraumId: number, dienststelleId: number) {
const plaetze = parseInt(editValue);
if (isNaN(plaetze) || plaetze < 0) {
fehlermeldung = 'Bitte geben Sie eine gültige Zahl ein (≥ 0)';
return;
}
try {
fehlermeldung = '';
erfolgsmeldung = '';
const res = await fetch('/api/admin/plaetze', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zeitraumId,
dienststelleId,
plaetze
})
});
if (!res.ok) {
const err = await res.json();
fehlermeldung = err.error || 'Fehler beim Speichern';
return;
}
editingCell = null;
await ladeDaten();
erfolgsmeldung = 'Plätze erfolgreich aktualisiert';
setTimeout(() => erfolgsmeldung = '', 3000);
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Speichern';
console.error(err);
}
}
function abbrechen() {
editingCell = null;
editValue = '';
}
function handleKeydown(e: KeyboardEvent, zeitraumId: number, dienststelleId: number) {
if (e.key === 'Enter') {
speicherePlaetze(zeitraumId, dienststelleId);
} else if (e.key === 'Escape') {
abbrechen();
}
}
onMount(ladeDaten);
</script>
<svelte:head>
<title>Plätze verwalten - Admin</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<AdminHeader
title="Praktikumsplätze verwalten"
showBackButton={true}
/>
<main class="max-w-7xl mx-auto px-4 py-6">
{#if fehlermeldung}
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{fehlermeldung}</p>
</div>
</div>
</div>
{/if}
{#if erfolgsmeldung}
<div class="bg-green-50 border border-green-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-green-700">{erfolgsmeldung}</p>
</div>
</div>
</div>
{/if}
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700">
Klicken Sie auf eine Zahl, um die Anzahl der Praktikumsplätze zu bearbeiten.
Drücken Sie Enter zum Speichern oder Esc zum Abbrechen.
</p>
</div>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Lade Daten...</span>
</div>
{:else if data.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Keine Zeiträume vorhanden</h3>
<p class="mt-1 text-sm text-gray-500">
Erstellen Sie zunächst
<a href="/admin/zeitraeume" class="text-blue-600 hover:underline">Praktikumszeiträume</a>
und
<a href="/admin/dienststellen" class="text-blue-600 hover:underline">Dienststellen</a>.
</p>
</div>
{:else}
{#each data as zeitraum}
<div class="bg-white shadow-sm rounded-lg overflow-hidden mb-6">
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">{zeitraum.zeitraumBezeichnung}</h3>
</div>
{#if zeitraum.dienststellen.length === 0}
<div class="px-6 py-8 text-center text-gray-500 text-sm">
Keine Dienststellen vorhanden.
<a href="/admin/dienststellen" class="text-blue-600 hover:underline">Dienststellen erstellen</a>
</div>
{:else}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dienststelle
</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Anzahl Plätze
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each zeitraum.dienststellen as dienststelle}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{dienststelle.dienststelleName}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
{#if editingCell?.zeitraumId === zeitraum.zeitraumId && editingCell?.dienststelleId === dienststelle.dienststelleId}
<div class="flex items-center justify-center gap-2">
<input
type="number"
bind:value={editValue}
min="0"
on:keydown={(e) => handleKeydown(e, zeitraum.zeitraumId, dienststelle.dienststelleId)}
class="w-20 border border-blue-500 rounded px-2 py-1 text-sm text-center focus:ring-2 focus:ring-blue-500 focus:border-transparent"
autofocus
/>
<button
on:click={() => speicherePlaetze(zeitraum.zeitraumId, dienststelle.dienststelleId)}
class="text-green-600 hover:text-green-800"
title="Speichern"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</button>
<button
on:click={abbrechen}
class="text-red-600 hover:text-red-800"
title="Abbrechen"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{:else}
<button
on:click={() => startEdit(zeitraum.zeitraumId, dienststelle.dienststelleId, dienststelle.plaetze)}
class="text-blue-600 hover:text-blue-800 hover:underline font-medium text-sm"
>
{dienststelle.plaetze}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
{/each}
{/if}
</main>
</div>

View File

@@ -0,0 +1,16 @@
// src/routes/admin/zeitraeume/+page.server.ts
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ cookies }) => {
// Korrigiere Cookie-Name um konsistent zu sein
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
throw redirect(303, '/admin');
}
return {
title: 'Zetraeume verwalten'
};
};

View File

@@ -0,0 +1,284 @@
<!-- src/routes/admin/zeitraeume/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import AdminHeader from '$lib/components/AdminHeader.svelte';
interface Zeitraum {
id: number;
bezeichnung: string;
startDatum: string;
endDatum: string;
}
let zeitraeume: Zeitraum[] = [];
let neuerBezeichnung = '';
let neuerstartDatum = '';
let neuerendDatum = '';
let fehlermeldung = '';
let bearbeiteId: number | null = null;
let isLoading = true;
async function ladeZeitraeume() {
try {
isLoading = true;
fehlermeldung = '';
const res = await fetch('/api/admin/zeitraeume');
if (!res.ok) {
throw new Error(`Fehler beim Laden: ${res.status}`);
}
zeitraeume = await res.json();
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Fehler beim Laden der Zeiträume:', err);
} finally {
isLoading = false;
}
}
function bearbeiten(d: { id: number; bezeichnung: string; startDatum: string; endDatum: string }) {
neuerBezeichnung = d.bezeichnung;
neuerstartDatum = d.startDatum ? d.startDatum.slice(0, 10) : '';
neuerendDatum = d.endDatum ? d.endDatum.slice(0, 10) : '';
bearbeiteId = d.id;
}
async function speichern() {
fehlermeldung = '';
if (!neuerBezeichnung.trim()) {
fehlermeldung = 'Bezeichnung ist erforderlich';
return;
}
if (!neuerstartDatum || !neuerendDatum) {
fehlermeldung = 'Start- und Enddatum sind erforderlich';
return;
}
try {
const method = bearbeiteId ? 'PATCH' : 'POST';
const body = bearbeiteId
? { id: bearbeiteId, bezeichnung: neuerBezeichnung, startDatum: neuerstartDatum, endDatum: neuerendDatum }
: { bezeichnung: neuerBezeichnung, startDatum: neuerstartDatum, endDatum: neuerendDatum };
const res = await fetch('/api/admin/zeitraeume', {
method,
body: JSON.stringify(body),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
neuerBezeichnung = '';
neuerstartDatum = '';
neuerendDatum = '';
bearbeiteId = null;
await ladeZeitraeume();
} else {
const err = await res.json();
fehlermeldung = err.error || 'Fehler beim Speichern';
}
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Speichern';
console.error(err);
}
}
async function loeschen(id: number) {
if (!confirm('Diesen Zeitraum wirklich löschen?')) return;
try {
const res = await fetch(`/api/admin/zeitraeume?id=${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json();
fehlermeldung = err.error || 'Fehler beim Löschen';
return;
}
await ladeZeitraeume();
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Löschen';
console.error(err);
}
}
function resetForm() {
neuerBezeichnung = '';
neuerstartDatum = '';
neuerendDatum = '';
bearbeiteId = null;
}
onMount(ladeZeitraeume);
</script>
<svelte:head>
<title>Praktikumszeiträume verwalten - Admin</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<AdminHeader
title="Praktikumszeiträume verwalten"
showBackButton={true}
/>
<main class="max-w-7xl mx-auto px-4 py-6">
{#if fehlermeldung}
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{fehlermeldung}</p>
</div>
</div>
</div>
{/if}
<!-- Eingabeformular -->
<div class="bg-white shadow-sm rounded-lg p-6 mb-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
{bearbeiteId !== null ? 'Praktikumszeitraum bearbeiten' : 'Neuen Praktikumszeitraum hinzufügen'}
</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label for="bezeichnung" class="block text-sm font-medium text-gray-700 mb-2">
Bezeichnung
</label>
<input
id="bezeichnung"
type="text"
bind:value={neuerBezeichnung}
placeholder="z.B. Sommerpraktikum 2024"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label for="startdatum" class="block text-sm font-medium text-gray-700 mb-2">
Startdatum
</label>
<input
id="startdatum"
type="date"
bind:value={neuerstartDatum}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div>
<label for="enddatum" class="block text-sm font-medium text-gray-700 mb-2">
Enddatum
</label>
<input
id="enddatum"
type="date"
bind:value={neuerendDatum}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div class="flex items-end gap-2">
<button
on:click={resetForm}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 hover:underline"
>
Zurücksetzen
</button>
<button
on:click={speichern}
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md text-sm font-medium"
>
{bearbeiteId !== null ? 'Ändern' : 'Hinzufügen'}
</button>
</div>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Lade Praktikumszeiträume...</span>
</div>
{:else if zeitraeume.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3a4 4 0 118 0v4m-4 6v6m-1 0h2m-1 0V9a4 4 0 00-8 0v2M7 9h2m-2 0v6" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Keine Praktikumszeiträume</h3>
<p class="mt-1 text-sm text-gray-500">Erstellen Sie Ihren ersten Praktikumszeitraum über das Formular oben.</p>
</div>
{:else}
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Alle Praktikumszeiträume</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bezeichnung
</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Startdatum
</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Enddatum
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Aktionen
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each zeitraeume as d}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{d.bezeichnung}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
{new Date(d.startDatum).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 text-center">
{new Date(d.endDatum).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
on:click={() => bearbeiten(d)}
class="text-blue-600 hover:text-blue-900 mr-4"
>
Bearbeiten
</button>
<button
on:click={() => loeschen(d.id)}
class="text-red-600 hover:text-red-900"
>
Löschen
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</main>
</div>

View File

@@ -1,64 +1,326 @@
import { PrismaClient } from '@prisma/client'; // src/routes/api/admin/anmeldungen/+server.ts
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import { PrismaClient } from '@prisma/client';
import fs from 'fs/promises';
import path from 'path';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
function checkAuth(cookies: any) { export async function GET() {
return cookies.get('admin_session') === 'true'; try {
const anmeldungen = await prisma.anmeldung.findMany({
include: {
wunsch1: true,
wunsch2: true,
wunsch3: true,
zugewiesen: true,
praktikum: true,
pdfs: true
},
orderBy: [
{
timestamp: 'desc'
}
]
});
const formattedAnmeldungen = anmeldungen.map((anmeldung) => ({
id: anmeldung.id,
anrede: anmeldung.anrede,
vorname: anmeldung.vorname,
nachname: anmeldung.nachname,
email: anmeldung.email,
geburtsdatum: anmeldung.geburtsdatum,
strasse: anmeldung.strasse,
hausnummer: anmeldung.hausnummer,
ort: anmeldung.ort,
plz: anmeldung.plz,
telefon: anmeldung.telefon,
schulart: anmeldung.schulart,
schulklasse: anmeldung.schulklasse,
motivation: anmeldung.motivation,
alter: anmeldung.alter,
noteDeutsch: anmeldung.noteDeutsch ? anmeldung.noteDeutsch.toString() : undefined,
noteMathe: anmeldung.noteMathe ? anmeldung.noteMathe.toString() : undefined,
sozialverhalten: anmeldung.sozialverhalten,
// Notfallkontakt
notfallVorname: anmeldung.notfallVorname,
notfallNachname: anmeldung.notfallNachname,
notfallTelefon: anmeldung.notfallTelefon,
status: mapPrismaStatusToFrontend(anmeldung.status),
processedAt: anmeldung.processedAt ? anmeldung.processedAt.getTime() : undefined,
wunsch1: anmeldung.wunsch1
? {
id: anmeldung.wunsch1.id,
name: anmeldung.wunsch1.name
}
: undefined,
wunsch2: anmeldung.wunsch2
? {
id: anmeldung.wunsch2.id,
name: anmeldung.wunsch2.name
}
: undefined,
wunsch3: anmeldung.wunsch3
? {
id: anmeldung.wunsch3.id,
name: anmeldung.wunsch3.name
}
: undefined,
assignedDienststelle: anmeldung.zugewiesen
? {
id: anmeldung.zugewiesen.id,
name: anmeldung.zugewiesen.name
}
: undefined,
zeitraum: anmeldung.praktikum
? {
id: anmeldung.praktikum.id,
bezeichnung: anmeldung.praktikum.bezeichnung,
startDatum: anmeldung.praktikum.startDatum.toISOString(),
endDatum: anmeldung.praktikum.endDatum.toISOString()
}
: undefined,
timestamp: anmeldung.timestamp ? anmeldung.timestamp.getTime() : Date.now(),
pdfs: anmeldung.pdfs || []
}));
return json(formattedAnmeldungen);
} catch (error) {
console.error('Fehler beim Laden der Anmeldungen:', error);
return json(
{ error: 'Fehler beim Laden der Anmeldungen', details: (error as Error).message },
{ status: 500 }
);
}
} }
export const GET: RequestHandler = async ({ cookies }) => { export async function POST({ request, url }) {
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 }); try {
const anmeldungen = await prisma.anmeldung.findMany({ const id = parseInt(url.searchParams.get('id') || '0');
include: { const { dienststelleId } = await request.json();
wunsch1: true,
wunsch2: true,
wunsch3: true,
pdfs: true
},
orderBy: { timestamp: 'desc' }
});
return new Response(JSON.stringify(anmeldungen), {
headers: { 'Content-Type': 'application/json' }
});
};
export const DELETE: RequestHandler = async ({ cookies, url }) => { if (!id || !dienststelleId) {
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 }); return json({ error: 'ID und Dienststelle erforderlich' }, { status: 400 });
const id = Number(url.searchParams.get('id')); }
if (isNaN(id)) {
return json({ error: 'Ungültige ID' }, { status: 400 });
}
try {
// 1. Alle PDF-Einträge zur Anmeldung laden
const pdfs = await prisma.pdfDatei.findMany({
where: { anmeldungId: id }
});
// 2. Dateien vom Dateisystem löschen // Prüfen ob Anmeldung existiert und Praktikumszeitraum laden
for (const pdf of pdfs) { const existingAnmeldung = await prisma.anmeldung.findUnique({
const filePath = path.resolve('static', pdf.pfad.replace(/^\/+/, '')); where: { id },
try { include: {
await fs.unlink(filePath); praktikum: true
} catch (err) { }
console.warn(`Datei konnte nicht gelöscht werden: ${filePath}`, err.message); });
// Fehler ignorieren, Datei evtl. manuell entfernt
}
}
// 3. PDF-Datensätze aus DB löschen if (!existingAnmeldung) {
await prisma.pdfDatei.deleteMany({ return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
where: {anmeldungId: id} }
});
// Anmeldung löschen if (existingAnmeldung.status === 'ANGENOMMEN') {
await prisma.anmeldung.delete({where: { id } }); return json({ error: 'Anmeldung bereits angenommen' }, { status: 409 });
return json({ ok: true }); }
} catch (error) {
console.error('Fehler beim Löschen der Anmeldung:', error); const zeitraumId = existingAnmeldung.praktikumId;
return json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
} if (!zeitraumId) {
}; return json(
{ error: 'Kein Praktikumszeitraum für diese Anmeldung gefunden' },
{ status: 400 }
);
}
// Prüfen ob ZeitraumPlaetze Eintrag existiert und freie Plätze vorhanden sind
const zeitraumPlaetze = await prisma.zeitraumPlaetze.findUnique({
where: {
zeitraumId_dienststelleId: {
zeitraumId: zeitraumId,
dienststelleId: dienststelleId
}
}
});
if (!zeitraumPlaetze) {
return json(
{
error: 'Keine Plätze für diese Dienststelle in diesem Zeitraum konfiguriert'
},
{ status: 400 }
);
}
// Prüfen ob noch Plätze frei sind (plaetze > 0)
if (zeitraumPlaetze.plaetze <= 0) {
return json(
{
error: 'Keine freien Plätze mehr für diese Dienststelle in diesem Zeitraum verfügbar.'
},
{ status: 400 }
);
}
// Anmeldung als angenommen markieren und Plätze in ZeitraumPlaetze reduzieren
// Verwendung von $transaction für Atomarität (Race Condition vermeiden)
await prisma.$transaction(async (tx) => {
// Nochmal prüfen innerhalb der Transaktion
const aktuellerStand = await tx.zeitraumPlaetze.findUnique({
where: {
zeitraumId_dienststelleId: {
zeitraumId: zeitraumId,
dienststelleId: dienststelleId
}
}
});
if (!aktuellerStand || aktuellerStand.plaetze <= 0) {
throw new Error('Keine freien Plätze mehr verfügbar');
}
// Anmeldung aktualisieren
await tx.anmeldung.update({
where: { id },
data: {
status: 'ANGENOMMEN',
zugewiesenId: dienststelleId,
processedAt: new Date()
}
});
// Plätze in ZeitraumPlaetze reduzieren
await tx.zeitraumPlaetze.update({
where: {
zeitraumId_dienststelleId: {
zeitraumId: zeitraumId,
dienststelleId: dienststelleId
}
},
data: {
plaetze: {
decrement: 1
}
}
});
});
return json({ success: true });
} catch (error) {
console.error('Fehler beim Annehmen der Anmeldung:', error);
// Spezifische Fehlermeldung für "keine Plätze"
if ((error as Error).message === 'Keine freien Plätze mehr verfügbar') {
return json(
{
error: 'Keine freien Plätze mehr für diese Dienststelle in diesem Zeitraum verfügbar.'
},
{ status: 400 }
);
}
return json(
{ error: 'Fehler beim Annehmen der Anmeldung', details: (error as Error).message },
{ status: 500 }
);
}
}
export async function PATCH({ request, url }) {
try {
const id = parseInt(url.searchParams.get('id') || '0');
const { action } = await request.json();
if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 });
}
let updateData = {};
switch (action) {
case 'reject':
updateData = {
status: 'ABGELEHNT',
processedAt: new Date()
};
break;
case 'set_processing':
const anmeldung = await prisma.anmeldung.findUnique({
where: { id }
});
if (!anmeldung) {
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
}
updateData = {
status: 'BEARBEITUNG',
processedAt: new Date()
};
break;
case 'reset_processing':
updateData = {
status: 'OFFEN',
processedAt: null
};
break;
default:
return json({ error: 'Unbekannte Aktion' }, { status: 400 });
}
await prisma.anmeldung.update({
where: { id },
data: updateData
});
return json({ success: true });
} catch (error) {
console.error('Fehler beim Aktualisieren der Anmeldung:', error);
return json(
{ error: 'Fehler beim Aktualisieren der Anmeldung', details: (error as Error).message },
{ status: 500 }
);
}
}
export async function DELETE({ url }) {
try {
const id = parseInt(url.searchParams.get('id') || '0');
if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 });
}
// Einfach löschen - Plätze werden NICHT zurückgegeben (gewolltes Verhalten)
await prisma.anmeldung.delete({
where: { id }
});
return json({ success: true });
} catch (error) {
console.error('Fehler beim Löschen der Anmeldung:', error);
return json(
{ error: 'Fehler beim Löschen der Anmeldung', details: (error as Error).message },
{ status: 500 }
);
}
}
// Hilfsfunktion: Prisma Status zu Frontend Status
function mapPrismaStatusToFrontend(prismaStatus: string): string {
const statusMap: Record<string, string> = {
OFFEN: 'pending',
BEARBEITUNG: 'processing',
ANGENOMMEN: 'accepted',
ABGELEHNT: 'rejected'
};
return statusMap[prismaStatus] || 'pending';
}

View File

@@ -0,0 +1,18 @@
// src/routes/api/admin/check-auth/+server.ts
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth === 'authenticated') {
return new Response(JSON.stringify({ authenticated: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
return new Response(JSON.stringify({ authenticated: false }), {
status: 401,
headers: { 'Content-Type': 'application/json' }
});
};

View File

@@ -1,78 +1,124 @@
import { PrismaClient } from '@prisma/client'; // src/routes/api/admin/dienststellen/+server.ts
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma';
const prisma = new PrismaClient(); // Hilfsfunktion: Erstelle ZeitraumPlaetze-Einträge für eine neue Dienststelle
async function createZeitraumPlaetzeForDienststelle(dienststelleId: number) {
const zeitraeume = await prisma.praktikumszeitraum.findMany();
import type { Cookies } from '@sveltejs/kit'; // Erstelle für jeden existierenden Zeitraum einen Eintrag mit 0 Plätzen
for (const zeitraum of zeitraeume) {
function checkAuth(cookies: Cookies) { await prisma.zeitraumPlaetze.create({
return cookies.get('admin_session') === 'true'; data: {
zeitraumId: zeitraum.id,
dienststelleId: dienststelleId,
plaetze: 0 // Standardwert: 0 Plätze
}
});
}
} }
export const GET: RequestHandler = async ({ cookies }) => { export const GET: RequestHandler = async ({ cookies }) => {
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 }); const adminAuth = cookies.get('admin-auth');
const dienststellen = await prisma.dienststelle.findMany({ orderBy: { name: 'asc' } }); if (adminAuth !== 'authenticated') {
return json(dienststellen); return json({ error: 'Nicht autorisiert' }, { status: 401 });
};
export const POST: RequestHandler = async ({ cookies, request }) => {
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 });
const { name, plaetze } = await request.json();
if (typeof plaetze !== 'number' || plaetze < 0) {
return json({ error: 'Ungültige Anzahl an Plätzen' }, { status: 400 });
}
try {
const created = await prisma.dienststelle.create({ data: {
name,
plaetze,
} });
return json(created);
} catch (e) {
console.error('Fehler beim Hinzufuegen:', e);
return json({ error: 'Dienststelle existiert bereits' }, { status: 400 });
}
};
export const PATCH: RequestHandler = async ({ cookies, request }) => {
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 });
const { id, name, plaetze } = await request.json();
if (typeof id !== 'number' || isNaN(id) || !name || typeof plaetze !== 'number' || plaetze < 0) {
return json({ error: 'Ungültige Eingabedaten' }, { status: 400 });
}
const existing = await prisma.dienststelle.findUnique({ where: { id } });
if (!existing) {
return json({ error: 'Dienststelle nicht gefunden' }, { status: 404 });
}
const konflikt = await prisma.dienststelle.findFirst({
where: {
name,
NOT: { id },
},
});
if (konflikt) {
return json({ error: 'Eine andere Dienststelle mit diesem Namen existiert bereits' }, { status: 400 });
} }
try { try {
const updated = await prisma.dienststelle.update({ const dienststellen = await prisma.dienststelle.findMany({
where: { id }, orderBy: { name: 'asc' }
data: { name, plaetze },
}); });
return json(updated); return json(dienststellen);
} catch (e) { } catch (error) {
console.error('Fehler beim Update:', e); console.error('Fehler beim Laden der Dienststellen:', error);
return json({ error: 'Update fehlgeschlagen' }, { status: 400 }); return json({ error: 'Serverfehler' }, { status: 500 });
} }
}; };
export const DELETE: RequestHandler = async ({ cookies, url }) => { export const POST: RequestHandler = async ({ request, cookies }) => {
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 }); const adminAuth = cookies.get('admin-auth');
const id = Number(url.searchParams.get('id')); if (adminAuth !== 'authenticated') {
await prisma.dienststelle.delete({ where: { id } }); return json({ error: 'Nicht autorisiert' }, { status: 401 });
return json({ success: true }); }
try {
const { name } = await request.json();
if (!name) {
return json({ error: 'Name ist erforderlich' }, { status: 400 });
}
const dienststelle = await prisma.dienststelle.create({
data: {
name,
plaetze: 0 // Wird nicht mehr verwendet, aber bleibt im Schema für Kompatibilität
}
});
// Automatisch ZeitraumPlaetze für alle existierenden Zeiträume erstellen
await createZeitraumPlaetzeForDienststelle(dienststelle.id);
return json(dienststelle);
} catch (error: any) {
console.error('Fehler beim Erstellen der Dienststelle:', error);
if (error.code === 'P2002') {
return json({ error: 'Eine Dienststelle mit diesem Namen existiert bereits' }, { status: 400 });
}
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
export const PATCH: RequestHandler = async ({ request, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const { id, name } = await request.json();
if (!id || !name) {
return json({ error: 'ID und Name sind erforderlich' }, { status: 400 });
}
const dienststelle = await prisma.dienststelle.update({
where: { id: parseInt(id) },
data: {
name
}
});
return json(dienststelle);
} catch (error: any) {
console.error('Fehler beim Aktualisieren der Dienststelle:', error);
if (error.code === 'P2002') {
return json({ error: 'Eine Dienststelle mit diesem Namen existiert bereits' }, { status: 400 });
}
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const id = url.searchParams.get('id');
if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 });
}
// ZeitraumPlaetze werden automatisch durch onDelete: Cascade gelöscht
await prisma.dienststelle.delete({
where: { id: parseInt(id) }
});
return json({ success: true });
} catch (error) {
console.error('Fehler beim Löschen der Dienststelle:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
}; };

View File

@@ -0,0 +1,76 @@
// src/routes/api/admin/email-config/+server.ts
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export const GET: RequestHandler = async () => {
try {
// E-Mail-Konfiguration aus der Datenbank laden
let emailConfig = await prisma.emailConfig.findUnique({
where: { id: 1 }
});
// Wenn keine Konfiguration existiert, Standard-Konfiguration erstellen
if (!emailConfig) {
emailConfig = await prisma.emailConfig.create({
data: {
id: 1,
subject: 'Praktikumsplatz-Zusage',
template: `Sehr geehrte/r {anrede} {nachname},
wir freuen uns, Ihnen mitteilen zu können, dass Ihre Bewerbung für ein Praktikum erfolgreich war.
Sie wurden für das Praktikum bei folgender Dienststelle angenommen:
{dienststelle}
Weitere Informationen erhalten Sie in den kommenden Tagen.
Mit freundlichen Grüßen
Ihr Praktikumsteam`
}
});
}
return json({
subject: emailConfig.subject,
template: emailConfig.template
});
} catch (err) {
console.error('Fehler beim Laden der E-Mail-Konfiguration:', err);
return error(500, 'Fehler beim Laden der E-Mail-Konfiguration');
}
};
export const POST: RequestHandler = async ({ request }) => {
try {
const { subject, template } = await request.json();
if (!subject || !template) {
return error(400, 'Subject und Template sind erforderlich');
}
// E-Mail-Konfiguration aktualisieren oder erstellen
const emailConfig = await prisma.emailConfig.upsert({
where: { id: 1 },
update: {
subject,
template
},
create: {
id: 1,
subject,
template
}
});
return json({
subject: emailConfig.subject,
template: emailConfig.template
});
} catch (err) {
console.error('Fehler beim Speichern der E-Mail-Konfiguration:', err);
return error(500, 'Fehler beim Speichern der E-Mail-Konfiguration');
}
};

View File

@@ -0,0 +1,216 @@
// src/routes/api/admin/export/+server.ts
import { json } from '@sveltejs/kit';
import { PrismaClient } from '@prisma/client';
import ExcelJS from 'exceljs';
const prisma = new PrismaClient();
export async function GET({ url }) {
try {
const zeitraumId = url.searchParams.get('zeitraumId');
if (!zeitraumId) {
return json({ error: 'Zeitraum-ID erforderlich' }, { status: 400 });
}
// Zeitraum laden für Dateinamen
const zeitraum = await prisma.praktikumszeitraum.findUnique({
where: { id: parseInt(zeitraumId) }
});
if (!zeitraum) {
return json({ error: 'Zeitraum nicht gefunden' }, { status: 404 });
}
// Angenommene Anmeldungen für diesen Zeitraum laden
const anmeldungen = await prisma.anmeldung.findMany({
where: {
praktikumId: parseInt(zeitraumId),
status: 'ANGENOMMEN'
},
include: {
zugewiesen: true,
praktikum: true
},
orderBy: [{ zugewiesen: { name: 'asc' } }, { nachname: 'asc' }]
});
// Excel-Datei erstellen
const workbook = new ExcelJS.Workbook();
workbook.creator = 'Praktikumsverwaltung';
workbook.created = new Date();
const worksheet = workbook.addWorksheet('Angenommene Anmeldungen');
// Spalten definieren (inkl. Notfallkontakt)
worksheet.columns = [
{ header: 'Dienststelle', key: 'dienststelle', width: 30 },
{ header: 'Anrede', key: 'anrede', width: 10 },
{ header: 'Vorname', key: 'vorname', width: 15 },
{ header: 'Nachname', key: 'nachname', width: 15 },
{ header: 'Geburtsdatum', key: 'geburtsdatum', width: 12 },
{ header: 'Alter', key: 'alter', width: 8 },
{ header: 'Straße', key: 'strasse', width: 20 },
{ header: 'Hausnr.', key: 'hausnummer', width: 10 },
{ header: 'PLZ', key: 'plz', width: 8 },
{ header: 'Ort', key: 'ort', width: 15 },
{ header: 'Telefon', key: 'telefon', width: 18 },
{ header: 'E-Mail', key: 'email', width: 30 },
{ header: 'Schulart', key: 'schulart', width: 15 },
{ header: 'Klasse', key: 'schulklasse', width: 8 },
{ header: 'Note Deutsch', key: 'noteDeutsch', width: 12 },
{ header: 'Note Mathe', key: 'noteMathe', width: 12 },
{ header: 'Sozialverhalten', key: 'sozialverhalten', width: 35 },
{ header: 'Notfall Vorname', key: 'notfallVorname', width: 15 },
{ header: 'Notfall Nachname', key: 'notfallNachname', width: 15 },
{ header: 'Notfall Telefon', key: 'notfallTelefon', width: 18 },
{ header: 'Angenommen am', key: 'processedAt', width: 15 }
];
// Header-Zeile formatieren
const headerRow = worksheet.getRow(1);
headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
headerRow.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FF4472C4' }
};
headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
headerRow.height = 25;
// Notfallkontakt-Spalten orange hervorheben
['R1', 'S1', 'T1'].forEach((cell) => {
worksheet.getCell(cell).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFED7D31' }
};
});
// Daten einfügen
anmeldungen.forEach((anmeldung) => {
worksheet.addRow({
dienststelle: anmeldung.zugewiesen?.name || 'Nicht zugewiesen',
anrede: anmeldung.anrede,
vorname: anmeldung.vorname,
nachname: anmeldung.nachname,
geburtsdatum: anmeldung.geburtsdatum,
alter: anmeldung.alter,
strasse: anmeldung.strasse,
hausnummer: anmeldung.hausnummer,
plz: anmeldung.plz,
ort: anmeldung.ort,
telefon: anmeldung.telefon,
email: anmeldung.email,
schulart: formatSchulart(anmeldung.schulart),
schulklasse: anmeldung.schulklasse,
noteDeutsch: anmeldung.noteDeutsch,
noteMathe: anmeldung.noteMathe,
sozialverhalten: anmeldung.sozialverhalten || '-',
notfallVorname: anmeldung.notfallVorname || '-',
notfallNachname: anmeldung.notfallNachname || '-',
notfallTelefon: anmeldung.notfallTelefon || '-',
processedAt: anmeldung.processedAt
? new Date(anmeldung.processedAt).toLocaleDateString('de-DE')
: '-'
});
});
// Datenzeilen formatieren
for (let i = 2; i <= anmeldungen.length + 1; i++) {
const row = worksheet.getRow(i);
row.alignment = { vertical: 'middle' };
// Abwechselnde Zeilenfarben
if (i % 2 === 0) {
row.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFF2F2F2' }
};
}
}
// Rahmen für alle Zellen
worksheet.eachRow((row, rowNumber) => {
row.eachCell((cell) => {
cell.border = {
top: { style: 'thin', color: { argb: 'FFD0D0D0' } },
left: { style: 'thin', color: { argb: 'FFD0D0D0' } },
bottom: { style: 'thin', color: { argb: 'FFD0D0D0' } },
right: { style: 'thin', color: { argb: 'FFD0D0D0' } }
};
});
});
// Filter aktivieren (angepasst auf neue Spaltenanzahl: A bis U = 21 Spalten)
worksheet.autoFilter = {
from: 'A1',
to: `U${anmeldungen.length + 1}`
};
// Zusammenfassung am Ende
const summaryRow = worksheet.addRow([]);
const totalRow = worksheet.addRow([
`Gesamt: ${anmeldungen.length} angenommene Anmeldungen`,
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
''
]);
totalRow.font = { bold: true };
// Excel-Datei als Buffer generieren
const buffer = await workbook.xlsx.writeBuffer();
// Dateiname generieren
const dateiname = `Praktikanten_${zeitraum.bezeichnung.replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
// Als Download zurückgeben
return new Response(buffer, {
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': buffer.byteLength.toString()
}
});
} catch (error) {
console.error('Fehler beim Exportieren:', error);
return json(
{ error: 'Fehler beim Exportieren', details: (error as Error).message },
{ status: 500 }
);
}
}
// Hilfsfunktion: Schulart formatieren
function formatSchulart(schulart: string): string {
const schulartMap: Record<string, string> = {
Gymnasium: 'Gymnasium',
KGS_Gymnasialzweig: 'KGS Gymnasialzweig',
Fachoberschule: 'Fachoberschule',
Realschule: 'Realschule',
KGSR: 'KGS Realschulzweig',
IGSR: 'IGS Realschulzweig'
};
return schulartMap[schulart] || schulart;
}

View File

@@ -1,29 +1,37 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { prisma } from '$lib/prisma';
const prisma = new PrismaClient();
export const POST: RequestHandler = async ({ request, cookies }) => { export const POST: RequestHandler = async ({ request, cookies }) => {
const { passwort } = await request.json(); try {
const adminRecord = await prisma.admin.findUnique({ where: { id: 1 } });
const admin = await prisma.admin.findUnique({ where: { id: 1 } }); if (!adminRecord?.password) {
if (!admin) { return new Response(JSON.stringify({ message: 'Admin password not found' }), { status: 500 });
return json({ error: 'Kein Admin gefunden' }, { status: 500 }); }
const { passwort } = await request.json();
if (!passwort) {
return new Response(JSON.stringify({ message: 'Passwort erforderlich' }), { status: 400 });
}
const isValid = await bcrypt.compare(passwort, adminRecord.password);
if (isValid) {
cookies.set('admin-auth', 'authenticated', {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 60 * 60 * 24,
});
return new Response(JSON.stringify({ success: true }), { status: 200 });
}
return new Response(JSON.stringify({ message: 'Falsches Passwort' }), { status: 401 });
} catch (error) {
console.error('Login error:', error);
return new Response(JSON.stringify({ message: 'Serverfehler' }), { status: 500 });
} }
const isValid = await bcrypt.compare(passwort, admin.password);
if (!isValid) {
return json({ error: 'Falsches Passwort' }, { status: 401 });
}
cookies.set('admin_session', 'true', {
path: '/',
httpOnly: true,
sameSite: 'strict',
maxAge: 60 * 60 * 4 // 4 Stunden
});
return json({ success: true });
}; };

View File

@@ -1,6 +1,15 @@
// src/routes/api/admin/logout/+server.ts
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies }) => { export const POST: RequestHandler = async ({ cookies }) => {
cookies.delete('admin_session', { path: '/' }); // Cookie löschen mit korrektem Namen
return new Response('Ausgeloggt'); cookies.delete('admin-auth', { path: '/' });
return new Response(
JSON.stringify({ success: true, message: 'Erfolgreich ausgeloggt' }),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}; };

View File

@@ -0,0 +1,92 @@
// src/routes/api/admin/plaetze/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma';
export const GET: RequestHandler = async ({ cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
// Lade alle Zeiträume mit ihren ZeitraumPlaetze-Einträgen
const zeitraeume = await prisma.praktikumszeitraum.findMany({
include: {
zeitraumPlaetze: {
include: {
dienststelle: true
},
orderBy: {
dienststelle: {
name: 'asc'
}
}
}
},
orderBy: {
startDatum: 'desc'
}
});
// Transformiere die Daten in das gewünschte Format
const result = zeitraeume.map(zeitraum => ({
zeitraumId: zeitraum.id,
zeitraumBezeichnung: zeitraum.bezeichnung,
dienststellen: zeitraum.zeitraumPlaetze.map(zp => ({
id: zp.id,
dienststelleId: zp.dienststelleId,
dienststelleName: zp.dienststelle.name,
plaetze: zp.plaetze
}))
}));
return json(result);
} catch (error) {
console.error('Fehler beim Laden der Plätze:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
export const PATCH: RequestHandler = async ({ request, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const { zeitraumId, dienststelleId, plaetze } = await request.json();
if (!zeitraumId || !dienststelleId || plaetze === undefined) {
return json({ error: 'Zeitraum-ID, Dienststellen-ID und Plätze sind erforderlich' }, { status: 400 });
}
const plaetzeInt = parseInt(plaetze);
if (isNaN(plaetzeInt) || plaetzeInt < 0) {
return json({ error: 'Plätze muss eine gültige Zahl ≥ 0 sein' }, { status: 400 });
}
// Aktualisiere oder erstelle den Eintrag
const updated = await prisma.zeitraumPlaetze.upsert({
where: {
zeitraumId_dienststelleId: {
zeitraumId: parseInt(zeitraumId),
dienststelleId: parseInt(dienststelleId)
}
},
update: {
plaetze: plaetzeInt
},
create: {
zeitraumId: parseInt(zeitraumId),
dienststelleId: parseInt(dienststelleId),
plaetze: plaetzeInt
}
});
return json(updated);
} catch (error) {
console.error('Fehler beim Aktualisieren der Plätze:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};

View File

@@ -0,0 +1,147 @@
// src/routes/api/admin/zeitraeume/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/prisma';
// Hilfsfunktion: Erstelle ZeitraumPlaetze-Einträge für einen neuen Zeitraum
async function createZeitraumPlaetzeForZeitraum(zeitraumId: number) {
const dienststellen = await prisma.dienststelle.findMany();
// Erstelle für jede existierende Dienststelle einen Eintrag mit 1 Platz
for (const dienststelle of dienststellen) {
await prisma.zeitraumPlaetze.create({
data: {
zeitraumId: zeitraumId,
dienststelleId: dienststelle.id,
plaetze: 1 // Standardwert: 1 Platz pro Dienststelle
}
});
}
}
export const GET: RequestHandler = async ({ cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const zeitraeume = await prisma.praktikumszeitraum.findMany({
orderBy: { startDatum: 'desc' }
});
return json(zeitraeume);
} catch (error) {
console.error('Fehler beim Laden der Zeiträume:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ request, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const { bezeichnung, startDatum, endDatum } = await request.json();
if (!bezeichnung || !startDatum || !endDatum) {
return json({ error: 'Alle Felder sind erforderlich' }, { status: 400 });
}
const start = new Date(startDatum);
const end = new Date(endDatum);
if (end <= start) {
return json({ error: 'Enddatum muss nach dem Startdatum liegen' }, { status: 400 });
}
const zeitraum = await prisma.praktikumszeitraum.create({
data: {
bezeichnung,
startDatum: start,
endDatum: end
}
});
// Automatisch ZeitraumPlaetze für alle existierenden Dienststellen erstellen (mit 1 Platz)
await createZeitraumPlaetzeForZeitraum(zeitraum.id);
return json(zeitraum);
} catch (error: any) {
console.error('Fehler beim Erstellen des Zeitraums:', error);
if (error.code === 'P2002') {
return json(
{ error: 'Ein Zeitraum mit dieser Bezeichnung existiert bereits' },
{ status: 400 }
);
}
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
export const PATCH: RequestHandler = async ({ request, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const { id, bezeichnung, startDatum, endDatum } = await request.json();
if (!id || !bezeichnung || !startDatum || !endDatum) {
return json({ error: 'Alle Felder sind erforderlich' }, { status: 400 });
}
const start = new Date(startDatum);
const end = new Date(endDatum);
if (end <= start) {
return json({ error: 'Enddatum muss nach dem Startdatum liegen' }, { status: 400 });
}
const zeitraum = await prisma.praktikumszeitraum.update({
where: { id: parseInt(id) },
data: {
bezeichnung,
startDatum: start,
endDatum: end
}
});
return json(zeitraum);
} catch (error: any) {
console.error('Fehler beim Aktualisieren des Zeitraums:', error);
if (error.code === 'P2002') {
return json(
{ error: 'Ein Zeitraum mit dieser Bezeichnung existiert bereits' },
{ status: 400 }
);
}
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ url, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const id = url.searchParams.get('id');
if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 });
}
// ZeitraumPlaetze werden automatisch durch onDelete: Cascade gelöscht
await prisma.praktikumszeitraum.delete({
where: { id: parseInt(id) }
});
return json({ success: true });
} catch (error) {
console.error('Fehler beim Löschen des Zeitraums:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};

View File

@@ -0,0 +1,295 @@
<!-- src/routes/admin/zeitraum-plaetze/+page.svelte -->
<script lang="ts">
import { onMount } from 'svelte';
import AdminHeader from '$lib/components/AdminHeader.svelte';
interface Zeitraum {
id: number;
bezeichnung: string;
startDatum: string;
endDatum: string;
}
interface ZeitraumPlatz {
id: number;
zeitraumId: number;
dienststelleId: number;
plaetze: number;
dienststelle: {
id: number;
name: string;
};
}
let zeitraeume: Zeitraum[] = [];
let selectedZeitraumId: number | null = null;
let plaetze: ZeitraumPlatz[] = [];
let isLoading = true;
let isSaving = false;
let fehlermeldung = '';
let erfolgsmeldung = '';
// Temporäre Änderungen speichern
let aenderungen: Map<number, number> = new Map();
async function ladeZeitraeume() {
try {
const res = await fetch('/api/admin/zeitraeume');
if (!res.ok) throw new Error('Fehler beim Laden');
zeitraeume = await res.json();
if (zeitraeume.length > 0 && !selectedZeitraumId) {
selectedZeitraumId = zeitraeume[0].id;
await ladePlaetze();
}
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Laden';
console.error(err);
}
}
async function ladePlaetze() {
if (!selectedZeitraumId) return;
try {
isLoading = true;
fehlermeldung = '';
erfolgsmeldung = '';
const res = await fetch(`/api/admin/zeitraum-plaetze?zeitraumId=${selectedZeitraumId}`);
if (!res.ok) throw new Error('Fehler beim Laden');
plaetze = await res.json();
aenderungen.clear();
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Laden';
console.error(err);
} finally {
isLoading = false;
}
}
function handlePlaetzeChange(dienststelleId: number, wert: string) {
const plaetzeWert = parseInt(wert) || 0;
aenderungen.set(dienststelleId, plaetzeWert);
aenderungen = aenderungen; // Trigger reactivity
}
function getPlaetzeWert(platz: ZeitraumPlatz): number {
return aenderungen.has(platz.dienststelleId)
? aenderungen.get(platz.dienststelleId)!
: platz.plaetze;
}
async function speichernAlle() {
if (!selectedZeitraumId || aenderungen.size === 0) {
erfolgsmeldung = 'Keine Änderungen zu speichern';
return;
}
try {
isSaving = true;
fehlermeldung = '';
erfolgsmeldung = '';
// Alle Änderungen nacheinander speichern
for (const [dienststelleId, plaetzeWert] of aenderungen) {
const res = await fetch('/api/admin/zeitraum-plaetze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
zeitraumId: selectedZeitraumId,
dienststelleId,
plaetze: plaetzeWert
})
});
if (!res.ok) {
throw new Error(`Fehler beim Speichern für Dienststelle ${dienststelleId}`);
}
}
erfolgsmeldung = 'Alle Änderungen erfolgreich gespeichert';
aenderungen.clear();
await ladePlaetze();
// Erfolgsmeldung nach 3 Sekunden ausblenden
setTimeout(() => { erfolgsmeldung = ''; }, 3000);
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Speichern';
console.error(err);
} finally {
isSaving = false;
}
}
async function syncAlles() {
if (!confirm('Möchten Sie wirklich alle Zeitraum-Dienststellen-Kombinationen synchronisieren?')) {
return;
}
try {
isSaving = true;
fehlermeldung = '';
const res = await fetch('/api/admin/zeitraum-plaetze', {
method: 'PATCH'
});
if (!res.ok) throw new Error('Fehler beim Synchronisieren');
erfolgsmeldung = 'Synchronisierung erfolgreich';
await ladePlaetze();
setTimeout(() => { erfolgsmeldung = ''; }, 3000);
} catch (err) {
fehlermeldung = err instanceof Error ? err.message : 'Fehler beim Synchronisieren';
console.error(err);
} finally {
isSaving = false;
}
}
$: if (selectedZeitraumId) {
ladePlaetze();
}
onMount(ladeZeitraeume);
</script>
<svelte:head>
<title>Plätze pro Zeitraum verwalten - Admin</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<AdminHeader
title="Plätze pro Zeitraum verwalten"
showBackButton={true}
/>
<main class="max-w-7xl mx-auto px-4 py-6">
{#if fehlermeldung}
<div class="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{fehlermeldung}</p>
</div>
</div>
</div>
{/if}
{#if erfolgsmeldung}
<div class="bg-green-50 border border-green-200 rounded-md p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800">{erfolgsmeldung}</p>
</div>
</div>
</div>
{/if}
<!-- Zeitraum Auswahl -->
<div class="bg-white shadow-sm rounded-lg p-6 mb-6">
<div class="flex justify-between items-center">
<div class="flex-1">
<label for="zeitraum" class="block text-sm font-medium text-gray-700 mb-2">
Praktikumszeitraum auswählen
</label>
<select
id="zeitraum"
bind:value={selectedZeitraumId}
class="w-full max-w-md border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
>
{#each zeitraeume as z}
<option value={z.id}>
{z.bezeichnung} ({new Date(z.startDatum).toLocaleDateString('de-DE')} - {new Date(z.endDatum).toLocaleDateString('de-DE')})
</option>
{/each}
</select>
</div>
<button
on:click={syncAlles}
disabled={isSaving}
class="px-4 py-2 text-sm text-blue-600 hover:text-blue-800 hover:underline disabled:opacity-50"
>
Alles synchronisieren
</button>
</div>
</div>
{#if isLoading}
<div class="flex justify-center items-center h-64">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span class="ml-3 text-gray-600">Lade Plätze...</span>
</div>
{:else if plaetze.length === 0}
<div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Keine Dienststellen</h3>
<p class="mt-1 text-sm text-gray-500">Erstellen Sie zuerst Dienststellen, um Plätze zuzuweisen.</p>
</div>
{:else}
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<h3 class="text-lg font-medium text-gray-900">
Plätze pro Dienststelle
</h3>
<button
on:click={speichernAlle}
disabled={isSaving || aenderungen.size === 0}
class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Speichert...' : `Änderungen speichern ${aenderungen.size > 0 ? `(${aenderungen.size})` : ''}`}
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dienststelle
</th>
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
Verfügbare Plätze
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{#each plaetze as platz}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{platz.dienststelle.name}
</td>
<td class="px-6 py-4 whitespace-nowrap text-center">
<input
type="number"
min="0"
value={getPlaetzeWert(platz)}
on:input={(e) => handlePlaetzeChange(platz.dienststelleId, e.currentTarget.value)}
class="w-24 border border-gray-300 rounded-md px-3 py-1 text-center focus:ring-blue-500 focus:border-blue-500 {aenderungen.has(platz.dienststelleId) ? 'bg-yellow-50 border-yellow-300' : ''}"
/>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</main>
</div>

View File

@@ -0,0 +1,114 @@
// src/routes/api/admin/zeitraum-plaetze/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// GET: Plätze für einen bestimmten Zeitraum abrufen
export const GET: RequestHandler = async ({ url, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const zeitraumId = url.searchParams.get('zeitraumId');
if (!zeitraumId) {
return json({ error: 'zeitraumId erforderlich' }, { status: 400 });
}
const plaetze = await prisma.zeitraumPlaetze.findMany({
where: { zeitraumId: parseInt(zeitraumId) },
include: {
dienststelle: true
},
orderBy: {
dienststelle: { name: 'asc' }
}
});
return json(plaetze);
} catch (error) {
console.error('Fehler beim Laden der Plätze:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
// POST: Plätze für einen Zeitraum und Dienststelle aktualisieren
export const POST: RequestHandler = async ({ request, cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
const { zeitraumId, dienststelleId, plaetze } = await request.json();
if (!zeitraumId || !dienststelleId || plaetze === undefined) {
return json({ error: 'Fehlende Parameter' }, { status: 400 });
}
const result = await prisma.zeitraumPlaetze.upsert({
where: {
zeitraumId_dienststelleId: {
zeitraumId: parseInt(zeitraumId),
dienststelleId: parseInt(dienststelleId)
}
},
update: {
plaetze: parseInt(plaetze)
},
create: {
zeitraumId: parseInt(zeitraumId),
dienststelleId: parseInt(dienststelleId),
plaetze: parseInt(plaetze)
}
});
return json(result);
} catch (error) {
console.error('Fehler beim Speichern der Plätze:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};
// PATCH: Synchronisiert ZeitraumPlaetze wenn neue Dienststellen oder Zeiträume hinzugefügt werden
export const PATCH: RequestHandler = async ({ cookies }) => {
const adminAuth = cookies.get('admin-auth');
if (adminAuth !== 'authenticated') {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
try {
// Alle Zeiträume und Dienststellen holen
const zeitraeume = await prisma.praktikumszeitraum.findMany();
const dienststellen = await prisma.dienststelle.findMany();
// Für jede Kombination prüfen ob Eintrag existiert, sonst erstellen
for (const zeitraum of zeitraeume) {
for (const dienststelle of dienststellen) {
await prisma.zeitraumPlaetze.upsert({
where: {
zeitraumId_dienststelleId: {
zeitraumId: zeitraum.id,
dienststelleId: dienststelle.id
}
},
update: {}, // Nichts updaten wenn bereits vorhanden
create: {
zeitraumId: zeitraum.id,
dienststelleId: dienststelle.id,
plaetze: dienststelle.plaetze // Standardwert von Dienststelle übernehmen
}
});
}
}
return json({ success: true, message: 'Synchronisierung abgeschlossen' });
} catch (error) {
console.error('Fehler bei der Synchronisierung:', error);
return json({ error: 'Serverfehler' }, { status: 500 });
}
};

View File

@@ -5,57 +5,88 @@ import { json } from '@sveltejs/kit';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export async function POST({ request }) { export async function POST({ request }: RequestEvent) {
const formData = await request.formData(); const formData = await request.formData();
const get = (key: string) => formData.get(key)?.toString() ?? ''; const get = (key: string) => formData.get(key)?.toString() ?? '';
const pdfs = formData.getAll('pdfs') as File[]; // const pdfs = formData.getAll('pdfs') as File[];
const pdfFiles = formData.getAll('pdfs') as File[];
const hasValidPdf = pdfFiles.some((file) => file.size > 0 && file.type === 'application/pdf');
if (!hasValidPdf) {
return json({ error: 'Bitte lade das Zeugnis hoch in PDF Format.' }, { status: 400 });
}
const pdfData = [];
const gespeichertePfade: string[] = []; // const gespeichertePfade: string[] = [];
const noteDeutsch = Number(get('noteDeutsch'));
const noteMathe = Number(get('noteMathe'));
for (const pdf of pdfs) { if (isNaN(noteDeutsch) || isNaN(noteMathe)) {
if (pdf.size > 0 && pdf.type === 'application/pdf') { return json({ error: 'Bitte gib gültige Noten an.' }, { status: 400 });
const buffer = Buffer.from(await pdf.arrayBuffer()); }
const dateiname = `${randomUUID()}.pdf`;
const pfad = `/uploads/${dateiname}`;
await writeFile(`static${pfad}`, buffer);
gespeichertePfade.push(pfad);
}
}
try { for (const file of pdfFiles) {
await prisma.anmeldung.create({ if (file.size > 0 && file.type === 'application/pdf') {
data: { const buffer = Buffer.from(await file.arrayBuffer());
anrede: get('anrede'), const filename = `${randomUUID()}.pdf`;
vorname: get('vorname'), const uploadPath = `/uploads/${filename}`;
nachname: get('nachname'), await writeFile(`static${uploadPath}`, buffer);
geburtsdatum: get('geburtsdatum'), pdfData.push({ pfad: uploadPath });
strasse: get('strasse'), }
hausnummer: get('hausnummer'), }
ort: get('ort'),
plz: get('plz'),
telefon: get('telefon'),
email: get('email'),
schulart: get('schulart'),
zeitraum: get('zeitraum'),
motivation: get('motivation'),
wunsch1Id: parseInt(get('wunsch1Id')),
wunsch2Id: parseInt(get('wunsch2Id')),
wunsch3Id: parseInt(get('wunsch3Id')),
pdfs: {
create: gespeichertePfade.map((pfad) => ({ pfad }))
}
}
});
return json({ success: true }); try {
} catch (err: unknown) { const anmeldung = await prisma.anmeldung.create({
if (err instanceof Error && (err as { code?: string }).code === 'P2002') { data: {
return json({ error: 'Diese E-Mail wurde bereits verwendet.' }, { status: 400 }); // Persönliche Daten
} anrede: formData.get('anrede') as string,
vorname: formData.get('vorname') as string,
nachname: formData.get('nachname') as string,
geburtsdatum: formData.get('geburtsdatum') as string,
// Adresse
strasse: formData.get('strasse') as string,
hausnummer: formData.get('hausnummer') as string,
ort: formData.get('ort') as string,
plz: formData.get('plz') as string,
// Kontakt
telefon: formData.get('telefon') as string,
email: formData.get('email') as string,
// Schule
schulart: formData.get('schulart') as string,
schulklasse: formData.get('schulklasse') as string,
noteDeutsch: parseInt(formData.get('noteDeutsch') as string),
noteMathe: parseInt(formData.get('noteMathe') as string),
sozialverhalten: (formData.get('sozialverhalten') as string) || null,
// Praktikum
praktikumId: parseInt(formData.get('zeitraum') as string),
motivation: (formData.get('motivation') as string) || '',
// Wünsche
wunsch1Id: parseInt(formData.get('wunsch1Id') as string),
wunsch2Id: parseInt(formData.get('wunsch2Id') as string),
wunsch3Id: parseInt(formData.get('wunsch3Id') as string),
// Alter (falls vom Frontend gesendet)
alter: formData.get('alter') ? parseInt(formData.get('alter') as string) : null,
// Notfallkontakt
notfallVorname: (formData.get('notfallVorname') as string) || null,
notfallNachname: (formData.get('notfallNachname') as string) || null,
notfallTelefon: (formData.get('notfallTelefon') as string) || null,
// System
zugewiesenId: null,
// timestamp wird automatisch durch @default(now()) gesetzt
pdfs: {
create: pdfData
}
}
});
console.error(err); return json({ success: true, anmeldung });
return json({ error: 'Fehler beim Speichern.' }, { status: 500 }); } catch (err: unknown) {
} if (err instanceof Error && (err as { code?: string }).code === 'P2002') {
return json({ error: 'Diese E-Mail wurde bereits verwendet.' }, { status: 400 });
}
console.error(err);
return json({ error: 'Fehler beim Speichern.' }, { status: 500 });
}
} }

View File

@@ -1,10 +1,70 @@
import { PrismaClient } from '@prisma/client'; // src/routes/api/dienststellen/+server.ts
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
const prisma = new PrismaClient(); let prismaPromise: Promise<any> | null = null;
async function getPrismaClient() {
if (!prismaPromise) {
prismaPromise = import('@prisma/client').then(({ PrismaClient }) => new PrismaClient());
}
return prismaPromise;
}
export const GET: RequestHandler = async ({ url }) => {
const prisma = await getPrismaClient();
const zeitraumId = url.searchParams.get('zeitraumId');
if (zeitraumId) {
// Dienststellen mit freien Plätzen für einen bestimmten Zeitraum
const zeitraumIdInt = parseInt(zeitraumId);
const dienststellen = await prisma.dienststelle.findMany({
orderBy: { name: 'asc' },
include: {
zeitraumPlaetze: {
where: { zeitraumId: zeitraumIdInt }
},
_count: {
select: {
zugewiesene: {
where: {
praktikumId: zeitraumIdInt,
status: 'ANGENOMMEN'
}
}
}
}
}
});
// Berechne freie Plätze pro Dienststelle
const result = dienststellen.map(d => {
const gesamtPlaetze = d.zeitraumPlaetze[0]?.plaetze ?? 0;
const belegtePlaetze = d._count.zugewiesene;
const freiePlaetze = Math.max(0, gesamtPlaetze - belegtePlaetze);
return {
id: d.id,
name: d.name,
plaetze: freiePlaetze,
gesamtPlaetze: gesamtPlaetze,
belegtePlaetze: belegtePlaetze
};
});
return json(result);
}
// Fallback: Alle Dienststellen ohne Zeitraum-Filter (für Admin-Bereich etc.)
const dienststellen = await prisma.dienststelle.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
plaetze: true
}
});
export const GET: RequestHandler = async () => {
const dienststellen = await prisma.dienststelle.findMany({ orderBy: { name: 'asc' } });
return json(dienststellen); return json(dienststellen);
}; };

View File

@@ -0,0 +1,10 @@
import { PrismaClient } from '@prisma/client';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const prisma = new PrismaClient();
export const GET: RequestHandler = async () => {
const zeitraeume = await prisma.praktikumszeitraum.findMany();
return json(zeitraeume);
};

View File

@@ -2,5 +2,8 @@ import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit()] plugins: [sveltekit()],
ssr: {
external: ['@prisma/client', '.prisma/client']
}
}); });