praktikum, Notfallkontakt und ein Platz pro Dienstelle bei neuer Zeitraum

This commit is contained in:
titver968
2025-12-29 12:09:55 +01:00
parent 07824f2b6a
commit a99ddf6fa9
9 changed files with 1123 additions and 996 deletions

Binary file not shown.

View File

@@ -17,7 +17,7 @@ model EmailConfig {
id Int @id @default(1) id Int @id @default(1)
subject String @default("Praktikumsplatz-Zusage") 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") 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") @@map("email_config")
} }
@@ -29,8 +29,8 @@ model Dienststelle {
anmeldungenWunsch2 Anmeldung[] @relation("Wunsch2") anmeldungenWunsch2 Anmeldung[] @relation("Wunsch2")
anmeldungenWunsch3 Anmeldung[] @relation("Wunsch3") anmeldungenWunsch3 Anmeldung[] @relation("Wunsch3")
zugewiesene Anmeldung[] @relation("Zugewiesen") zugewiesene Anmeldung[] @relation("Zugewiesen")
zeitraumPlaetze ZeitraumPlaetze[] zeitraumPlaetze ZeitraumPlaetze[]
} }
model Praktikumszeitraum { model Praktikumszeitraum {
@@ -39,19 +39,19 @@ model Praktikumszeitraum {
startDatum DateTime startDatum DateTime
endDatum DateTime endDatum DateTime
anmeldungen Anmeldung[] @relation("PraktikumszeitraumAnmeldungen") anmeldungen Anmeldung[] @relation("PraktikumszeitraumAnmeldungen")
zeitraumPlaetze ZeitraumPlaetze[] zeitraumPlaetze ZeitraumPlaetze[]
} }
model ZeitraumPlaetze { model ZeitraumPlaetze {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
zeitraumId Int zeitraumId Int
dienststelleId Int dienststelleId Int
plaetze Int @default(0) plaetze Int @default(0)
zeitraum Praktikumszeitraum @relation(fields: [zeitraumId], references: [id], onDelete: Cascade) zeitraum Praktikumszeitraum @relation(fields: [zeitraumId], references: [id], onDelete: Cascade)
dienststelle Dienststelle @relation(fields: [dienststelleId], references: [id], onDelete: Cascade) dienststelle Dienststelle @relation(fields: [dienststelleId], references: [id], onDelete: Cascade)
@@unique([zeitraumId, dienststelleId]) @@unique([zeitraumId, dienststelleId])
@@index([zeitraumId]) @@index([zeitraumId])
@@index([dienststelleId]) @@index([dienststelleId])
@@ -66,7 +66,7 @@ enum Status {
} }
model Anmeldung { model Anmeldung {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
anrede String anrede String
vorname String vorname String
nachname String nachname String
@@ -84,25 +84,31 @@ model Anmeldung {
sozialverhalten String? sozialverhalten String?
motivation String? motivation String?
alter Int? alter Int?
status Status @default(OFFEN)
// Notfallkontakt
processedAt DateTime? notfallVorname String?
notfallNachname String?
praktikumId Int? notfallTelefon String?
praktikum Praktikumszeitraum? @relation("PraktikumszeitraumAnmeldungen", fields: [praktikumId], references: [id])
status Status @default(OFFEN)
zugewiesenId Int?
zugewiesen Dienststelle? @relation("Zugewiesen", fields: [zugewiesenId], references: [id]) processedAt DateTime?
wunsch1Id Int?
wunsch1 Dienststelle? @relation("Wunsch1", fields: [wunsch1Id], references: [id]) praktikumId Int?
wunsch2Id Int? praktikum Praktikumszeitraum? @relation("PraktikumszeitraumAnmeldungen", fields: [praktikumId], references: [id])
wunsch2 Dienststelle? @relation("Wunsch2", fields: [wunsch2Id], references: [id])
wunsch3Id Int? zugewiesenId Int?
wunsch3 Dienststelle? @relation("Wunsch3", fields: [wunsch3Id], references: [id]) zugewiesen Dienststelle? @relation("Zugewiesen", fields: [zugewiesenId], references: [id])
wunsch1Id Int?
timestamp DateTime @default(now()) wunsch1 Dienststelle? @relation("Wunsch1", fields: [wunsch1Id], references: [id])
pdfs PdfDatei[] wunsch2Id Int?
wunsch2 Dienststelle? @relation("Wunsch2", fields: [wunsch2Id], references: [id])
wunsch3Id Int?
wunsch3 Dienststelle? @relation("Wunsch3", fields: [wunsch3Id], references: [id])
timestamp DateTime @default(now())
pdfs PdfDatei[]
@@index([status]) @@index([status])
@@index([processedAt]) @@index([processedAt])
@@index([zugewiesenId]) @@index([zugewiesenId])

View File

@@ -1,11 +1,11 @@
<!-- src/lib/components/AnmeldungenTable.svelte --> <!-- src/lib/components/AnmeldungenTable.svelte -->
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
export let anmeldungen: any[]; export let anmeldungen: any[];
export let getStatusColor: (status: string) => string; export let getStatusColor: (status: string) => string;
export let getStatusText: (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 };
@@ -92,240 +92,255 @@
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200"> <table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50"> <thead class="bg-gray-50">
<tr> <tr>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status Status
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Persönliche Daten Persönliche Daten
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Kontakt & Adresse Kontakt & Adresse
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Schule & Noten Schule & Noten
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Praktikum & Wünsche Praktikum & Wünsche
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Dokumente Dokumente
</th> </th>
<th class="px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th class="px-3 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 align-top"> <tr class="hover:bg-gray-50 align-top">
<!-- Status --> <!-- Status -->
<td class="px-3 py-4"> <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')}"> <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')} {getStatusText(anmeldung.status || 'pending')}
</span> </span>
<div class="text-xs text-gray-400 mt-2"> <div class="text-xs text-gray-400 mt-2">
Eingang:<br> Eingang:<br>
{formatDate(anmeldung.timestamp)} {formatDate(anmeldung.timestamp)}
</div> </div>
</td> </td>
<!-- Persönliche Daten --> <!-- Persönliche Daten -->
<td class="px-3 py-4"> <td class="px-3 py-4">
<div class="text-sm font-medium text-gray-900"> <div class="text-sm font-medium text-gray-900">
{anmeldung.anrede} {anmeldung.vorname} {anmeldung.nachname} {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> </div>
<div class="text-xs text-gray-500 mt-1"> {/if}
<span class="font-medium">Geb.:</span> {formatGeburtsdatum(anmeldung.geburtsdatum)} </td>
</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 --> <!-- Kontakt & Adresse -->
<td class="px-3 py-4 text-sm"> <td class="px-3 py-4 text-sm">
<div class="text-xs space-y-1"> <div class="text-xs space-y-1">
<div> <div>
<span class="font-medium text-gray-700">Adresse:</span><br> <span class="font-medium text-gray-700">Adresse:</span><br>
<span class="text-gray-600"> <span class="text-gray-600">
{anmeldung.strasse || '-'} {anmeldung.hausnummer || ''}<br> {anmeldung.strasse || '-'} {anmeldung.hausnummer || ''}<br>
{anmeldung.plz || ''} {anmeldung.ort || ''} {anmeldung.plz || ''} {anmeldung.ort || ''}
</span> </span>
</div> </div>
<div> <div>
<span class="font-medium text-gray-700">Tel:</span> <span class="font-medium text-gray-700">Tel:</span>
{#if anmeldung.telefon} {#if anmeldung.telefon}
<a href="tel:{anmeldung.telefon}" class="text-blue-600 hover:text-blue-800">{anmeldung.telefon}</a> <a href="tel:{anmeldung.telefon}" class="text-blue-600 hover:text-blue-800">{anmeldung.telefon}</a>
{:else} {:else}
<span class="text-gray-400">-</span> <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} {/if}
</div> </div>
<div> {/if}
<span class="font-medium text-gray-700">E-Mail:</span><br> </div>
<a href="mailto:{anmeldung.email}" class="text-blue-600 hover:text-blue-800 break-all text-xs">{anmeldung.email}</a> </td>
</div>
</div>
</td>
<!-- Schule & Noten --> <!-- Schule & Noten -->
<td class="px-3 py-4 text-sm"> <td class="px-3 py-4 text-sm">
<div class="text-xs space-y-1"> <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> <div>
<span class="font-medium text-gray-700">Schulart:</span><br> <span class="font-medium text-gray-700">Klasse:</span>
<span class="text-gray-900">{formatSchulart(anmeldung.schulart)}</span> <span class="text-gray-900">{anmeldung.schulklasse}. Klasse</span>
</div> </div>
{#if anmeldung.schulklasse} {/if}
<div> <div class="pt-1 border-t border-gray-100">
<span class="font-medium text-gray-700">Klasse:</span> <span class="font-medium text-gray-700">Noten:</span>
<span class="text-gray-900">{anmeldung.schulklasse}. Klasse</span> <div class="flex gap-2 mt-1">
</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"> <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> D: <span class="font-bold ml-1">{anmeldung.noteDeutsch || '-'}</span>
</span> </span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs bg-gray-100"> <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> M: <span class="font-bold ml-1">{anmeldung.noteMathe || '-'}</span>
</span> </span>
</div>
</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> </div>
</td> {#if anmeldung.sozialverhalten}
<!-- 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> <div>
<span class="font-medium text-gray-700">Wünsche:</span> <span class="font-medium text-gray-700">Sozialverh.:</span>
<div class="space-y-1 mt-1"> <span class="text-gray-900 text-xs">{formatSozialverhalten(anmeldung.sozialverhalten)}</span>
{#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> </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} {/if}
</td> </div>
</td>
<!-- Aktionen --> <!-- Praktikum & Wünsche -->
<td class="px-3 py-4"> <td class="px-3 py-4 text-sm">
<div class="flex flex-col space-y-2"> <div class="text-xs space-y-2">
{#if canBeAccepted(anmeldung.status || 'pending')} <!-- Zeitraum -->
<button {#if anmeldung.zeitraum}
on:click={() => dispatch('accept', { id: anmeldung.id })} <div>
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" <span class="font-medium text-gray-700">Zeitraum:</span><br>
> <span class="text-gray-900">{formatZeitraum(anmeldung.zeitraum)}</span>
✓ Annehmen </div>
</button> {/if}
{/if}
{#if canBeRejected(anmeldung.status || 'pending')} <!-- Zugewiesene Dienststelle -->
<button {#if anmeldung.assignedDienststelle}
on:click={() => dispatch('reject', { id: anmeldung.id })} <div class="p-2 bg-green-50 border border-green-200 rounded">
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" <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">
✗ Ablehnen <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" />
</button> </svg>
{/if} <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}
<button <!-- Alle 3 Wünsche -->
on:click={() => dispatch('delete', { id: anmeldung.id })} <div>
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" <span class="font-medium text-gray-700">Wünsche:</span>
> <div class="space-y-1 mt-1">
🗑 Löschen {#if anmeldung.wunsch1}
</button> <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> </div>
</td>
</tr> <!-- Motivation -->
{/each} {#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> </tbody>
</table> </table>
</div> </div>

View File

@@ -16,6 +16,11 @@
let zeitraum = ''; let zeitraum = '';
let motivation = ''; let motivation = '';
// Notfallkontakt
let notfallVorname = '';
let notfallNachname = '';
let notfallTelefon = '';
let wunsch1Id = ''; let wunsch1Id = '';
let wunsch2Id = ''; let wunsch2Id = '';
let wunsch3Id = ''; let wunsch3Id = '';
@@ -41,10 +46,10 @@
let sozialverhaltenFehler = ''; let sozialverhaltenFehler = '';
// Hinweis für IGS/KGS mit Lernentwicklungsbericht // Hinweis für IGS/KGS mit Lernentwicklungsbericht
$: zeigeIgsKgsHinweis = $: zeigeIgsKgsHinweis =
['IGSR', 'KGSR'].includes(schulart) && ['IGSR', 'KGSR'].includes(schulart) &&
schulklasse && schulklasse &&
parseInt(schulklasse) < 10; parseInt(schulklasse) < 10;
// Berechnung des Alters // Berechnung des Alters
$: { $: {
@@ -53,10 +58,10 @@
if (gewaehlterZeitraum) { if (gewaehlterZeitraum) {
const geburt = new Date(geburtsdatum); const geburt = new Date(geburtsdatum);
const praktikumStart = new Date(gewaehlterZeitraum.startDatum); const praktikumStart = new Date(gewaehlterZeitraum.startDatum);
let altersberechnung = praktikumStart.getFullYear() - geburt.getFullYear(); let altersberechnung = praktikumStart.getFullYear() - geburt.getFullYear();
const monthDiff = praktikumStart.getMonth() - geburt.getMonth(); const monthDiff = praktikumStart.getMonth() - geburt.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && praktikumStart.getDate() < geburt.getDate())) { if (monthDiff < 0 || (monthDiff === 0 && praktikumStart.getDate() < geburt.getDate())) {
altersberechnung--; altersberechnung--;
} }
@@ -80,7 +85,7 @@
const deutsch = parseInt(noteDeutsch); const deutsch = parseInt(noteDeutsch);
const mathe = parseInt(noteMathe); const mathe = parseInt(noteMathe);
const klasse = parseInt(schulklasse); const klasse = parseInt(schulklasse);
if (noteDeutsch && noteMathe && schulart) { if (noteDeutsch && noteMathe && schulart) {
// Gymnasium oder KGS_Gymnasialzweig: mindestens 4 in Deutsch UND Mathe // Gymnasium oder KGS_Gymnasialzweig: mindestens 4 in Deutsch UND Mathe
if (['Gymnasium', 'KGS_Gymnasialzweig'].includes(schulart)) { if (['Gymnasium', 'KGS_Gymnasialzweig'].includes(schulart)) {
@@ -129,7 +134,7 @@
dienststellen = []; dienststellen = [];
return; return;
} }
try { try {
isLoadingDienststellen = true; isLoadingDienststellen = true;
const res = await fetch(`/api/dienststellen?zeitraumId=${zeitraumId}`); const res = await fetch(`/api/dienststellen?zeitraumId=${zeitraumId}`);
@@ -153,7 +158,7 @@
// Filter: Nur Dienststellen mit freien Plätzen und Alterscheck für PK Mitte // Filter: Nur Dienststellen mit freien Plätzen und Alterscheck für PK Mitte
$: filteredDienststellen = (dienststellen ?? []).filter(d => { $: filteredDienststellen = (dienststellen ?? []).filter(d => {
if (d.plaetze <= 0) return false; if (d.plaetze <= 0) return false;
if (d.name.includes('PK Mitte') || d.name.toLowerCase().includes('polizeikommissariat mitte')) { if (d.name.includes('PK Mitte') || d.name.toLowerCase().includes('polizeikommissariat mitte')) {
return parseInt(alter) >= 18; return parseInt(alter) >= 18;
} }
@@ -168,9 +173,9 @@
let startDatum = ''; let startDatum = '';
$: hideSozialVerhalten = $: hideSozialVerhalten =
Number(schulklasse) >= 11 && Number(schulklasse) >= 11 &&
["Gymnasium", "KGS_Gymnasialzweig", "Fachoberschule"].includes(schulart); ["Gymnasium", "KGS_Gymnasialzweig", "Fachoberschule"].includes(schulart);
onMount(async () => { onMount(async () => {
const resZeitraeume = await fetch('/api/zeitraeume'); const resZeitraeume = await fetch('/api/zeitraeume');
@@ -208,6 +213,9 @@
noteMathe = ''; noteMathe = '';
sozialverhalten = ''; sozialverhalten = '';
schulklasse = ''; schulklasse = '';
notfallVorname = '';
notfallNachname = '';
notfallTelefon = '';
pdfDateien = []; pdfDateien = [];
fileInputKey += 1; fileInputKey += 1;
success = false; success = false;
@@ -246,6 +254,9 @@
data.append('sozialverhalten', sozialverhalten); data.append('sozialverhalten', sozialverhalten);
data.append('schulklasse', schulklasse); data.append('schulklasse', schulklasse);
data.append('alter', alter); 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);
@@ -254,7 +265,7 @@
const res = await fetch('/api/anmelden', { const res = await fetch('/api/anmelden', {
method: 'POST', method: 'POST',
body: data body: data
}); });
const result = await res.json(); const result = await res.json();
if (!res.ok) { if (!res.ok) {
@@ -263,13 +274,13 @@
} else { } else {
fehler = ''; fehler = '';
success = true; success = true;
} }
} }
</script> </script>
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-6"> <div class="min-h-screen bg-gray-100 flex items-center justify-center p-6">
<form on:submit|preventDefault={anmelden} <form on:submit|preventDefault={anmelden}
class="bg-white shadow-md rounded-2xl p-8 max-w-2xl w-full space-y-4"> class="bg-white shadow-md rounded-2xl p-8 max-w-2xl w-full space-y-4">
<h1 class="text-2xl font-bold text-gray-800 mb-4 text-center">Praktikumsanmeldung</h1> <h1 class="text-2xl font-bold text-gray-800 mb-4 text-center">Praktikumsanmeldung</h1>
@@ -302,7 +313,7 @@
<option value="KGSR">Kooperative Gesamtschule Realschulzweg</option> <option value="KGSR">Kooperative Gesamtschule Realschulzweg</option>
<option value="IGSR">Integrierte Gesamtschule Realschulzweig</option> <option value="IGSR">Integrierte Gesamtschule Realschulzweig</option>
</select> </select>
<!-- Schulklasse --> <!-- Schulklasse -->
<select bind:value={schulklasse} required class="input"> <select bind:value={schulklasse} required class="input">
<option value="" disabled selected hidden>Schulklasse</option> <option value="" disabled selected hidden>Schulklasse</option>
@@ -314,15 +325,15 @@
<option value="12">12. Klasse</option> <option value="12">12. Klasse</option>
<option value="13">13. Klasse</option> <option value="13">13. Klasse</option>
</select> </select>
<!-- Sozialverhalten mit Echtzeit-Validierung --> <!-- Sozialverhalten mit Echtzeit-Validierung -->
{#if !hideSozialVerhalten} {#if !hideSozialVerhalten}
<div class="col-span-2"> <div class="col-span-2">
<select <select
bind:value={sozialverhalten} bind:value={sozialverhalten}
required required
class="input" class="input"
class:input-error={sozialverhaltenFehler} class:input-error={sozialverhaltenFehler}
> >
<option value="" disabled selected hidden>Sozialverhalten auswählen</option> <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 in vollem Umfang">Entspricht den Erwartungen in vollem Umfang</option>
@@ -350,27 +361,27 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<input <input
bind:value={noteDeutsch} bind:value={noteDeutsch}
type="number" type="number"
min="1" min="1"
max="6" max="6"
placeholder="Note in Deutsch" placeholder="Note in Deutsch"
required required
class="input" class="input"
class:input-error={notenFehler} class:input-error={notenFehler}
/> />
<input <input
bind:value={noteMathe} bind:value={noteMathe}
type="number" type="number"
min="1" min="1"
max="6" max="6"
placeholder="Note in Mathe" placeholder="Note in Mathe"
required required
class="input" class="input"
class:input-error={notenFehler} class:input-error={notenFehler}
/> />
</div> </div>
{#if notenFehler} {#if notenFehler}
@@ -384,8 +395,8 @@
<select bind:value={zeitraum} required class="input"> <select bind:value={zeitraum} required class="input">
<option value="" disabled selected hidden>Wunschzeitraum fürs Praktikum</option> <option value="" disabled selected hidden>Wunschzeitraum fürs Praktikum</option>
{#each filteredZeitraeume as d} {#each filteredZeitraeume as d}
<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' })}) 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> </option>
{/each} {/each}
</select> </select>
@@ -453,7 +464,7 @@
<!-- Motivation --> <!-- Motivation -->
<textarea bind:value={motivation} placeholder="Motivation (optional)" <textarea bind:value={motivation} placeholder="Motivation (optional)"
class="w-full border border-gray-300 rounded-xl p-3 focus:outline-none focus:ring-2 focus:ring-blue-500 h-32 resize-none" > class="w-full border border-gray-300 rounded-xl p-3 focus:outline-none focus:ring-2 focus:ring-blue-500 h-32 resize-none" >
</textarea> </textarea>
<!-- Mehrere PDF Upload --> <!-- Mehrere PDF Upload -->
@@ -461,23 +472,33 @@
<div> <div>
<label for="pdf-upload" class="block text-gray-700 font-medium mb-1">Zeugnis hochladen:</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"
accept="application/pdf" accept="application/pdf"
multiple multiple
on:change={(e) => pdfDateien = Array.from((e.target as HTMLInputElement).files || [])} on:change={(e) => pdfDateien = Array.from((e.target as HTMLInputElement).files || [])}
class="input" class="input"
/> />
</div> </div>
{/key} {/key}
<!-- Notfallkontakt -->
<div class="border-t pt-4 mt-4">
<h2 class="text-lg font-semibold text-gray-700 mb-3">Notfallkontakt</h2>
<div class="grid grid-cols-2 gap-4">
<input bind:value={notfallVorname} placeholder="Vorname Notfallkontakt" required class="input" />
<input bind:value={notfallNachname} placeholder="Nachname Notfallkontakt" required class="input" />
<input bind:value={notfallTelefon} type="tel" placeholder="Mobilnummer Notfallkontakt" required class="input col-span-2" />
</div>
</div>
{#if showAblehnungModal} {#if showAblehnungModal}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <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> <p class="text-red-600 font-semibold">{ablehnungHinweis}</p>
<button <button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={() => { resetForm(); showAblehnungModal = false; }} on:click={() => { resetForm(); showAblehnungModal = false; }}
> >
OK OK
</button> </button>
@@ -486,15 +507,15 @@
{/if} {/if}
<!-- Button - deaktiviert bei Validierungsfehlern oder fehlenden Pflichtfeldern --> <!-- Button - deaktiviert bei Validierungsfehlern oder fehlenden Pflichtfeldern -->
<button <button
type="submit" type="submit"
disabled={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id} disabled={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
class="w-full py-3 rounded-xl transition-all" class="w-full py-3 rounded-xl transition-all"
class:bg-blue-600={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id} class:bg-blue-600={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
class:hover:bg-blue-700={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id} class:hover:bg-blue-700={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
class:text-white={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id} class:text-white={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
class:bg-gray-400={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id} class:bg-gray-400={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
class:cursor-not-allowed={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id} class:cursor-not-allowed={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
> >
Jetzt anmelden Jetzt anmelden
</button> </button>
@@ -508,8 +529,8 @@
<div class="bg-white p-6 rounded shadow-lg text-center space-y-4 max-w-sm w-full"> <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> <p class="text-green-600 font-semibold">Anmeldung erfolgreich gesendet!</p>
<button <button
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
on:click={resetForm} on:click={resetForm}
> >
OK OK
</button> </button>

View File

@@ -5,7 +5,7 @@
import AnmeldungenTable from '$lib/components/AnmeldungenTable.svelte'; import AnmeldungenTable from '$lib/components/AnmeldungenTable.svelte';
import DienststellenDialog from '$lib/components/DienststellenDialog.svelte'; import DienststellenDialog from '$lib/components/DienststellenDialog.svelte';
import AdminHeader from '$lib/components/AdminHeader.svelte'; import AdminHeader from '$lib/components/AdminHeader.svelte';
interface Anmeldung { interface Anmeldung {
pdfs: { pfad: string }[]; pdfs: { pfad: string }[];
anrede: string; anrede: string;
@@ -15,6 +15,9 @@
noteDeutsch?: string; noteDeutsch?: string;
noteMathe?: string; noteMathe?: string;
sozialverhalten?: string; sozialverhalten?: string;
notfallVorname?: string;
notfallNachname?: string;
notfallTelefon?: string;
wunsch1?: { id: number; name: string }; wunsch1?: { id: number; name: string };
wunsch2?: { id: number; name: string }; wunsch2?: { id: number; name: string };
wunsch3?: { id: number; name: string }; wunsch3?: { id: number; name: string };
@@ -43,11 +46,11 @@
let zeitraeume: Zeitraum[] = []; let zeitraeume: Zeitraum[] = [];
let isLoading = true; let isLoading = true;
let error = ''; let error = '';
// Filter für Status // Filter für Status
let statusFilter: 'all' | 'pending' | 'accepted' | 'rejected' = 'all'; let statusFilter: 'all' | 'pending' | 'accepted' | 'rejected' = 'all';
let filteredAnmeldungen: Anmeldung[] = []; let filteredAnmeldungen: Anmeldung[] = [];
// Dialog state // Dialog state
let showDialog = false; let showDialog = false;
let selectedAnmeldungId: number | null = null; let selectedAnmeldungId: number | null = null;
@@ -120,9 +123,9 @@ Ihr Praktikumsteam`;
// Zähle angenommene Anmeldungen pro Zeitraum // Zähle angenommene Anmeldungen pro Zeitraum
function getAcceptedCountForZeitraum(zeitraumId: number): number { function getAcceptedCountForZeitraum(zeitraumId: number): number {
return anmeldungen.filter(a => return anmeldungen.filter(a =>
a.status === 'accepted' && a.status === 'accepted' &&
a.zeitraum?.id === zeitraumId a.zeitraum?.id === zeitraumId
).length; ).length;
} }
@@ -130,26 +133,26 @@ Ihr Praktikumsteam`;
try { try {
isLoading = true; isLoading = true;
error = ''; error = '';
const res = await fetch('/api/admin/anmeldungen'); const res = await fetch('/api/admin/anmeldungen');
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); const errorText = await res.text();
console.error('❌ API Fehler:', res.status, errorText); console.error('❌ API Fehler:', res.status, errorText);
throw new Error(`Fehler beim Laden: ${res.status} - ${errorText}`); throw new Error(`Fehler beim Laden: ${res.status} - ${errorText}`);
} }
const data = await res.json(); const data = await res.json();
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
console.error('❌ Antwort ist kein Array:', data); console.error('❌ Antwort ist kein Array:', data);
throw new Error('Antwort vom Server ist kein Array'); throw new Error('Antwort vom Server ist kein Array');
} }
anmeldungen = data.map(a => { anmeldungen = data.map(a => {
return { ...a, status: a.status || 'pending' }; return { ...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('❌ Frontend Fehler beim Laden der Anmeldungen:', err); console.error('❌ Frontend Fehler beim Laden der Anmeldungen:', err);
@@ -172,13 +175,13 @@ Ihr Praktikumsteam`;
async function loadEmailConfig() { async function loadEmailConfig() {
try { try {
isLoadingEmailConfig = true; isLoadingEmailConfig = true;
const res = await fetch('/api/admin/email-config'); const res = await fetch('/api/admin/email-config');
if (!res.ok) { if (!res.ok) {
throw new Error(`Fehler beim Laden der E-Mail-Konfiguration: ${res.status}`); throw new Error(`Fehler beim Laden der E-Mail-Konfiguration: ${res.status}`);
} }
const config: EmailConfig = await res.json(); const config: EmailConfig = await res.json();
emailSubject = config.subject; emailSubject = config.subject;
emailTemplate = config.template; emailTemplate = config.template;
@@ -192,7 +195,7 @@ Ihr Praktikumsteam`;
async function saveEmailTemplate() { async function saveEmailTemplate() {
try { try {
isSavingEmailConfig = true; isSavingEmailConfig = true;
const res = await fetch('/api/admin/email-config', { const res = await fetch('/api/admin/email-config', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -247,10 +250,10 @@ Ihr Praktikumsteam`;
} }
showDialog = false; showDialog = false;
// E-Mail Vorschau öffnen nach erfolgreichem Annehmen // E-Mail Vorschau öffnen nach erfolgreichem Annehmen
openEmailPreview(selectedAnmeldungId, event.detail.dienststelleId); openEmailPreview(selectedAnmeldungId, event.detail.dienststelleId);
selectedAnmeldungId = null; selectedAnmeldungId = null;
await loadAnmeldungen(); await loadAnmeldungen();
} catch (err) { } catch (err) {
@@ -265,16 +268,16 @@ Ihr Praktikumsteam`;
// Dienststelle finden // Dienststelle finden
const dienststelle = availableWishes.find(w => w.id === dienststelleId); const dienststelle = availableWishes.find(w => w.id === dienststelleId);
const dienststelleName = dienststelle const dienststelleName = dienststelle
? dienststelle.name.replace(/^\d+\.\s*Wunsch:\s*/, '') ? dienststelle.name.replace(/^\d+\.\s*Wunsch:\s*/, '')
: 'Unbekannte Dienststelle'; : 'Unbekannte Dienststelle';
// E-Mail Text personalisieren // E-Mail Text personalisieren
const personalizedEmail = emailTemplate const personalizedEmail = emailTemplate
.replace(/\{anrede\}/g, anmeldung.anrede) .replace(/\{anrede\}/g, anmeldung.anrede)
.replace(/\{vorname\}/g, anmeldung.vorname) .replace(/\{vorname\}/g, anmeldung.vorname)
.replace(/\{nachname\}/g, anmeldung.nachname) .replace(/\{nachname\}/g, anmeldung.nachname)
.replace(/\{dienststelle\}/g, dienststelleName); .replace(/\{dienststelle\}/g, dienststelleName);
// Modal mit Vorschau öffnen // Modal mit Vorschau öffnen
emailPreviewData = { emailPreviewData = {
@@ -288,11 +291,11 @@ Ihr Praktikumsteam`;
async function copyAndOpenMail() { async function copyAndOpenMail() {
if (!emailPreviewData) return; if (!emailPreviewData) return;
try { try {
await navigator.clipboard.writeText(emailPreviewData.body); await navigator.clipboard.writeText(emailPreviewData.body);
emailCopied = true; emailCopied = true;
// Kurz warten, dann Mail öffnen // Kurz warten, dann Mail öffnen
setTimeout(() => { setTimeout(() => {
const mailtoLink = `mailto:${emailPreviewData!.to}?subject=${encodeURIComponent(emailPreviewData!.subject)}`; const mailtoLink = `mailto:${emailPreviewData!.to}?subject=${encodeURIComponent(emailPreviewData!.subject)}`;
@@ -347,7 +350,7 @@ Ihr Praktikumsteam`;
const blob = await res.blob(); const blob = await res.blob();
const contentDisposition = res.headers.get('Content-Disposition'); const contentDisposition = res.headers.get('Content-Disposition');
let filename = 'export.xlsx'; let filename = 'export.xlsx';
if (contentDisposition) { if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/); const match = contentDisposition.match(/filename="(.+)"/);
if (match) { if (match) {
@@ -375,18 +378,18 @@ Ihr Praktikumsteam`;
async function handleReject(event: CustomEvent<{id: number}>) { async function handleReject(event: CustomEvent<{id: number}>) {
if (!confirm('Diese Anmeldung wirklich ablehnen?')) return; if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
try { try {
const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, { const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject' }) body: JSON.stringify({ action: 'reject' })
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Fehler beim Ablehnen: ${res.status}`); throw new Error(`Fehler beim Ablehnen: ${res.status}`);
} }
await loadAnmeldungen(); await loadAnmeldungen();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Ablehnen'; error = err instanceof Error ? err.message : 'Fehler beim Ablehnen';
@@ -396,16 +399,16 @@ Ihr Praktikumsteam`;
async function handleDelete(event: CustomEvent<{id: number}>) { async function handleDelete(event: CustomEvent<{id: number}>) {
if (!confirm('Diese Anmeldung wirklich löschen?')) return; if (!confirm('Diese Anmeldung wirklich löschen?')) return;
try { try {
const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, { const res = await fetch(`/api/admin/anmeldungen?id=${event.detail.id}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (!res.ok) { if (!res.ok) {
throw new Error(`Fehler beim Löschen: ${res.status}`); throw new Error(`Fehler beim Löschen: ${res.status}`);
} }
await loadAnmeldungen(); await loadAnmeldungen();
} catch (err) { } catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Löschen'; error = err instanceof Error ? err.message : 'Fehler beim Löschen';
@@ -430,9 +433,9 @@ Ihr Praktikumsteam`;
</svelte:head> </svelte:head>
<div class="min-h-screen bg-gray-50"> <div class="min-h-screen bg-gray-50">
<AdminHeader <AdminHeader
title="Anmeldungen verwalten" title="Anmeldungen verwalten"
showBackButton={true} showBackButton={true}
/> />
<main class="max-w-7xl mx-auto px-4 py-6"> <main class="max-w-7xl mx-auto px-4 py-6">
@@ -442,9 +445,9 @@ Ihr Praktikumsteam`;
<div class="flex items-center space-x-4"> <div class="flex items-center space-x-4">
<label for="status-filter" class="text-sm font-medium text-gray-700">Filter:</label> <label for="status-filter" class="text-sm font-medium text-gray-700">Filter:</label>
<select <select
id="status-filter" id="status-filter"
bind:value={statusFilter} bind:value={statusFilter}
class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:ring-blue-500 focus:border-blue-500" 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="all">Alle ({anmeldungen.length})</option>
<option value="pending">Offen ({anmeldungen.filter(a => (a.status || 'pending') === 'pending').length})</option> <option value="pending">Offen ({anmeldungen.filter(a => (a.status || 'pending') === 'pending').length})</option>
@@ -457,9 +460,9 @@ Ihr Praktikumsteam`;
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<!-- Excel Export Button --> <!-- Excel Export Button -->
<button <button
on:click={openExportModal} on:click={openExportModal}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center" class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
disabled={anmeldungen.filter(a => a.status === 'accepted').length === 0} 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"> <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" /> <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" />
@@ -469,9 +472,9 @@ Ihr Praktikumsteam`;
<!-- E-Mail Konfiguration Button --> <!-- 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"
disabled={isLoadingEmailConfig} disabled={isLoadingEmailConfig}
> >
{#if isLoadingEmailConfig} {#if isLoadingEmailConfig}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
@@ -546,49 +549,49 @@ Ihr Praktikumsteam`;
{#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">
<h3 class="text-lg font-medium text-gray-900 mb-4">E-Mail-Vorlage konfigurieren</h3> <h3 class="text-lg font-medium text-gray-900 mb-4">E-Mail-Vorlage konfigurieren</h3>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label for="email-subject" class="block text-sm font-medium text-gray-700 mb-2"> <label for="email-subject" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail Betreff E-Mail Betreff
</label> </label>
<input <input
id="email-subject" id="email-subject"
type="text" type="text"
bind:value={emailSubject} bind:value={emailSubject}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isSavingEmailConfig} disabled={isSavingEmailConfig}
/> />
</div> </div>
<div> <div>
<label for="email-template" class="block text-sm font-medium text-gray-700 mb-2"> <label for="email-template" class="block text-sm font-medium text-gray-700 mb-2">
E-Mail Text E-Mail Text
</label> </label>
<textarea <textarea
id="email-template" id="email-template"
bind:value={emailTemplate} bind:value={emailTemplate}
rows="10" rows="10"
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
disabled={isSavingEmailConfig} disabled={isSavingEmailConfig}
></textarea> ></textarea>
<p class="mt-2 text-sm text-gray-500"> <p class="mt-2 text-sm text-gray-500">
Verfügbare Platzhalter: &#123;anrede&#125;, &#123;vorname&#125;, &#123;nachname&#125;, &#123;dienststelle&#125; Verfügbare Platzhalter: &#123;anrede&#125;, &#123;vorname&#125;, &#123;nachname&#125;, &#123;dienststelle&#125;
</p> </p>
</div> </div>
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<button <button
on:click={() => showEmailConfig = false} on:click={() => showEmailConfig = false}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
disabled={isSavingEmailConfig} disabled={isSavingEmailConfig}
> >
Abbrechen Abbrechen
</button> </button>
<button <button
on:click={saveEmailTemplate} 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" 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} disabled={isSavingEmailConfig}
> >
{#if isSavingEmailConfig} {#if isSavingEmailConfig}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
@@ -614,9 +617,9 @@ Ihr Praktikumsteam`;
<h3 class="text-sm font-medium text-red-800">Fehler</h3> <h3 class="text-sm font-medium text-red-800">Fehler</h3>
<p class="mt-1 text-sm text-red-700">{error}</p> <p class="mt-1 text-sm text-red-700">{error}</p>
</div> </div>
<button <button
on:click={() => error = ''} on:click={() => error = ''}
class="ml-auto text-red-400 hover:text-red-600" 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
@@ -640,20 +643,20 @@ Ihr Praktikumsteam`;
{statusFilter === 'all' ? 'Keine Anmeldungen' : `Keine ${getStatusText(statusFilter).toLowerCase()}en Anmeldungen`} {statusFilter === 'all' ? 'Keine Anmeldungen' : `Keine ${getStatusText(statusFilter).toLowerCase()}en Anmeldungen`}
</h3> </h3>
<p class="mt-1 text-sm text-gray-500"> <p class="mt-1 text-sm text-gray-500">
{statusFilter === 'all' {statusFilter === 'all'
? 'Es sind noch keine Praktikumsanmeldungen eingegangen.' ? 'Es sind noch keine Praktikumsanmeldungen eingegangen.'
: `Es gibt keine Anmeldungen mit dem Status "${getStatusText(statusFilter).toLowerCase()}".`} : `Es gibt keine Anmeldungen mit dem Status "${getStatusText(statusFilter).toLowerCase()}".`}
</p> </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={filteredAnmeldungen} anmeldungen={filteredAnmeldungen}
{getStatusColor} {getStatusColor}
{getStatusText} {getStatusText}
on:accept={handleAccept} on:accept={handleAccept}
on:reject={handleReject} on:reject={handleReject}
on:delete={handleDelete} on:delete={handleDelete}
/> />
</div> </div>
{/if} {/if}
@@ -663,10 +666,10 @@ Ihr Praktikumsteam`;
<!-- Dienststellen Dialog --> <!-- Dienststellen Dialog -->
{#if showDialog} {#if showDialog}
<DienststellenDialog <DienststellenDialog
wishes={availableWishes} wishes={availableWishes}
selectedId={selectedDienststelleId} selectedId={selectedDienststelleId}
on:confirm={handleConfirmAccept} on:confirm={handleConfirmAccept}
on:cancel={closeDialog} on:cancel={closeDialog}
/> />
{/if} {/if}
@@ -677,11 +680,11 @@ Ihr Praktikumsteam`;
<!-- Backdrop --> <!-- Backdrop -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
on:click={closeExportModal} on:click={closeExportModal}
></div> ></div>
<!-- Modal --> <!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl max-w-md w-full"> <div class="relative bg-white rounded-lg shadow-xl max-w-md w-full">
<!-- Header --> <!-- Header -->
@@ -694,31 +697,31 @@ Ihr Praktikumsteam`;
</div> </div>
<h3 class="text-lg font-medium text-gray-900">Excel-Export</h3> <h3 class="text-lg font-medium text-gray-900">Excel-Export</h3>
</div> </div>
<button <button
on:click={closeExportModal} on:click={closeExportModal}
class="text-gray-400 hover:text-gray-600 transition-colors" 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="px-6 py-4"> <div class="px-6 py-4">
<p class="text-sm text-gray-600 mb-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. Wählen Sie einen Praktikumszeitraum aus, um alle angenommenen Anmeldungen als Excel-Datei zu exportieren.
</p> </p>
<div> <div>
<label for="export-zeitraum" class="block text-sm font-medium text-gray-700 mb-2"> <label for="export-zeitraum" class="block text-sm font-medium text-gray-700 mb-2">
Praktikumszeitraum Praktikumszeitraum
</label> </label>
<select <select
id="export-zeitraum" id="export-zeitraum"
bind:value={selectedExportZeitraum} bind:value={selectedExportZeitraum}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500"
disabled={isExporting} disabled={isExporting}
> >
<option value="" disabled>Zeitraum auswählen...</option> <option value="" disabled>Zeitraum auswählen...</option>
{#each zeitraeume as z} {#each zeitraeume as z}
@@ -730,20 +733,20 @@ Ihr Praktikumsteam`;
</select> </select>
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end space-x-3"> <div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end space-x-3">
<button <button
on:click={closeExportModal} on:click={closeExportModal}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800" class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
disabled={isExporting} disabled={isExporting}
> >
Abbrechen Abbrechen
</button> </button>
<button <button
on:click={exportToExcel} 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" 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} disabled={!selectedExportZeitraum || isExporting}
> >
{#if isExporting} {#if isExporting}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div> <div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
@@ -768,11 +771,11 @@ Ihr Praktikumsteam`;
<!-- Backdrop --> <!-- Backdrop -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<div <div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity" class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
on:click={closeEmailPreview} on:click={closeEmailPreview}
></div> ></div>
<!-- Modal --> <!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden"> <div class="relative bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-hidden">
<!-- Header --> <!-- Header -->
@@ -785,16 +788,16 @@ Ihr Praktikumsteam`;
</div> </div>
<h3 class="text-lg font-medium text-gray-900">E-Mail Vorschau</h3> <h3 class="text-lg font-medium text-gray-900">E-Mail Vorschau</h3>
</div> </div>
<button <button
on:click={closeEmailPreview} on:click={closeEmailPreview}
class="text-gray-400 hover:text-gray-600 transition-colors" 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"> <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" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="px-6 py-4 overflow-y-auto max-h-[60vh]"> <div class="px-6 py-4 overflow-y-auto max-h-[60vh]">
<div class="space-y-4"> <div class="space-y-4">
@@ -803,30 +806,30 @@ Ihr Praktikumsteam`;
<span class="text-sm font-medium text-gray-500 w-16">An:</span> <span class="text-sm font-medium text-gray-500 w-16">An:</span>
<span class="text-sm text-gray-900">{emailPreviewData.to}</span> <span class="text-sm text-gray-900">{emailPreviewData.to}</span>
</div> </div>
<!-- Betreff --> <!-- Betreff -->
<div class="flex items-center space-x-3 p-3 bg-gray-50 rounded-lg"> <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 font-medium text-gray-500 w-16">Betreff:</span>
<span class="text-sm text-gray-900">{emailPreviewData.subject}</span> <span class="text-sm text-gray-900">{emailPreviewData.subject}</span>
</div> </div>
<!-- Nachricht (editierbar) --> <!-- Nachricht (editierbar) -->
<div> <div>
<label for="email-body" class="block text-sm font-medium text-gray-700 mb-2"> <label for="email-body" class="block text-sm font-medium text-gray-700 mb-2">
Nachricht Nachricht
<span class="font-normal text-gray-500">(kann bearbeitet werden)</span> <span class="font-normal text-gray-500">(kann bearbeitet werden)</span>
</label> </label>
<textarea <textarea
id="email-body" id="email-body"
bind:value={emailPreviewData.body} bind:value={emailPreviewData.body}
rows="14" 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" 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} disabled={emailCopied}
></textarea> ></textarea>
</div> </div>
</div> </div>
</div> </div>
<!-- Footer --> <!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50"> <div class="px-6 py-4 border-t border-gray-200 bg-gray-50">
{#if emailCopied} {#if emailCopied}
@@ -853,14 +856,14 @@ Ihr Praktikumsteam`;
</div> </div>
<div class="flex space-x-3 w-full sm:w-auto"> <div class="flex space-x-3 w-full sm:w-auto">
<button <button
on:click={closeEmailPreview} 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" 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 Abbrechen
</button> </button>
<button <button
on:click={copyAndOpenMail} 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" 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"> <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" /> <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" />

View File

@@ -6,282 +6,321 @@ import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
export async function GET() { export async function GET() {
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, zugewiesen: true,
praktikum: true, praktikum: true,
pdfs: true pdfs: true
}, },
orderBy: [ orderBy: [
{ {
timestamp: 'desc' timestamp: 'desc'
} }
] ]
}); });
const formattedAnmeldungen = anmeldungen.map(anmeldung => ({ const formattedAnmeldungen = anmeldungen.map((anmeldung) => ({
id: anmeldung.id, id: anmeldung.id,
anrede: anmeldung.anrede, anrede: anmeldung.anrede,
vorname: anmeldung.vorname, vorname: anmeldung.vorname,
nachname: anmeldung.nachname, nachname: anmeldung.nachname,
email: anmeldung.email, 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,
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); geburtsdatum: anmeldung.geburtsdatum,
} catch (error) { strasse: anmeldung.strasse,
console.error('Fehler beim Laden der Anmeldungen:', error); hausnummer: anmeldung.hausnummer,
return json({ error: 'Fehler beim Laden der Anmeldungen', details: (error as Error).message }, { status: 500 }); 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 }) { export async function POST({ request, url }) {
try { try {
const id = parseInt(url.searchParams.get('id') || '0'); const id = parseInt(url.searchParams.get('id') || '0');
const { dienststelleId } = await request.json(); const { dienststelleId } = await request.json();
if (!id || !dienststelleId) { if (!id || !dienststelleId) {
return json({ error: 'ID und Dienststelle erforderlich' }, { status: 400 }); return json({ error: 'ID und Dienststelle erforderlich' }, { status: 400 });
} }
// Prüfen ob Anmeldung existiert und Praktikumszeitraum laden // Prüfen ob Anmeldung existiert und Praktikumszeitraum laden
const existingAnmeldung = await prisma.anmeldung.findUnique({ const existingAnmeldung = await prisma.anmeldung.findUnique({
where: { id }, where: { id },
include: { include: {
praktikum: true praktikum: true
} }
}); });
if (!existingAnmeldung) { if (!existingAnmeldung) {
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 }); return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
} }
if (existingAnmeldung.status === 'ANGENOMMEN') { if (existingAnmeldung.status === 'ANGENOMMEN') {
return json({ error: 'Anmeldung bereits angenommen' }, { status: 409 }); return json({ error: 'Anmeldung bereits angenommen' }, { status: 409 });
} }
const zeitraumId = existingAnmeldung.praktikumId; const zeitraumId = existingAnmeldung.praktikumId;
if (!zeitraumId) { if (!zeitraumId) {
return json({ error: 'Kein Praktikumszeitraum für diese Anmeldung gefunden' }, { status: 400 }); return json(
} { error: 'Kein Praktikumszeitraum für diese Anmeldung gefunden' },
{ status: 400 }
);
}
// Prüfen ob ZeitraumPlaetze Eintrag existiert und freie Plätze vorhanden sind // Prüfen ob ZeitraumPlaetze Eintrag existiert und freie Plätze vorhanden sind
const zeitraumPlaetze = await prisma.zeitraumPlaetze.findUnique({ const zeitraumPlaetze = await prisma.zeitraumPlaetze.findUnique({
where: { where: {
zeitraumId_dienststelleId: { zeitraumId_dienststelleId: {
zeitraumId: zeitraumId, zeitraumId: zeitraumId,
dienststelleId: dienststelleId dienststelleId: dienststelleId
} }
} }
}); });
if (!zeitraumPlaetze) { if (!zeitraumPlaetze) {
return json({ return json(
error: 'Keine Plätze für diese Dienststelle in diesem Zeitraum konfiguriert' {
}, { status: 400 }); error: 'Keine Plätze für diese Dienststelle in diesem Zeitraum konfiguriert'
} },
{ status: 400 }
);
}
// Prüfen ob noch Plätze frei sind (plaetze > 0) // Prüfen ob noch Plätze frei sind (plaetze > 0)
if (zeitraumPlaetze.plaetze <= 0) { if (zeitraumPlaetze.plaetze <= 0) {
return json({ return json(
error: 'Keine freien Plätze mehr für diese Dienststelle in diesem Zeitraum verfügbar.' {
}, { status: 400 }); 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 // Anmeldung als angenommen markieren und Plätze in ZeitraumPlaetze reduzieren
// Verwendung von $transaction für Atomarität (Race Condition vermeiden) // Verwendung von $transaction für Atomarität (Race Condition vermeiden)
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Nochmal prüfen innerhalb der Transaktion // Nochmal prüfen innerhalb der Transaktion
const aktuellerStand = await tx.zeitraumPlaetze.findUnique({ const aktuellerStand = await tx.zeitraumPlaetze.findUnique({
where: { where: {
zeitraumId_dienststelleId: { zeitraumId_dienststelleId: {
zeitraumId: zeitraumId, zeitraumId: zeitraumId,
dienststelleId: dienststelleId dienststelleId: dienststelleId
} }
} }
}); });
if (!aktuellerStand || aktuellerStand.plaetze <= 0) { if (!aktuellerStand || aktuellerStand.plaetze <= 0) {
throw new Error('Keine freien Plätze mehr verfügbar'); throw new Error('Keine freien Plätze mehr verfügbar');
} }
// Anmeldung aktualisieren // Anmeldung aktualisieren
await tx.anmeldung.update({ await tx.anmeldung.update({
where: { id }, where: { id },
data: { data: {
status: 'ANGENOMMEN', status: 'ANGENOMMEN',
zugewiesenId: dienststelleId, zugewiesenId: dienststelleId,
processedAt: new Date() processedAt: new Date()
} }
}); });
// Plätze in ZeitraumPlaetze reduzieren // Plätze in ZeitraumPlaetze reduzieren
await tx.zeitraumPlaetze.update({ await tx.zeitraumPlaetze.update({
where: { where: {
zeitraumId_dienststelleId: { zeitraumId_dienststelleId: {
zeitraumId: zeitraumId, zeitraumId: zeitraumId,
dienststelleId: dienststelleId dienststelleId: dienststelleId
} }
}, },
data: { data: {
plaetze: { plaetze: {
decrement: 1 decrement: 1
} }
} }
}); });
}); });
return json({ success: true }); return json({ success: true });
} catch (error) { } catch (error) {
console.error('Fehler beim Annehmen der Anmeldung:', error); console.error('Fehler beim Annehmen der Anmeldung:', error);
// Spezifische Fehlermeldung für "keine Plätze" // Spezifische Fehlermeldung für "keine Plätze"
if ((error as Error).message === 'Keine freien Plätze mehr verfügbar') { if ((error as Error).message === 'Keine freien Plätze mehr verfügbar') {
return json({ return json(
error: 'Keine freien Plätze mehr für diese Dienststelle in diesem Zeitraum verfügbar.' {
}, { status: 400 }); 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 }); );
} }
return json(
{ error: 'Fehler beim Annehmen der Anmeldung', details: (error as Error).message },
{ status: 500 }
);
}
} }
export async function PATCH({ request, url }) { export async function PATCH({ request, url }) {
try { try {
const id = parseInt(url.searchParams.get('id') || '0'); const id = parseInt(url.searchParams.get('id') || '0');
const { action } = await request.json(); const { action } = await request.json();
if (!id) { if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 }); return json({ error: 'ID erforderlich' }, { status: 400 });
} }
let updateData = {}; let updateData = {};
switch (action) { switch (action) {
case 'reject': case 'reject':
updateData = { updateData = {
status: 'ABGELEHNT', status: 'ABGELEHNT',
processedAt: new Date() processedAt: new Date()
}; };
break; break;
case 'set_processing': case 'set_processing':
const anmeldung = await prisma.anmeldung.findUnique({ const anmeldung = await prisma.anmeldung.findUnique({
where: { id } where: { id }
}); });
if (!anmeldung) { if (!anmeldung) {
return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 }); return json({ error: 'Anmeldung nicht gefunden' }, { status: 404 });
} }
updateData = { updateData = {
status: 'BEARBEITUNG', status: 'BEARBEITUNG',
processedAt: new Date() processedAt: new Date()
}; };
break; break;
case 'reset_processing': case 'reset_processing':
updateData = { updateData = {
status: 'OFFEN', status: 'OFFEN',
processedAt: null processedAt: null
}; };
break; break;
default: default:
return json({ error: 'Unbekannte Aktion' }, { status: 400 }); return json({ error: 'Unbekannte Aktion' }, { status: 400 });
} }
await prisma.anmeldung.update({ await prisma.anmeldung.update({
where: { id }, where: { id },
data: updateData data: updateData
}); });
return json({ success: true }); return json({ success: true });
} catch (error) { } catch (error) {
console.error('Fehler beim Aktualisieren der Anmeldung:', error); console.error('Fehler beim Aktualisieren der Anmeldung:', error);
return json({ error: 'Fehler beim Aktualisieren der Anmeldung', details: (error as Error).message }, { status: 500 }); return json(
} { error: 'Fehler beim Aktualisieren der Anmeldung', details: (error as Error).message },
{ status: 500 }
);
}
} }
export async function DELETE({ url }) { export async function DELETE({ url }) {
try { try {
const id = parseInt(url.searchParams.get('id') || '0'); const id = parseInt(url.searchParams.get('id') || '0');
if (!id) { if (!id) {
return json({ error: 'ID erforderlich' }, { status: 400 }); return json({ error: 'ID erforderlich' }, { status: 400 });
} }
// Einfach löschen - Plätze werden NICHT zurückgegeben (gewolltes Verhalten) // Einfach löschen - Plätze werden NICHT zurückgegeben (gewolltes Verhalten)
await prisma.anmeldung.delete({ await prisma.anmeldung.delete({
where: { id } where: { id }
}); });
return json({ success: true }); 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: 'Fehler beim Löschen der Anmeldung', details: (error as Error).message }, { status: 500 }); return json(
} { error: 'Fehler beim Löschen der Anmeldung', details: (error as Error).message },
{ status: 500 }
);
}
} }
// Hilfsfunktion: Prisma Status zu Frontend Status // Hilfsfunktion: Prisma Status zu Frontend Status
function mapPrismaStatusToFrontend(prismaStatus: string): string { function mapPrismaStatusToFrontend(prismaStatus: string): string {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
'OFFEN': 'pending', OFFEN: 'pending',
'BEARBEITUNG': 'processing', BEARBEITUNG: 'processing',
'ANGENOMMEN': 'accepted', ANGENOMMEN: 'accepted',
'ABGELEHNT': 'rejected' ABGELEHNT: 'rejected'
}; };
return statusMap[prismaStatus] || 'pending'; return statusMap[prismaStatus] || 'pending';
} }

View File

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

View File

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

View File

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