Compare commits
55 Commits
90a46bdf96
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
838a38fc18 | ||
|
|
0e21b78a4c | ||
|
|
a99ddf6fa9 | ||
|
|
07824f2b6a | ||
|
|
b240c7ab12 | ||
|
|
2a052478f0 | ||
|
|
77efdda5f3 | ||
|
|
0f9fbbf177 | ||
|
|
9173229ad1 | ||
|
|
3d31167987 | ||
|
|
89bf0298ce | ||
|
|
be9228b71d | ||
|
|
84f9aab3c0 | ||
|
|
043704d0a4 | ||
|
|
1b4f37ec87 | ||
|
|
62a46fb0de | ||
|
|
f1eeac6934 | ||
|
|
6e7a71c150 | ||
|
|
38602fa4e6 | ||
|
|
aeabd91d2d | ||
|
|
24dd912f77 | ||
|
|
1e6c3b1703 | ||
|
|
5516acb840 | ||
|
|
6fb118ac5b | ||
|
|
ad65207313 | ||
|
|
d118a7e831 | ||
|
|
fd52b33ca2 | ||
|
|
beac2582ef | ||
|
|
0bd4d7be96 | ||
|
|
da087bfa8b | ||
|
|
ec0a2f41e2 | ||
|
|
e5f51f6c6c | ||
|
|
7ed885e603 | ||
|
|
55b286b03c | ||
|
|
215fbb1786 | ||
|
|
2c8ab5538b | ||
|
|
f817ddda3a | ||
|
|
fcd296d402 | ||
|
|
badc902fc9 | ||
|
|
6b84d7f8bd | ||
|
|
835f13b8ff | ||
|
|
352e119a60 | ||
|
|
466a5cde1f | ||
|
|
4feb6e4da7 | ||
|
|
ae6849eee6 | ||
|
|
85a9f39f4a | ||
|
|
355dbf8bf6 | ||
|
|
177eb03179 | ||
|
|
72055bfb4b | ||
|
|
78942e95e1 | ||
|
|
4d7d330e93 | ||
|
|
a87cfe7858 | ||
|
|
d2a0f8fdc5 | ||
|
|
268847efdb | ||
|
|
171c9feeec |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
3449
package-lock.json
generated
3449
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
48
package.json
48
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
@@ -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");
|
|
||||||
@@ -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");
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Admin" (
|
|
||||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
|
||||||
"password" TEXT NOT NULL
|
|
||||||
);
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Anmeldung" ADD COLUMN "pdfdatei" TEXT;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
114
prisma/migrations/20251125082147_prisma6/migration.sql
Normal file
114
prisma/migrations/20251125082147_prisma6/migration.sql
Normal 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");
|
||||||
@@ -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
BIN
prisma/prisma/praktika.db
Normal file
Binary file not shown.
@@ -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,13 +13,56 @@ 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 {
|
||||||
@@ -34,27 +76,48 @@ model Anmeldung {
|
|||||||
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])
|
processedAt DateTime?
|
||||||
|
|
||||||
|
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())
|
timestamp DateTime @default(now())
|
||||||
|
pdfs PdfDatei[]
|
||||||
|
|
||||||
pdfs PdfDatei[] @relation("AnmeldungPdfs")
|
@@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
|
||||||
}
|
}
|
||||||
|
|||||||
57
src/lib/components/AdminHeader.svelte
Normal file
57
src/lib/components/AdminHeader.svelte
Normal 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>
|
||||||
78
src/lib/components/AdminNavigation.svelte
Normal file
78
src/lib/components/AdminNavigation.svelte
Normal 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>
|
||||||
355
src/lib/components/AnmeldungenTable.svelte
Normal file
355
src/lib/components/AnmeldungenTable.svelte
Normal 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>
|
||||||
138
src/lib/components/DienststellenDialog.svelte
Normal file
138
src/lib/components/DienststellenDialog.svelte
Normal 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>
|
||||||
91
src/lib/components/LoginForm.svelte
Normal file
91
src/lib/components/LoginForm.svelte
Normal 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
15
src/lib/prisma.ts
Normal 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
16
src/lib/server/prisma.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 = '';
|
||||||
|
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();
|
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);
|
||||||
@@ -88,39 +400,238 @@
|
|||||||
<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" />
|
|
||||||
<input bind:value={ort} placeholder="Ort" required class="input" />
|
<!-- PLZ mit Validierung -->
|
||||||
<input bind:value={telefon} placeholder="Telefonnummer" required class="input col-span-2" />
|
<div>
|
||||||
<input bind:value={email} type="email" placeholder="E-Mail-Adresse" required class="input col-span-2" />
|
<input
|
||||||
<input bind:value={schulart} placeholder="Schulart" required class="input col-span-2" />
|
bind:value={plz}
|
||||||
<input bind:value={zeitraum} placeholder="Wunschzeitraum fürs Praktikum" required class="input col-span-2" />
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Wunschdienststellen -->
|
<input bind:value={ort} placeholder="Ort" required class="input" />
|
||||||
|
|
||||||
|
<!-- Telefonnummer mit Validierung -->
|
||||||
|
<div class="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>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
{#if alter}
|
||||||
|
<div class="text-sm" class:text-gray-600={!alterFehler} class:text-red-600={alterFehler}>
|
||||||
|
Alter zu Praktikumsbeginn: {alter} Jahre
|
||||||
|
{#if alterFehler}
|
||||||
|
<p class="font-semibold mt-1">{alterFehler}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Wunschdienststellen - erst auswählbar wenn Zeitraum gewählt -->
|
||||||
|
<div class="grid grid-cols-1 gap-4">
|
||||||
|
{#if isLoadingDienststellen}
|
||||||
|
<div class="flex items-center justify-center py-4 text-gray-500">
|
||||||
|
<svg class="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
|
||||||
|
<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>
|
<option value="" disabled selected>2. Wunschdienststelle</option>
|
||||||
{#each dienststellen as d}
|
{#each filteredDienststellen.filter(d => d.id != wunsch1Id) as d}
|
||||||
<option value={d.id}>{d.name}</option>
|
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<select bind:value={wunsch3Id} required>
|
<!-- 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>
|
<option value="" disabled selected>3. Wunschdienststelle</option>
|
||||||
{#each dienststellen as d}
|
{#each filteredDienststellen.filter(d => d.id != wunsch1Id && d.id != wunsch2Id) as d}
|
||||||
<option value={d.id}>{d.name}</option>
|
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</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 -->
|
||||||
@@ -129,8 +640,9 @@
|
|||||||
</textarea>
|
</textarea>
|
||||||
|
|
||||||
<!-- Mehrere PDF Upload -->
|
<!-- Mehrere PDF Upload -->
|
||||||
|
{#key fileInputKey}
|
||||||
<div>
|
<div>
|
||||||
<label for="pdf-upload" class="block text-gray-700 font-medium mb-1">PDFs hochladen (optional):</label>
|
<label for="pdf-upload" class="block text-gray-700 font-medium mb-1">Zeugnis hochladen:</label>
|
||||||
<input
|
<input
|
||||||
id="pdf-upload"
|
id="pdf-upload"
|
||||||
type="file"
|
type="file"
|
||||||
@@ -140,15 +652,76 @@
|
|||||||
class="input"
|
class="input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{/key}
|
||||||
|
|
||||||
<!-- Button -->
|
<!-- Notfallkontakt -->
|
||||||
<button type="submit"
|
<div class="border-t pt-4 mt-4">
|
||||||
class="w-full bg-blue-600 text-white py-3 rounded-xl hover:bg-blue-700 transition-all">
|
<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>
|
||||||
|
|
||||||
|
{#if showAblehnungModal}
|
||||||
|
<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-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>
|
||||||
|
|||||||
@@ -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 }),
|
onMount(async () => {
|
||||||
headers: { 'Content-Type': 'application/json' }
|
// Prüfe ob bereits eingeloggt
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/check-auth');
|
||||||
|
isAuthenticated = res.ok;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
isAuthenticated = false;
|
||||||
|
}
|
||||||
|
isLoading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
async function handleLogin(event: CustomEvent<{success: boolean}>) {
|
||||||
eingeloggt = true;
|
if (event.detail.success) {
|
||||||
fehler = false;
|
isAuthenticated = true;
|
||||||
} else {
|
}
|
||||||
fehler = 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>
|
</div>
|
||||||
{#if fehler}
|
{:else if !isAuthenticated}
|
||||||
<p class="text-red-600">Falsches Passwort</p>
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
{/if}
|
<h1 class="text-3xl font-bold text-gray-900 mb-6 text-center">Admin Login</h1>
|
||||||
|
<LoginForm on:loginResult={handleLogin} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-4">
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
<h1 class="text-2xl font-bold mb-4">Admin-Bereich</h1>
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div class="flex flex-col gap-4">
|
<h1 class="text-3xl font-bold text-gray-900">Admin-Bereich</h1>
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
on:click={async () => {
|
on:click={handleLogout}
|
||||||
await fetch('/api/admin/logout', { method: 'POST' });
|
class="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md transition-colors"
|
||||||
location.reload();
|
>
|
||||||
}}
|
|
||||||
class="bg-red-600 text-white px-4 py-3 rounded text-center hiver:bg-red-700">
|
|
||||||
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>
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
};
|
};
|
||||||
@@ -1,94 +1,880 @@
|
|||||||
<script lang="ts">
|
<!-- src/routes/admin/anmeldungen/+page.svelte -->
|
||||||
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import AnmeldungenTable from '$lib/components/AnmeldungenTable.svelte';
|
||||||
|
import DienststellenDialog from '$lib/components/DienststellenDialog.svelte';
|
||||||
|
import AdminHeader from '$lib/components/AdminHeader.svelte';
|
||||||
|
|
||||||
interface Anmeldung {
|
interface Anmeldung {
|
||||||
pdfs: any;
|
pdfs: { pfad: string }[];
|
||||||
anrede: string;
|
anrede: string;
|
||||||
vorname: string;
|
vorname: string;
|
||||||
nachname: string;
|
nachname: string;
|
||||||
email: string;
|
email: string;
|
||||||
wunsch1?: { name: string };
|
noteDeutsch?: string;
|
||||||
wunsch2?: { name: string };
|
noteMathe?: string;
|
||||||
wunsch3?: { name: 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;
|
timestamp: number;
|
||||||
id: 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 anmeldungen: Anmeldung[] = [];
|
||||||
|
let zeitraeume: Zeitraum[] = [];
|
||||||
|
let isLoading = true;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
async function ladeAnmeldungen() {
|
// Filter für Status
|
||||||
const res = await fetch('/api/admin/anmeldungen');
|
let statusFilter: 'all' | 'pending' | 'accepted' | 'rejected' = 'all';
|
||||||
anmeldungen = await res.json();
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loeschen(id: number) {
|
function getStatusText(status: string): string {
|
||||||
if (!confirm('Diese Anmeldung wirklich löschen?')) return;
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/admin/anmeldungen?id=${id}`, { method: 'DELETE' });
|
isLoading = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
const res = await fetch('/api/admin/anmeldungen');
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const errorText = await res.text();
|
const errorText = await res.text();
|
||||||
throw new Error(`Fehler beim Löschen (${res.status}): ${errorText}`);
|
console.error('❌ API Fehler:', res.status, errorText);
|
||||||
|
throw new Error(`Fehler beim Laden: ${res.status} - ${errorText}`);
|
||||||
}
|
}
|
||||||
await ladeAnmeldungen();
|
|
||||||
} catch (error) {
|
const data = await res.json();
|
||||||
console.error(error);
|
|
||||||
alert('Fehler beim Löschen der Anmeldung.\n' + (error as Error).message);
|
if (!Array.isArray(data)) {
|
||||||
|
console.error('❌ Antwort ist kein Array:', data);
|
||||||
|
throw new Error('Antwort vom Server ist kein Array');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onMount(ladeAnmeldungen);
|
anmeldungen = data.map(a => {
|
||||||
|
return { ...a, status: a.status || 'pending' };
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
<div class="p-6 max-w-7xl mx-auto">
|
error = err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||||
<h1 class="text-2xl font-bold mb-4 text-center">Alle Anmeldungen</h1>
|
console.error('❌ Frontend Fehler beim Laden der Anmeldungen:', err);
|
||||||
<table class="w-full border text-sm">
|
} finally {
|
||||||
<thead>
|
isLoading = false;
|
||||||
<tr class="bg-gray-200">
|
}
|
||||||
<th class="p-2 text-left">Name</th>
|
}
|
||||||
<th class="p-2 text-left">E-Mail</th>
|
|
||||||
<th class="p-2 text-left">Wunsch 1–3</th>
|
async function loadZeitraeume() {
|
||||||
<th class="p-2 text-left">Datum</th>
|
try {
|
||||||
<th class="p-2 text-left">Dateien</th>
|
const res = await fetch('/api/zeitraeume');
|
||||||
<th class="p-2 text-left">Aktionen</th>
|
if (res.ok) {
|
||||||
</tr>
|
zeitraeume = await res.json();
|
||||||
</thead>
|
}
|
||||||
<tbody>
|
} catch (err) {
|
||||||
{#each anmeldungen as a}
|
console.error('Fehler beim Laden der Zeiträume:', err);
|
||||||
<tr class="border-t">
|
}
|
||||||
<td class="p-2">{a.anrede} {a.vorname} {a.nachname}</td>
|
}
|
||||||
<td class="p-2">{a.email}</td>
|
|
||||||
<td class="p-2">
|
async function loadEmailConfig() {
|
||||||
{a.wunsch1?.name}<br>
|
try {
|
||||||
{a.wunsch2?.name}<br>
|
isLoadingEmailConfig = true;
|
||||||
{a.wunsch3?.name}
|
|
||||||
</td>
|
const res = await fetch('/api/admin/email-config');
|
||||||
<td class="p-2">{new Date(a.timestamp).toLocaleDateString()}</td>
|
|
||||||
<td class="p-2">
|
if (!res.ok) {
|
||||||
{#each a.pdfs as pdf}
|
throw new Error(`Fehler beim Laden der E-Mail-Konfiguration: ${res.status}`);
|
||||||
<li>
|
}
|
||||||
<a href={pdf.pfad} target="_blank" class="text-blue-600 hover:underline">
|
|
||||||
PDF ansehen
|
const config: EmailConfig = await res.json();
|
||||||
</a>
|
emailSubject = config.subject;
|
||||||
</li>
|
emailTemplate = config.template;
|
||||||
{/each}
|
} catch (err) {
|
||||||
</td>
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isExporting = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/export?zeitraumId=${selectedExportZeitraum}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errorData = await res.json();
|
||||||
|
throw new Error(errorData.error || 'Fehler beim Exportieren');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download auslösen
|
||||||
|
const blob = await res.blob();
|
||||||
|
const contentDisposition = res.headers.get('Content-Disposition');
|
||||||
|
let filename = 'export.xlsx';
|
||||||
|
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="(.+)"/);
|
||||||
|
if (match) {
|
||||||
|
filename = match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Fehler beim Exportieren';
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
isExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReject(event: CustomEvent<{id: number}>) {
|
||||||
|
if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action: 'reject' })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Fehler beim Ablehnen: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadAnmeldungen();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Fehler beim Ablehnen';
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
<button
|
||||||
<button
|
on:click={openExportModal}
|
||||||
class="text-red-600 hover:underline"
|
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
|
||||||
on:click={() => loeschen(a.id)}>
|
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>
|
</button>
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
|
|
||||||
|
<!-- E-Mail Konfiguration Button -->
|
||||||
<button
|
<button
|
||||||
<button
|
on:click={() => showEmailConfig = !showEmailConfig}
|
||||||
on:click={async () => {
|
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
|
||||||
await fetch('/api/admin/logout', { method: 'POST' });
|
disabled={isLoadingEmailConfig}
|
||||||
location.reload();
|
>
|
||||||
}}
|
{#if isLoadingEmailConfig}
|
||||||
class="bg-red-600 text-white px-4 py-3 rounded text-center hover:bg-red-700">
|
<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>
|
</button>
|
||||||
</div>
|
</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: {anrede}, {vorname}, {nachname}, {dienststelle}
|
||||||
|
</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}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
on:click={closeExportModal}
|
||||||
|
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
|
||||||
|
disabled={isExporting}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
{/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}
|
||||||
@@ -1,19 +1,42 @@
|
|||||||
|
<!-- src/routes/admin/change-password/+page.svelte -->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import AdminHeader from '$lib/components/AdminHeader.svelte';
|
||||||
|
|
||||||
let oldPassword = '';
|
let oldPassword = '';
|
||||||
let newPassword = '';
|
let newPassword = '';
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
let message = '';
|
let message = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
message = '';
|
message = '';
|
||||||
error = '';
|
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) {
|
if (newPassword !== confirmPassword) {
|
||||||
error = 'Die neuen Passwörter stimmen nicht überein.';
|
error = 'Die neuen Passwörter stimmen nicht überein.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
isLoading = true;
|
||||||
|
|
||||||
const res = await fetch('/api/admin/change-password', {
|
const res = await fetch('/api/admin/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -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>
|
||||||
|
<title>Passwort ändern - Admin</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50">
|
||||||
|
<AdminHeader
|
||||||
|
title="Admin-Passwort ändern"
|
||||||
|
showBackButton={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||||
|
{#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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if message}
|
||||||
|
<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">
|
||||||
|
<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>
|
<div>
|
||||||
<div>
|
<label for="old-password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Aktuelles Passwort
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="old-password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={oldPassword}
|
bind:value={oldPassword}
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<label for="new-password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Neues Passwort
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="new-password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={newPassword}
|
bind:value={newPassword}
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<label for="confirm-password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Neues Passwort wiederholen
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
|
id="confirm-password"
|
||||||
type="password"
|
type="password"
|
||||||
bind:value={confirmPassword}
|
bind:value={confirmPassword}
|
||||||
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>
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-4">
|
||||||
{#if error}
|
|
||||||
<div class="text-red-600 text-sm font-medium">{error}</div>
|
|
||||||
{/if}
|
|
||||||
{#if message}
|
|
||||||
<div class="text-green-600 text-sm font-medium">{message}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
<button
|
type="button"
|
||||||
on:click={changePassword}
|
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
|
Passwort ändern
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
@@ -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'
|
||||||
|
};
|
||||||
};
|
};
|
||||||
236
src/routes/admin/dienststellen/+page.svelte
Normal file
236
src/routes/admin/dienststellen/+page.svelte
Normal 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>
|
||||||
15
src/routes/admin/plaetze/+page.server.ts
Normal file
15
src/routes/admin/plaetze/+page.server.ts
Normal 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'
|
||||||
|
};
|
||||||
|
};
|
||||||
259
src/routes/admin/plaetze/+page.svelte
Normal file
259
src/routes/admin/plaetze/+page.svelte
Normal 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>
|
||||||
16
src/routes/admin/zeitraeume/+page.server.ts
Normal file
16
src/routes/admin/zeitraeume/+page.server.ts
Normal 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'
|
||||||
|
};
|
||||||
|
};
|
||||||
284
src/routes/admin/zeitraeume/+page.svelte
Normal file
284
src/routes/admin/zeitraeume/+page.svelte
Normal 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>
|
||||||
@@ -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 {
|
||||||
}
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ cookies }) => {
|
|
||||||
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 });
|
|
||||||
const anmeldungen = await prisma.anmeldung.findMany({
|
const anmeldungen = await prisma.anmeldung.findMany({
|
||||||
include: {
|
include: {
|
||||||
wunsch1: true,
|
wunsch1: true,
|
||||||
wunsch2: true,
|
wunsch2: true,
|
||||||
wunsch3: true,
|
wunsch3: true,
|
||||||
|
zugewiesen: true,
|
||||||
|
praktikum: true,
|
||||||
pdfs: true
|
pdfs: true
|
||||||
},
|
},
|
||||||
orderBy: { timestamp: 'desc' }
|
orderBy: [
|
||||||
});
|
{
|
||||||
return new Response(JSON.stringify(anmeldungen), {
|
timestamp: 'desc'
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const DELETE: RequestHandler = async ({ cookies, url }) => {
|
|
||||||
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 });
|
|
||||||
const id = Number(url.searchParams.get('id'));
|
|
||||||
if (isNaN(id)) {
|
|
||||||
return json({ error: 'Ungültige ID' }, { status: 400 });
|
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
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 async function POST({ request, url }) {
|
||||||
try {
|
try {
|
||||||
// 1. Alle PDF-Einträge zur Anmeldung laden
|
const id = parseInt(url.searchParams.get('id') || '0');
|
||||||
const pdfs = await prisma.pdfDatei.findMany({
|
const { dienststelleId } = await request.json();
|
||||||
where: { anmeldungId: id }
|
|
||||||
|
if (!id || !dienststelleId) {
|
||||||
|
return json({ error: 'ID und Dienststelle erforderlich' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfen ob Anmeldung existiert und Praktikumszeitraum laden
|
||||||
|
const existingAnmeldung = await prisma.anmeldung.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
praktikum: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Dateien vom Dateisystem löschen
|
if (!existingAnmeldung) {
|
||||||
for (const pdf of pdfs) {
|
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
|
||||||
const filePath = path.resolve('static', pdf.pfad.replace(/^\/+/, ''));
|
}
|
||||||
|
|
||||||
|
if (existingAnmeldung.status === 'ANGENOMMEN') {
|
||||||
|
return json({ error: 'Anmeldung bereits angenommen' }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeitraumId = existingAnmeldung.praktikumId;
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
await fs.unlink(filePath);
|
const id = parseInt(url.searchParams.get('id') || '0');
|
||||||
} catch (err) {
|
const { action } = await request.json();
|
||||||
console.warn(`Datei konnte nicht gelöscht werden: ${filePath}`, err.message);
|
|
||||||
// Fehler ignorieren, Datei evtl. manuell entfernt
|
if (!id) {
|
||||||
}
|
return json({ error: 'ID erforderlich' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. PDF-Datensätze aus DB löschen
|
let updateData = {};
|
||||||
await prisma.pdfDatei.deleteMany({
|
|
||||||
where: {anmeldungId: id}
|
switch (action) {
|
||||||
|
case 'reject':
|
||||||
|
updateData = {
|
||||||
|
status: 'ABGELEHNT',
|
||||||
|
processedAt: new Date()
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'set_processing':
|
||||||
|
const anmeldung = await prisma.anmeldung.findUnique({
|
||||||
|
where: { id }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Anmeldung löschen
|
if (!anmeldung) {
|
||||||
await prisma.anmeldung.delete({where: { id } });
|
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
|
||||||
return json({ ok: true });
|
}
|
||||||
|
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Löschen der Anmeldung:', error);
|
console.error('Fehler beim Löschen der Anmeldung:', error);
|
||||||
return json({ error: 'Löschen fehlgeschlagen' }, { status: 500 });
|
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';
|
||||||
|
}
|
||||||
|
|||||||
18
src/routes/api/admin/check-auth/+server.ts
Normal file
18
src/routes/api/admin/check-auth/+server.ts
Normal 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' }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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({ error: 'Nicht autorisiert' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dienststellen = await prisma.dienststelle.findMany({
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
return json(dienststellen);
|
return json(dienststellen);
|
||||||
};
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Laden der Dienststellen:', error);
|
||||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
return json({ error: 'Serverfehler' }, { status: 500 });
|
||||||
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 }) => {
|
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||||
if (!checkAuth(cookies)) return new Response('Nicht erlaubt', { status: 401 });
|
const adminAuth = cookies.get('admin-auth');
|
||||||
|
if (adminAuth !== 'authenticated') {
|
||||||
const { id, name, plaetze } = await request.json();
|
return json({ error: 'Nicht autorisiert' }, { status: 401 });
|
||||||
|
|
||||||
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 { name } = await request.json();
|
||||||
where: { id },
|
|
||||||
data: { name, plaetze },
|
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
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return json(updated);
|
|
||||||
} catch (e) {
|
// Automatisch ZeitraumPlaetze für alle existierenden Zeiträume erstellen
|
||||||
console.error('Fehler beim Update:', e);
|
await createZeitraumPlaetzeForDienststelle(dienststelle.id);
|
||||||
return json({ error: 'Update fehlgeschlagen' }, { status: 400 });
|
|
||||||
|
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 DELETE: RequestHandler = async ({ cookies, url }) => {
|
export const PATCH: 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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
return json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fehler beim Löschen der Dienststelle:', error);
|
||||||
|
return json({ error: 'Serverfehler' }, { status: 500 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
76
src/routes/api/admin/email-config/+server.ts
Normal file
76
src/routes/api/admin/email-config/+server.ts
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
216
src/routes/api/admin/export/+server.ts
Normal file
216
src/routes/api/admin/export/+server.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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 }) => {
|
||||||
|
try {
|
||||||
|
const adminRecord = await prisma.admin.findUnique({ where: { id: 1 } });
|
||||||
|
|
||||||
|
if (!adminRecord?.password) {
|
||||||
|
return new Response(JSON.stringify({ message: 'Admin password not found' }), { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
const { passwort } = await request.json();
|
const { passwort } = await request.json();
|
||||||
|
|
||||||
const admin = await prisma.admin.findUnique({ where: { id: 1 } });
|
if (!passwort) {
|
||||||
if (!admin) {
|
return new Response(JSON.stringify({ message: 'Passwort erforderlich' }), { status: 400 });
|
||||||
return json({ error: 'Kein Admin gefunden' }, { status: 500 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValid = await bcrypt.compare(passwort, admin.password);
|
const isValid = await bcrypt.compare(passwort, adminRecord.password);
|
||||||
if (!isValid) {
|
|
||||||
return json({ error: 'Falsches Passwort' }, { status: 401 });
|
|
||||||
}
|
|
||||||
|
|
||||||
cookies.set('admin_session', 'true', {
|
if (isValid) {
|
||||||
|
cookies.set('admin-auth', 'authenticated', {
|
||||||
path: '/',
|
path: '/',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
maxAge: 60 * 60 * 4 // 4 Stunden
|
maxAge: 60 * 60 * 24,
|
||||||
});
|
});
|
||||||
|
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true });
|
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 });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
@@ -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' }
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
92
src/routes/api/admin/plaetze/+server.ts
Normal file
92
src/routes/api/admin/plaetze/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
147
src/routes/api/admin/zeitraeume/+server.ts
Normal file
147
src/routes/api/admin/zeitraeume/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
295
src/routes/api/admin/zeitraum-plaetze/+page.svelte
Normal file
295
src/routes/api/admin/zeitraum-plaetze/+page.svelte
Normal 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>
|
||||||
114
src/routes/api/admin/zeitraum-plaetze/+server.ts
Normal file
114
src/routes/api/admin/zeitraum-plaetze/+server.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -5,51 +5,82 @@ 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}`;
|
for (const file of pdfFiles) {
|
||||||
await writeFile(`static${pfad}`, buffer);
|
if (file.size > 0 && file.type === 'application/pdf') {
|
||||||
gespeichertePfade.push(pfad);
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const filename = `${randomUUID()}.pdf`;
|
||||||
|
const uploadPath = `/uploads/${filename}`;
|
||||||
|
await writeFile(`static${uploadPath}`, buffer);
|
||||||
|
pdfData.push({ pfad: uploadPath });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await prisma.anmeldung.create({
|
const anmeldung = await prisma.anmeldung.create({
|
||||||
data: {
|
data: {
|
||||||
anrede: get('anrede'),
|
// Persönliche Daten
|
||||||
vorname: get('vorname'),
|
anrede: formData.get('anrede') as string,
|
||||||
nachname: get('nachname'),
|
vorname: formData.get('vorname') as string,
|
||||||
geburtsdatum: get('geburtsdatum'),
|
nachname: formData.get('nachname') as string,
|
||||||
strasse: get('strasse'),
|
geburtsdatum: formData.get('geburtsdatum') as string,
|
||||||
hausnummer: get('hausnummer'),
|
// Adresse
|
||||||
ort: get('ort'),
|
strasse: formData.get('strasse') as string,
|
||||||
plz: get('plz'),
|
hausnummer: formData.get('hausnummer') as string,
|
||||||
telefon: get('telefon'),
|
ort: formData.get('ort') as string,
|
||||||
email: get('email'),
|
plz: formData.get('plz') as string,
|
||||||
schulart: get('schulart'),
|
// Kontakt
|
||||||
zeitraum: get('zeitraum'),
|
telefon: formData.get('telefon') as string,
|
||||||
motivation: get('motivation'),
|
email: formData.get('email') as string,
|
||||||
wunsch1Id: parseInt(get('wunsch1Id')),
|
// Schule
|
||||||
wunsch2Id: parseInt(get('wunsch2Id')),
|
schulart: formData.get('schulart') as string,
|
||||||
wunsch3Id: parseInt(get('wunsch3Id')),
|
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: {
|
pdfs: {
|
||||||
create: gespeichertePfade.map((pfad) => ({ pfad }))
|
create: pdfData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return json({ success: true });
|
return json({ success: true, anmeldung });
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (err instanceof Error && (err as { code?: string }).code === 'P2002') {
|
if (err instanceof Error && (err as { code?: string }).code === 'P2002') {
|
||||||
return json({ error: 'Diese E-Mail wurde bereits verwendet.' }, { status: 400 });
|
return json({ error: 'Diese E-Mail wurde bereits verwendet.' }, { status: 400 });
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
10
src/routes/api/zeitraeume/+server.ts
Normal file
10
src/routes/api/zeitraeume/+server.ts
Normal 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);
|
||||||
|
};
|
||||||
Binary file not shown.
@@ -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']
|
||||||
|
}
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user