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

View File

@@ -2,30 +2,16 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
export let anmeldungen: any[];
export let getStatusColor: (status: string) => string;
export let getStatusText: (status: string) => string;
const dispatch = createEventDispatcher<{ const dispatch = createEventDispatcher<{
accept: { id: number }; accept: { id: number };
reject: { id: number }; reject: { id: number };
delete: { 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 { function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('de-DE', { return new Date(timestamp).toLocaleDateString('de-DE', {
day: '2-digit', day: '2-digit',
@@ -36,16 +22,23 @@
}); });
} }
function handleAccept(id: number) { function formatProcessedDate(timestamp: number | undefined): string {
dispatch('accept', { id }); 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) { function canBeAccepted(status: string): boolean {
dispatch('reject', { id }); return status === 'pending' || status === 'processing';
} }
function handleDelete(id: number) { function canBeRejected(status: string): boolean {
dispatch('delete', { id }); return status === 'pending' || status === 'processing';
} }
</script> </script>
@@ -54,7 +47,10 @@
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-6 py-3 text-left 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">
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>
<th class="px-6 py-3 text-left 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">
Noten Noten
@@ -63,129 +59,174 @@
Wünsche Wünsche
</th> </th>
<th class="px-6 py-3 text-left 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">
Dokumente Zugewiesen
</th> </th>
<th class="px-6 py-3 text-left 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">
Anmeldung Eingegangen
</th> </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 Aktionen
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
{#each anmeldungen as anmeldung (anmeldung.id)} {#each anmeldungen as anmeldung (anmeldung.id)}
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50" class:bg-blue-50={anmeldung.status === 'processing'}>
<!-- Bewerber Info --> <!-- Status -->
<td class="px-6 py-4 whitespace-nowrap"> <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')}">
<div class="text-sm font-medium text-gray-900"> {getStatusText(anmeldung.status || 'pending')}
{anmeldung.anrede} {anmeldung.vorname} {anmeldung.nachname} </span>
</div> {#if anmeldung.status === 'processing' && anmeldung.processedBy}
<div class="text-sm text-gray-500"> <div class="text-xs text-gray-500 mt-1">
{anmeldung.email} von {anmeldung.processedBy}
</div> </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>
<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> </td>
<!-- Noten --> <!-- Noten -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div class="text-sm text-gray-900 space-y-1"> {#if anmeldung.noteDeutsch || anmeldung.noteMathe}
<div><span class="font-medium">Deutsch:</span> {anmeldung.noteDeutsch || '—'}</div> <div class="space-y-1">
<div><span class="font-medium">Mathe:</span> {anmeldung.noteMathe || '—'}</div> {#if anmeldung.noteDeutsch}
{#if anmeldung.sozialverhalten} <div>D: {anmeldung.noteDeutsch}</div>
<div class="text-xs text-gray-600 mt-2"> {/if}
<span class="font-medium">Sozialverhalten:</span><br> {#if anmeldung.noteMathe}
{anmeldung.sozialverhalten} <div>M: {anmeldung.noteMathe}</div>
</div> {/if}
{/if} </div>
</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> </td>
<!-- Wünsche --> <!-- Wünsche -->
<td class="px-6 py-4"> <td class="px-6 py-4 text-sm text-gray-900">
<div class="space-y-2 text-sm"> <div class="space-y-1">
{#if anmeldung.wunsch1} {#if anmeldung.wunsch1}
<div class="flex items-center"> <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="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>
<span class="text-gray-900">{anmeldung.wunsch1.name}</span> {anmeldung.wunsch1.name}
</div> </div>
{/if} {/if}
{#if anmeldung.wunsch2} {#if anmeldung.wunsch2}
<div class="flex items-center"> <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="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>
<span class="text-gray-900">{anmeldung.wunsch2.name}</span> {anmeldung.wunsch2.name}
</div> </div>
{/if} {/if}
{#if anmeldung.wunsch3} {#if anmeldung.wunsch3}
<div class="flex items-center"> <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="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>
<span class="text-gray-900">{anmeldung.wunsch3.name}</span> {anmeldung.wunsch3.name}
</div> </div>
{/if} {/if}
</div> </div>
</td> </td>
<!-- Dokumente --> <!-- Zugewiesen -->
<td class="px-6 py-4 whitespace-nowrap"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<div class="space-y-1"> {#if anmeldung.assignedDienststelle}
{#each anmeldung.pdfs as pdf, index} <div class="flex items-center">
<div> <svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<a <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" />
href={pdf.pfad} </svg>
target="_blank" <span class="font-medium">{anmeldung.assignedDienststelle.name}</span>
class="inline-flex items-center text-sm text-blue-600 hover:text-blue-900 hover:underline" </div>
> {:else}
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="text-gray-400">-</span>
<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" /> {/if}
</svg>
PDF {index + 1}
</a>
</div>
{/each}
</div>
</td> </td>
<!-- Anmeldedatum --> <!-- Eingegangen -->
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatDate(anmeldung.timestamp)} {formatDate(anmeldung.timestamp)}
</td> </td>
<!-- 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 --> <!-- Aktionen -->
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex flex-col space-y-2"> <div class="flex space-x-2">
<button {#if canBeAccepted(anmeldung.status || 'pending')}
on:click={() => handleAccept(anmeldung.id)} <button
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" 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"
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class:opacity-50={anmeldung.status === 'processing'}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /> class:cursor-not-allowed={anmeldung.status === 'processing'}
</svg> >
Annehmen {anmeldung.status === 'processing' ? 'Wird bearbeitet' : 'Annehmen'}
</button> </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"
>
Ablehnen
</button>
{/if}
<button <button
on:click={() => handleReject(anmeldung.id)} on:click={() => dispatch('delete', { id: 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" 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="M6 18L18 6M6 6l12 12" />
</svg>
Ablehnen
</button>
<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"
>
<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 Löschen
</button> </button>
</div> </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> </td>
</tr> </tr>
{/each} {/each}

View File

@@ -20,6 +20,10 @@
wunsch3?: { id: number; name: string }; wunsch3?: { id: number; name: string };
timestamp: number; timestamp: number;
id: 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 { interface EmailConfig {
@@ -31,6 +35,10 @@
let isLoading = true; let isLoading = true;
let error = ''; let error = '';
// Filter für Status
let statusFilter: 'all' | 'pending' | 'accepted' | 'rejected' | 'processing' = 'all';
let filteredAnmeldungen: Anmeldung[] = [];
// Dialog state // Dialog state
let showDialog = false; let showDialog = false;
let selectedAnmeldungId: number | null = null; let selectedAnmeldungId: number | null = null;
@@ -55,6 +63,40 @@ Ihr Praktikumsteam`;
let isLoadingEmailConfig = false; let isLoadingEmailConfig = false;
let isSavingEmailConfig = 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() { async function loadAnmeldungen() {
try { try {
isLoading = true; isLoading = true;
@@ -67,6 +109,8 @@ Ihr Praktikumsteam`;
} }
anmeldungen = await res.json(); anmeldungen = await res.json();
// Standardstatus setzen falls nicht vorhanden
anmeldungen = anmeldungen.map(a => ({ ...a, status: a.status || 'pending' }));
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Unbekannter Fehler'; error = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Fehler beim Laden der Anmeldungen:', err); 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() { async function loadEmailConfig() {
try { try {
isLoadingEmailConfig = true; isLoadingEmailConfig = true;
@@ -127,6 +193,16 @@ Ihr Praktikumsteam`;
const anmeldung = anmeldungen.find(a => a.id === event.detail.id); const anmeldung = anmeldungen.find(a => a.id === event.detail.id);
if (!anmeldung) return; 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 = [ availableWishes = [
anmeldung.wunsch1 && { id: anmeldung.wunsch1.id, name: `1. Wunsch: ${anmeldung.wunsch1.name}` }, 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.wunsch2 && { id: anmeldung.wunsch2.id, name: `2. Wunsch: ${anmeldung.wunsch2.name}` },
@@ -189,6 +265,15 @@ Ihr Praktikumsteam`;
} }
async function handleReject(event: CustomEvent<{id: number}>) { 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; if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
try { try {
@@ -231,6 +316,10 @@ Ihr Praktikumsteam`;
function closeDialog() { function closeDialog() {
showDialog = false; showDialog = false;
selectedAnmeldungId = null; selectedAnmeldungId = null;
// Status zurücksetzen falls Dialog abgebrochen wird
if (selectedAnmeldungId) {
// Hier könnten Sie den Status zurück auf "pending" setzen
}
} }
onMount(() => { onMount(() => {
@@ -250,8 +339,25 @@ Ihr Praktikumsteam`;
/> />
<main class="max-w-7xl mx-auto px-4 py-6"> <main class="max-w-7xl mx-auto px-4 py-6">
<!-- E-Mail Konfiguration Button --> <!-- Filter und E-Mail Konfiguration -->
<div class="mb-6 flex justify-end"> <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 -->
<button <button
on:click={() => showEmailConfig = !showEmailConfig} 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" 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> </button>
</div> </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 --> <!-- E-Mail Konfiguration Panel -->
{#if showEmailConfig} {#if showEmailConfig}
<div class="bg-white shadow-sm rounded-lg p-6 mb-6"> <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> <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> <span class="ml-3 text-gray-600">Lade Anmeldungen...</span>
</div> </div>
{:else if anmeldungen.length === 0} {:else if filteredAnmeldungen.length === 0}
<div class="text-center py-12"> <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"> <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" /> <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> </svg>
<h3 class="mt-2 text-sm font-medium text-gray-900">Keine Anmeldungen</h3> <h3 class="mt-2 text-sm font-medium text-gray-900">
<p class="mt-1 text-sm text-gray-500">Es sind noch keine Praktikumsanmeldungen eingegangen.</p> {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> </div>
{:else} {:else}
<div class="bg-white shadow-sm rounded-lg overflow-hidden"> <div class="bg-white shadow-sm rounded-lg overflow-hidden">
<AnmeldungenTable <AnmeldungenTable
{anmeldungen} anmeldungen={filteredAnmeldungen}
{getStatusColor}
{getStatusText}
on:accept={handleAccept} on:accept={handleAccept}
on:reject={handleReject} on:reject={handleReject}
on:delete={handleDelete} on:delete={handleDelete}

View File

@@ -1,214 +1,222 @@
// src/routes/api/admin/anmeldungen/+server.ts // src/routes/api/admin/anmeldungen/+server.js
import { PrismaClient, Status } from '@prisma/client';
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();
import type { Cookies } from '@sveltejs/kit'; export async function GET() {
// 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' }
}
);
}
try { try {
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: [
{
status: 'asc' // BEARBEITUNG zuerst, dann OFFEN, etc.
},
{
timestamp: 'desc'
}
]
}); });
return new Response(JSON.stringify(anmeldungen), { // Daten für Frontend formatieren
headers: { 'Content-Type': 'application/json' } 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) { } catch (error) {
console.error('Fehler beim Laden der Anmeldungen:', error); console.error('Fehler beim Laden der Anmeldungen:', error);
return json({ error: 'Fehler beim Laden der Anmeldungen' }, { status: 500 }); 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 { try {
// Prüfe ob eine spezifische Dienststelle zugewiesen werden soll const id = parseInt(url.searchParams.get('id') || '0');
const body = await request.json().catch(() => ({})); const { dienststelleId } = await request.json();
const dienststelleId = body.dienststelleId;
const anmeldung = await prisma.anmeldung.findUnique({ if (!id || !dienststelleId) {
where: { id }, return json({ error: 'ID und Dienststelle erforderlich' }, { status: 400 });
include: { }
wunsch1: true,
wunsch2: true, // Prüfen ob Anmeldung existiert und bearbeitet werden kann
wunsch3: true const existingAnmeldung = await prisma.anmeldung.findUnique({
} where: { id }
}); });
if (!anmeldung) { if (!existingAnmeldung) {
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 }); return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
} }
// Falls spezifische Dienststelle gewählt wurde if (existingAnmeldung.status === 'ANGENOMMEN') {
if (dienststelleId) { return json({ error: 'Anmeldung bereits angenommen' }, { status: 409 });
const dienststelle = await prisma.dienststelle.findUnique({
where: { id: dienststelleId }
});
if (!dienststelle) {
return json({ error: 'Dienststelle nicht gefunden' }, { status: 404 });
}
if (dienststelle.plaetze <= 0) {
return json({ error: 'Keine verfügbaren Plätze bei dieser Dienststelle' }, { status: 409 });
}
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 // Anmeldung als angenommen markieren
const wuensche = [anmeldung.wunsch1, anmeldung.wunsch2, anmeldung.wunsch3]; await prisma.anmeldung.update({
where: { id },
for (const wunsch of wuensche) { data: {
if (wunsch && wunsch.plaetze > 0) { status: 'ANGENOMMEN',
await prisma.$transaction([ zugewiesenId: dienststelleId,
prisma.anmeldung.update({ processedBy: 'current_user', // TODO: Echten Benutzer verwenden
where: { id }, processedAt: new Date()
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 }); return json({ success: true });
} catch (err) { } catch (error) {
console.error('Fehler beim Annehmen der Anmeldung:', err); console.error('Fehler beim Annehmen der Anmeldung:', error);
return json({ error: 'Interner Serverfehler' }, { status: 500 }); return json({ error: 'Fehler beim Annehmen der Anmeldung' }, { 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 });
export async function PATCH({ request, url }) {
try { try {
const body = await request.json().catch(() => ({})); const id = parseInt(url.searchParams.get('id') || '0');
const { action, processedBy } = await request.json();
if (body.action === 'reject') { if (!id) {
await prisma.anmeldung.update({ return json({ error: 'ID erforderlich' }, { status: 400 });
where: { id }, }
data: {
status: Status.ABGELEHNT 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 });
} }
});
return json({ success: true, message: 'Anmeldung abgelehnt' }); if (anmeldung.status !== 'OFFEN') {
return json({ error: 'Anmeldung kann nicht mehr bearbeitet werden' }, { status: 409 });
}
updateData = {
status: 'BEARBEITUNG',
processedBy: processedBy || 'current_user',
processedAt: new Date()
};
break;
case 'reset_processing':
updateData = {
status: 'OFFEN',
processedBy: null,
processedAt: null
};
break;
default:
return json({ error: 'Unbekannte Aktion' }, { status: 400 });
} }
return json({ error: 'Unbekannte Aktion' }, { status: 400 }); const result = await prisma.anmeldung.update({
} catch (err) { where: { id },
console.error('Fehler beim Ablehnen der Anmeldung:', err); data: updateData
return json({ error: 'Interner Serverfehler' }, { status: 500 }); });
}
};
export const DELETE: RequestHandler = async ({ cookies, url }) => { return json({ success: true });
if (!checkAuth(cookies)) { } catch (error) {
return json({ error: 'Nicht autorisiert' }, { status: 401 }); console.error('Fehler beim Aktualisieren der Anmeldung:', error);
} return json({ error: 'Fehler beim Aktualisieren der Anmeldung' }, { status: 500 });
const id = Number(url.searchParams.get('id'));
if (isNaN(id)) {
return json({ error: 'Ungültige ID' }, { status: 400 });
} }
}
export async function DELETE({ 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({
where: { anmeldungId: id }
});
// 2. Dateien vom Dateisystem löschen if (!id) {
for (const pdf of pdfs) { return json({ error: 'ID erforderlich' }, { status: 400 });
const filePath = path.resolve('static', pdf.pfad.replace(/^\/+/, ''));
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
}
} }
// 3. PDF-Datensätze aus DB löschen
await prisma.pdfDatei.deleteMany({
where: { anmeldungId: id }
});
// 4. Anmeldung löschen
await prisma.anmeldung.delete({ await prisma.anmeldung.delete({
where: { id } where: { id }
}); });
return json({ success: true, message: 'Anmeldung erfolgreich gelöscht' }); 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' }, { 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';
}