Anmeldungen Bearbeitung hinzugefuegt, leider die Anzeige ist noch nicht voll funktionsfaehig

This commit is contained in:
titver968
2025-07-26 12:10:45 +02:00
parent 1b4f37ec87
commit 043704d0a4
5 changed files with 584 additions and 274 deletions

View File

@@ -0,0 +1,62 @@
/*
Warnings:
- You are about to alter the column `noteDeutsch` on the `anmeldungen` table. The data in that column could be lost. The data in that column will be cast from `String` to `Int`.
- You are about to alter the column `noteMathe` on the `anmeldungen` table. The data in that column could be lost. The data in that column will be cast from `String` to `Int`.
- You are about to alter the column `timestamp` on the `anmeldungen` table. The data in that column could be lost. The data in that column will be cast from `BigInt` to `DateTime`.
- Added the required column `geburtsdatum` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Added the required column `hausnummer` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Added the required column `ort` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Added the required column `plz` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Added the required column `schulart` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Added the required column `strasse` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Added the required column `telefon` to the `anmeldungen` table without a default value. This is not possible if the table is not empty.
- Made the column `noteDeutsch` on table `anmeldungen` required. This step will fail if there are existing NULL values in that column.
- Made the column `noteMathe` on table `anmeldungen` required. This step will fail if there are existing NULL values in that column.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_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',
"processedBy" TEXT,
"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
);
INSERT INTO "new_anmeldungen" ("anrede", "email", "id", "nachname", "noteDeutsch", "noteMathe", "sozialverhalten", "status", "timestamp", "vorname", "wunsch1Id", "wunsch2Id", "wunsch3Id", "zugewiesenId") SELECT "anrede", "email", "id", "nachname", "noteDeutsch", "noteMathe", "sozialverhalten", "status", "timestamp", "vorname", "wunsch1Id", "wunsch2Id", "wunsch3Id", "zugewiesenId" FROM "anmeldungen";
DROP TABLE "anmeldungen";
ALTER TABLE "new_anmeldungen" RENAME TO "anmeldungen";
CREATE INDEX "anmeldungen_status_idx" ON "anmeldungen"("status");
CREATE INDEX "anmeldungen_processedAt_idx" ON "anmeldungen"("processedAt");
CREATE INDEX "anmeldungen_zugewiesenId_idx" ON "anmeldungen"("zugewiesenId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

@@ -40,10 +40,12 @@ model Praktikumszeitraum {
anmeldungen Anmeldung[] @relation("PraktikumszeitraumAnmeldungen")
}
// Erweiterte Status-Enum für bessere Nachverfolgung
enum Status {
OFFEN
ANGENOMMEN
ABGELEHNT
OFFEN // pending - neu eingegangen
BEARBEITUNG // processing - wird gerade bearbeitet
ANGENOMMEN // accepted - wurde angenommen
ABGELEHNT // rejected - wurde abgelehnt
}
model Anmeldung {
@@ -67,6 +69,10 @@ model Anmeldung {
alter Int? // Neu hinzugefügt für Altersvalidierung
status Status @default(OFFEN)
// Neue Felder für Status-Tracking
processedBy String? // Wer bearbeitet die Anmeldung
processedAt DateTime? // Wann wurde sie bearbeitet
// Praktikumszeitraum Relation
praktikumId Int?
praktikum Praktikumszeitraum? @relation("PraktikumszeitraumAnmeldungen", fields: [praktikumId], references: [id])
@@ -84,6 +90,10 @@ model Anmeldung {
timestamp DateTime @default(now())
pdfs PdfDatei[]
// Indizes für bessere Performance
@@index([status])
@@index([processedAt])
@@index([zugewiesenId])
@@map("anmeldungen")
}

View File

@@ -2,30 +2,16 @@
<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 };
}>();
interface Anmeldung {
pdfs: { pfad: string }[];
anrede: string;
vorname: string;
nachname: string;
email: string;
noteDeutsch?: string;
noteMathe?: string;
sozialverhalten?: string;
wunsch1?: { id: number; name: string };
wunsch2?: { id: number; name: string };
wunsch3?: { id: number; name: string };
timestamp: number;
id: number;
}
export let anmeldungen: Anmeldung[];
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('de-DE', {
day: '2-digit',
@@ -36,16 +22,23 @@
});
}
function handleAccept(id: number) {
dispatch('accept', { id });
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 handleReject(id: number) {
dispatch('reject', { id });
function canBeAccepted(status: string): boolean {
return status === 'pending' || status === 'processing';
}
function handleDelete(id: number) {
dispatch('delete', { id });
function canBeRejected(status: string): boolean {
return status === 'pending' || status === 'processing';
}
</script>
@@ -54,7 +47,10 @@
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bewerber
Status
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bewerber/in
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Noten
@@ -63,129 +59,174 @@
Wünsche
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dokumente
Zugewiesen
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Anmeldung
Eingegangen
</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Bearbeitet
</th>
<th class="px-6 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">
<!-- Bewerber Info -->
<tr class="hover:bg-gray-50" class:bg-blue-50={anmeldung.status === 'processing'}>
<!-- Status -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-col">
<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>
{#if anmeldung.status === 'processing' && anmeldung.processedBy}
<div class="text-xs text-gray-500 mt-1">
von {anmeldung.processedBy}
</div>
{/if}
</td>
<!-- Bewerber/in -->
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">
{anmeldung.anrede} {anmeldung.vorname} {anmeldung.nachname}
</div>
<div class="text-sm text-gray-500">
{anmeldung.email}
</div>
{#if anmeldung.pdfs && anmeldung.pdfs.length > 0}
<div class="mt-1">
{#each anmeldung.pdfs as pdf}
<a
href="/api/admin/pdf?path={encodeURIComponent(pdf.pfad)}"
target="_blank"
class="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 mr-2"
>
<svg class="w-3 h-3 mr-1" 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 ansehen
</a>
{/each}
</div>
{/if}
</td>
<!-- Noten -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900 space-y-1">
<div><span class="font-medium">Deutsch:</span> {anmeldung.noteDeutsch || '—'}</div>
<div><span class="font-medium">Mathe:</span> {anmeldung.noteMathe || '—'}</div>
{#if anmeldung.sozialverhalten}
<div class="text-xs text-gray-600 mt-2">
<span class="font-medium">Sozialverhalten:</span><br>
{anmeldung.sozialverhalten}
</div>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{#if anmeldung.noteDeutsch || anmeldung.noteMathe}
<div class="space-y-1">
{#if anmeldung.noteDeutsch}
<div>D: {anmeldung.noteDeutsch}</div>
{/if}
{#if anmeldung.noteMathe}
<div>M: {anmeldung.noteMathe}</div>
{/if}
</div>
{:else}
<span class="text-gray-400">-</span>
{/if}
{#if anmeldung.sozialverhalten}
<div class="text-xs text-gray-500 mt-1">
SV: {anmeldung.sozialverhalten}
</div>
{/if}
</td>
<!-- Wünsche -->
<td class="px-6 py-4">
<div class="space-y-2 text-sm">
<td class="px-6 py-4 text-sm text-gray-900">
<div class="space-y-1">
{#if anmeldung.wunsch1}
<div class="flex items-center">
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 text-blue-800 text-xs font-medium mr-2">1</span>
<span class="text-gray-900">{anmeldung.wunsch1.name}</span>
<span class="inline-flex items-center justify-center w-4 h-4 text-xs font-medium text-white bg-blue-600 rounded-full mr-2">1</span>
{anmeldung.wunsch1.name}
</div>
{/if}
{#if anmeldung.wunsch2}
<div class="flex items-center">
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-green-100 text-green-800 text-xs font-medium mr-2">2</span>
<span class="text-gray-900">{anmeldung.wunsch2.name}</span>
<span class="inline-flex items-center justify-center w-4 h-4 text-xs font-medium text-white bg-blue-500 rounded-full mr-2">2</span>
{anmeldung.wunsch2.name}
</div>
{/if}
{#if anmeldung.wunsch3}
<div class="flex items-center">
<span class="inline-flex items-center justify-center w-5 h-5 rounded-full bg-yellow-100 text-yellow-800 text-xs font-medium mr-2">3</span>
<span class="text-gray-900">{anmeldung.wunsch3.name}</span>
<span class="inline-flex items-center justify-center w-4 h-4 text-xs font-medium text-white bg-blue-400 rounded-full mr-2">3</span>
{anmeldung.wunsch3.name}
</div>
{/if}
</div>
</td>
<!-- Dokumente -->
<td class="px-6 py-4 whitespace-nowrap">
<div class="space-y-1">
{#each anmeldung.pdfs as pdf, index}
<div>
<a
href={pdf.pfad}
target="_blank"
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-900 hover:underline"
>
<svg class="w-4 h-4 mr-1" 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" />
<!-- Zugewiesen -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{#if anmeldung.assignedDienststelle}
<div class="flex items-center">
<svg class="w-4 h-4 text-green-500 mr-2" 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>
PDF {index + 1}
</a>
</div>
{/each}
<span class="font-medium">{anmeldung.assignedDienststelle.name}</span>
</div>
{:else}
<span class="text-gray-400">-</span>
{/if}
</td>
<!-- Anmeldedatum -->
<!-- Eingegangen -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(anmeldung.timestamp)}
</td>
<!-- Aktionen -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex flex-col space-y-2">
<button
on:click={() => handleAccept(anmeldung.id)}
class="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
<svg class="w-3 h-3 mr-1" 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>
Annehmen
</button>
<!-- Bearbeitet -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div>{formatProcessedDate(anmeldung.processedAt)}</div>
{#if anmeldung.processedBy && anmeldung.processedAt}
<div class="text-xs text-gray-400">
von {anmeldung.processedBy}
</div>
{/if}
</td>
<!-- Aktionen -->
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
{#if canBeAccepted(anmeldung.status || 'pending')}
<button
on:click={() => handleReject(anmeldung.id)}
class="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded-md text-white bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500"
on:click={() => dispatch('accept', { id: anmeldung.id })}
class="text-green-600 hover:text-green-900 px-2 py-1 rounded text-xs font-medium border border-green-200 hover:bg-green-50"
class:opacity-50={anmeldung.status === 'processing'}
class:cursor-not-allowed={anmeldung.status === 'processing'}
>
{anmeldung.status === 'processing' ? 'Wird bearbeitet' : '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-2 py-1 rounded text-xs font-medium border border-red-200 hover:bg-red-50"
>
<svg class="w-3 h-3 mr-1" 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>
Ablehnen
</button>
{/if}
<button
on:click={() => handleDelete(anmeldung.id)}
class="inline-flex items-center px-3 py-1 border border-transparent text-xs 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"
on:click={() => dispatch('delete', { id: anmeldung.id })}
class="text-gray-600 hover:text-gray-900 px-2 py-1 rounded text-xs font-medium border border-gray-200 hover:bg-gray-50"
>
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Löschen
</button>
</div>
<!-- Warnung bei "In Bearbeitung" Status -->
{#if anmeldung.status === 'processing'}
<div class="mt-2 flex items-center text-xs text-amber-600">
<svg class="w-3 h-3 mr-1" 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>
Wird bereits bearbeitet
</div>
{/if}
</td>
</tr>
{/each}

View File

@@ -20,6 +20,10 @@
wunsch3?: { id: number; name: string };
timestamp: number;
id: number;
status?: 'pending' | 'accepted' | 'rejected' | 'processing'; // Neuer Status
assignedDienststelle?: { id: number; name: string }; // Zugewiesene Dienststelle
processedBy?: string; // Wer die Anmeldung bearbeitet
processedAt?: number; // Wann bearbeitet
}
interface EmailConfig {
@@ -31,6 +35,10 @@
let isLoading = true;
let error = '';
// Filter für Status
let statusFilter: 'all' | 'pending' | 'accepted' | 'rejected' | 'processing' = 'all';
let filteredAnmeldungen: Anmeldung[] = [];
// Dialog state
let showDialog = false;
let selectedAnmeldungId: number | null = null;
@@ -55,6 +63,40 @@ Ihr Praktikumsteam`;
let isLoadingEmailConfig = false;
let isSavingEmailConfig = false;
// Status-Badge Funktionen
function getStatusColor(status: string): string {
switch (status) {
case 'pending': return 'bg-yellow-100 text-yellow-800';
case 'processing': return 'bg-blue-100 text-blue-800';
case 'accepted': return 'bg-green-100 text-green-800';
case 'rejected': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
}
function getStatusText(status: string): string {
switch (status) {
case 'pending': return 'Offen';
case 'processing': return 'In Bearbeitung';
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);
}
}
$: {
filterAnmeldungen();
}
async function loadAnmeldungen() {
try {
isLoading = true;
@@ -67,6 +109,8 @@ Ihr Praktikumsteam`;
}
anmeldungen = await res.json();
// Standardstatus setzen falls nicht vorhanden
anmeldungen = anmeldungen.map(a => ({ ...a, status: a.status || 'pending' }));
} catch (err) {
error = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Fehler beim Laden der Anmeldungen:', err);
@@ -75,6 +119,28 @@ Ihr Praktikumsteam`;
}
}
async function setProcessingStatus(anmeldungId: number) {
try {
const res = await fetch(`/api/admin/anmeldungen?id=${anmeldungId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'set_processing',
processedBy: 'current_user' // Hier sollte der aktuelle Benutzer stehen
})
});
if (!res.ok) {
throw new Error(`Fehler beim Setzen des Status: ${res.status}`);
}
await loadAnmeldungen();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Setzen des Status';
console.error(err);
}
}
async function loadEmailConfig() {
try {
isLoadingEmailConfig = true;
@@ -127,6 +193,16 @@ Ihr Praktikumsteam`;
const anmeldung = anmeldungen.find(a => a.id === event.detail.id);
if (!anmeldung) return;
// Prüfen ob bereits bearbeitet wird
if (anmeldung.status === 'processing') {
if (!confirm('Diese Anmeldung wird bereits bearbeitet. Trotzdem fortfahren?')) {
return;
}
}
// Status auf "in Bearbeitung" setzen
setProcessingStatus(event.detail.id);
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}` },
@@ -189,6 +265,15 @@ Ihr Praktikumsteam`;
}
async function handleReject(event: CustomEvent<{id: number}>) {
const anmeldung = anmeldungen.find(a => a.id === event.detail.id);
// Prüfen ob bereits bearbeitet wird
if (anmeldung?.status === 'processing') {
if (!confirm('Diese Anmeldung wird bereits bearbeitet. Trotzdem ablehnen?')) {
return;
}
}
if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
try {
@@ -231,6 +316,10 @@ Ihr Praktikumsteam`;
function closeDialog() {
showDialog = false;
selectedAnmeldungId = null;
// Status zurücksetzen falls Dialog abgebrochen wird
if (selectedAnmeldungId) {
// Hier könnten Sie den Status zurück auf "pending" setzen
}
}
onMount(() => {
@@ -250,8 +339,25 @@ Ihr Praktikumsteam`;
/>
<main class="max-w-7xl mx-auto px-4 py-6">
<!-- Filter und E-Mail Konfiguration -->
<div class="mb-6 flex justify-between items-center">
<!-- 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="processing">In Bearbeitung ({anmeldungen.filter(a => a.status === 'processing').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>
<!-- E-Mail Konfiguration Button -->
<div class="mb-6 flex justify-end">
<button
on:click={() => showEmailConfig = !showEmailConfig}
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
@@ -268,6 +374,81 @@ Ihr Praktikumsteam`;
</button>
</div>
<!-- Status Übersicht -->
<div class="grid grid-cols-2 md:grid-cols-4 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-blue-100 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
</svg>
</div>
</div>
<div class="ml-4">
<p class="text-sm font-medium text-gray-600">In Bearbeitung</p>
<p class="text-2xl font-semibold text-gray-900">
{anmeldungen.filter(a => a.status === 'processing').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">
@@ -349,18 +530,26 @@ Ihr Praktikumsteam`;
<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 anmeldungen.length === 0}
{: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">Keine Anmeldungen</h3>
<p class="mt-1 text-sm text-gray-500">Es sind noch keine Praktikumsanmeldungen eingegangen.</p>
<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}
anmeldungen={filteredAnmeldungen}
{getStatusColor}
{getStatusText}
on:accept={handleAccept}
on:reject={handleReject}
on:delete={handleDelete}

View File

@@ -1,214 +1,222 @@
// src/routes/api/admin/anmeldungen/+server.ts
import { PrismaClient, Status } from '@prisma/client';
// src/routes/api/admin/anmeldungen/+server.js
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import fs from 'fs/promises';
import path from 'path';
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
import type { Cookies } from '@sveltejs/kit';
// Korrigierte Auth-Funktion mit neuem Cookie-Namen
function checkAuth(cookies: Cookies) {
return cookies.get('admin-auth') === 'authenticated';
}
export const GET: RequestHandler = async ({ cookies }) => {
if (!checkAuth(cookies)) {
return new Response(
JSON.stringify({ error: 'Nicht autorisiert' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
}
);
}
export async function GET() {
try {
const anmeldungen = await prisma.anmeldung.findMany({
include: {
wunsch1: true,
wunsch2: true,
wunsch3: true,
zugewiesen: true,
praktikum: true,
pdfs: true
},
orderBy: { timestamp: 'desc' }
orderBy: [
{
status: 'asc' // BEARBEITUNG zuerst, dann OFFEN, etc.
},
{
timestamp: 'desc'
}
]
});
return new Response(JSON.stringify(anmeldungen), {
headers: { 'Content-Type': 'application/json' }
});
// Daten für Frontend formatieren
const formattedAnmeldungen = anmeldungen.map(anmeldung => ({
id: anmeldung.id,
anrede: anmeldung.anrede,
vorname: anmeldung.vorname,
nachname: anmeldung.nachname,
email: anmeldung.email,
noteDeutsch: anmeldung.noteDeutsch?.toString(),
noteMathe: anmeldung.noteMathe?.toString(),
sozialverhalten: anmeldung.sozialverhalten,
// Status-Mapping für Frontend
status: mapPrismaStatusToFrontend(anmeldung.status),
processedBy: anmeldung.processedBy,
processedAt: anmeldung.processedAt?.getTime(),
// Wünsche
wunsch1: anmeldung.wunsch1 ? {
id: anmeldung.wunsch1.id,
name: anmeldung.wunsch1.name
} : null,
wunsch2: anmeldung.wunsch2 ? {
id: anmeldung.wunsch2.id,
name: anmeldung.wunsch2.name
} : null,
wunsch3: anmeldung.wunsch3 ? {
id: anmeldung.wunsch3.id,
name: anmeldung.wunsch3.name
} : null,
// Zugewiesene Dienststelle
assignedDienststelle: anmeldung.zugewiesen ? {
id: anmeldung.zugewiesen.id,
name: anmeldung.zugewiesen.name
} : null,
timestamp: anmeldung.timestamp.getTime(),
pdfs: anmeldung.pdfs
}));
return json(formattedAnmeldungen);
} catch (error) {
console.error('Fehler beim Laden der Anmeldungen:', error);
return json({ error: 'Fehler beim Laden der Anmeldungen' }, { status: 500 });
}
};
export const POST: RequestHandler = async ({ url, cookies, request }) => {
if (!checkAuth(cookies)) {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
const id = Number(url.searchParams.get('id'));
if (!id) return json({ error: 'Ungültige ID' }, { status: 400 });
}
export async function POST({ request, url }) {
try {
// Prüfe ob eine spezifische Dienststelle zugewiesen werden soll
const body = await request.json().catch(() => ({}));
const dienststelleId = body.dienststelleId;
const id = parseInt(url.searchParams.get('id') || '0');
const { dienststelleId } = await request.json();
const anmeldung = await prisma.anmeldung.findUnique({
where: { id },
include: {
wunsch1: true,
wunsch2: true,
wunsch3: true
if (!id || !dienststelleId) {
return json({ error: 'ID und Dienststelle erforderlich' }, { status: 400 });
}
// Prüfen ob Anmeldung existiert und bearbeitet werden kann
const existingAnmeldung = await prisma.anmeldung.findUnique({
where: { id }
});
if (!existingAnmeldung) {
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
}
if (existingAnmeldung.status === 'ANGENOMMEN') {
return json({ error: 'Anmeldung bereits angenommen' }, { status: 409 });
}
// Anmeldung als angenommen markieren
await prisma.anmeldung.update({
where: { id },
data: {
status: 'ANGENOMMEN',
zugewiesenId: dienststelleId,
processedBy: 'current_user', // TODO: Echten Benutzer verwenden
processedAt: new Date()
}
});
return json({ success: true });
} catch (error) {
console.error('Fehler beim Annehmen der Anmeldung:', error);
return json({ error: 'Fehler beim Annehmen der Anmeldung' }, { status: 500 });
}
}
export async function PATCH({ request, url }) {
try {
const id = parseInt(url.searchParams.get('id') || '0');
const { action, processedBy } = await request.json();
if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 });
}
let updateData = {};
switch (action) {
case 'reject':
updateData = {
status: 'ABGELEHNT',
processedBy: 'current_user', // TODO: Echten Benutzer verwenden
processedAt: new Date()
};
break;
case 'set_processing':
// Nur setzen wenn noch OFFEN
const anmeldung = await prisma.anmeldung.findUnique({
where: { id }
});
if (!anmeldung) {
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
}
// Falls spezifische Dienststelle gewählt wurde
if (dienststelleId) {
const dienststelle = await prisma.dienststelle.findUnique({
where: { id: dienststelleId }
});
if (!dienststelle) {
return json({ error: 'Dienststelle nicht gefunden' }, { status: 404 });
if (anmeldung.status !== 'OFFEN') {
return json({ error: 'Anmeldung kann nicht mehr bearbeitet werden' }, { status: 409 });
}
if (dienststelle.plaetze <= 0) {
return json({ error: 'Keine verfügbaren Plätze bei dieser Dienststelle' }, { status: 409 });
}
updateData = {
status: 'BEARBEITUNG',
processedBy: processedBy || 'current_user',
processedAt: new Date()
};
break;
await prisma.$transaction([
prisma.anmeldung.update({
where: { id },
data: {
status: Status.ANGENOMMEN,
zugewiesenId: dienststelleId
}
}),
prisma.dienststelle.update({
where: { id: dienststelleId },
data: {
plaetze: { decrement: 1 }
}
})
]);
return json({ success: true, message: `Zugewiesen an: ${dienststelle.name}` });
}
// Fallback: Automatische Zuweisung nach Wunschreihenfolge
const wuensche = [anmeldung.wunsch1, anmeldung.wunsch2, anmeldung.wunsch3];
for (const wunsch of wuensche) {
if (wunsch && wunsch.plaetze > 0) {
await prisma.$transaction([
prisma.anmeldung.update({
where: { id },
data: {
status: Status.ANGENOMMEN,
zugewiesenId: wunsch.id
}
}),
prisma.dienststelle.update({
where: { id: wunsch.id },
data: {
plaetze: { decrement: 1 }
}
})
]);
return json({ success: true, message: `Zugewiesen an: ${wunsch.name}` });
}
}
return json({ error: 'Keine verfügbaren Plätze bei Wunsch-Dienststellen' }, { status: 409 });
} catch (err) {
console.error('Fehler beim Annehmen der Anmeldung:', err);
return json({ error: 'Interner Serverfehler' }, { status: 500 });
}
};
// Neue PATCH-Route für Ablehnung
export const PATCH: RequestHandler = async ({ url, cookies, request }) => {
if (!checkAuth(cookies)) {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
const id = Number(url.searchParams.get('id'));
if (!id) return json({ error: 'Ungültige ID' }, { status: 400 });
try {
const body = await request.json().catch(() => ({}));
if (body.action === 'reject') {
await prisma.anmeldung.update({
where: { id },
data: {
status: Status.ABGELEHNT
}
});
return json({ success: true, message: 'Anmeldung abgelehnt' });
}
case 'reset_processing':
updateData = {
status: 'OFFEN',
processedBy: null,
processedAt: null
};
break;
default:
return json({ error: 'Unbekannte Aktion' }, { status: 400 });
} catch (err) {
console.error('Fehler beim Ablehnen der Anmeldung:', err);
return json({ error: 'Interner Serverfehler' }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ cookies, url }) => {
if (!checkAuth(cookies)) {
return json({ error: 'Nicht autorisiert' }, { status: 401 });
}
const id = Number(url.searchParams.get('id'));
if (isNaN(id)) {
return json({ error: 'Ungültige ID' }, { status: 400 });
}
try {
// 1. Alle PDF-Einträge zur Anmeldung laden
const pdfs = await prisma.pdfDatei.findMany({
where: { anmeldungId: id }
const result = await prisma.anmeldung.update({
where: { id },
data: updateData
});
// 2. Dateien vom Dateisystem löschen
for (const pdf of pdfs) {
const filePath = path.resolve('static', pdf.pfad.replace(/^\/+/, ''));
return json({ success: true });
} catch (error) {
console.error('Fehler beim Aktualisieren der Anmeldung:', error);
return json({ error: 'Fehler beim Aktualisieren der Anmeldung' }, { status: 500 });
}
}
export async function DELETE({ url }) {
try {
await fs.unlink(filePath);
} catch (err) {
console.warn(
`Datei konnte nicht gelöscht werden: ${filePath}`,
err instanceof Error ? err.message : String(err)
);
// Fehler ignorieren, Datei evtl. manuell entfernt
}
const id = parseInt(url.searchParams.get('id') || '0');
if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 });
}
// 3. PDF-Datensätze aus DB löschen
await prisma.pdfDatei.deleteMany({
where: { anmeldungId: id }
});
// 4. Anmeldung löschen
await prisma.anmeldung.delete({
where: { id }
});
return json({ success: true, message: 'Anmeldung erfolgreich gelöscht' });
return json({ success: true });
} catch (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' }, { status: 500 });
}
};
}
// Hilfsfunktion: Prisma Status zu Frontend Status
function mapPrismaStatusToFrontend(prismaStatus) {
const statusMap = {
'OFFEN': 'pending',
'BEARBEITUNG': 'processing',
'ANGENOMMEN': 'accepted',
'ABGELEHNT': 'rejected'
};
return statusMap[prismaStatus] || 'pending';
}
// Hilfsfunktion: Frontend Status zu Prisma Status
function mapFrontendStatusToPrisma(frontendStatus) {
const statusMap = {
'pending': 'OFFEN',
'processing': 'BEARBEITUNG',
'accepted': 'ANGENOMMEN',
'rejected': 'ABGELEHNT'
};
return statusMap[frontendStatus] || 'OFFEN';
}