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

@@ -30,7 +30,7 @@ model Dienststelle {
anmeldungenWunsch3 Anmeldung[] @relation("Wunsch3") anmeldungenWunsch3 Anmeldung[] @relation("Wunsch3")
zugewiesene Anmeldung[] @relation("Zugewiesen") zugewiesene Anmeldung[] @relation("Zugewiesen")
zeitraumPlaetze ZeitraumPlaetze[] zeitraumPlaetze ZeitraumPlaetze[]
} }
model Praktikumszeitraum { model Praktikumszeitraum {
@@ -44,13 +44,13 @@ model Praktikumszeitraum {
} }
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])
@@ -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,24 +84,30 @@ model Anmeldung {
sozialverhalten String? sozialverhalten String?
motivation String? motivation String?
alter Int? alter Int?
status Status @default(OFFEN)
processedAt DateTime? // Notfallkontakt
notfallVorname String?
notfallNachname String?
notfallTelefon String?
praktikumId Int? status Status @default(OFFEN)
praktikum Praktikumszeitraum? @relation("PraktikumszeitraumAnmeldungen", fields: [praktikumId], references: [id])
zugewiesenId Int? processedAt DateTime?
zugewiesen Dienststelle? @relation("Zugewiesen", fields: [zugewiesenId], references: [id])
wunsch1Id Int?
wunsch1 Dienststelle? @relation("Wunsch1", fields: [wunsch1Id], references: [id])
wunsch2Id Int?
wunsch2 Dienststelle? @relation("Wunsch2", fields: [wunsch2Id], references: [id])
wunsch3Id Int?
wunsch3 Dienststelle? @relation("Wunsch3", fields: [wunsch3Id], references: [id])
timestamp DateTime @default(now()) praktikumId Int?
pdfs PdfDatei[] praktikum Praktikumszeitraum? @relation("PraktikumszeitraumAnmeldungen", fields: [praktikumId], references: [id])
zugewiesenId Int?
zugewiesen Dienststelle? @relation("Zugewiesen", fields: [zugewiesenId], references: [id])
wunsch1Id Int?
wunsch1 Dienststelle? @relation("Wunsch1", fields: [wunsch1Id], references: [id])
wunsch2Id Int?
wunsch2 Dienststelle? @relation("Wunsch2", fields: [wunsch2Id], references: [id])
wunsch3Id Int?
wunsch3 Dienststelle? @relation("Wunsch3", fields: [wunsch3Id], references: [id])
timestamp DateTime @default(now())
pdfs PdfDatei[]
@@index([status]) @@index([status])
@@index([processedAt]) @@index([processedAt])

View File

@@ -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 = '';
@@ -42,9 +47,9 @@
// 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
$: { $: {
@@ -169,8 +174,8 @@
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);
@@ -269,7 +280,7 @@
<div class="min-h-screen bg-gray-100 flex items-center justify-center p-6"> <div class="min-h-screen bg-gray-100 flex items-center justify-center p-6">
<form on:submit|preventDefault={anmelden} <form on:submit|preventDefault={anmelden}
class="bg-white shadow-md rounded-2xl p-8 max-w-2xl w-full space-y-4"> class="bg-white shadow-md rounded-2xl p-8 max-w-2xl w-full space-y-4">
<h1 class="text-2xl font-bold text-gray-800 mb-4 text-center">Praktikumsanmeldung</h1> <h1 class="text-2xl font-bold text-gray-800 mb-4 text-center">Praktikumsanmeldung</h1>
@@ -319,10 +330,10 @@
{#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>
@@ -353,24 +364,24 @@
<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}
@@ -385,7 +396,7 @@
<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>
@@ -487,14 +508,14 @@
<!-- 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

@@ -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 };
@@ -121,8 +124,8 @@ 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;
} }
@@ -266,15 +269,15 @@ 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 = {
@@ -431,8 +434,8 @@ Ihr Praktikumsteam`;
<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>
@@ -553,11 +556,11 @@ Ihr Praktikumsteam`;
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>
@@ -566,11 +569,11 @@ Ihr Praktikumsteam`;
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;
@@ -579,16 +582,16 @@ Ihr Praktikumsteam`;
<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>
@@ -615,8 +618,8 @@ Ihr Praktikumsteam`;
<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" />
@@ -641,19 +644,19 @@ Ihr Praktikumsteam`;
</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}
@@ -678,8 +681,8 @@ Ihr Praktikumsteam`;
<!-- 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 -->
@@ -695,8 +698,8 @@ Ihr Praktikumsteam`;
<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" />
@@ -715,10 +718,10 @@ Ihr Praktikumsteam`;
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}
@@ -734,16 +737,16 @@ Ihr Praktikumsteam`;
<!-- 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>
@@ -769,8 +772,8 @@ Ihr Praktikumsteam`;
<!-- 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 -->
@@ -786,8 +789,8 @@ Ihr Praktikumsteam`;
<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" />
@@ -817,11 +820,11 @@ Ihr Praktikumsteam`;
<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>
@@ -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, geburtsdatum: anmeldung.geburtsdatum,
strasse: anmeldung.strasse, strasse: anmeldung.strasse,
hausnummer: anmeldung.hausnummer, hausnummer: anmeldung.hausnummer,
ort: anmeldung.ort, ort: anmeldung.ort,
plz: anmeldung.plz, plz: anmeldung.plz,
telefon: anmeldung.telefon, telefon: anmeldung.telefon,
schulart: anmeldung.schulart, schulart: anmeldung.schulart,
schulklasse: anmeldung.schulklasse, schulklasse: anmeldung.schulklasse,
motivation: anmeldung.motivation, motivation: anmeldung.motivation,
alter: anmeldung.alter, alter: anmeldung.alter,
noteDeutsch: anmeldung.noteDeutsch ? anmeldung.noteDeutsch.toString() : undefined, noteDeutsch: anmeldung.noteDeutsch ? anmeldung.noteDeutsch.toString() : undefined,
noteMathe: anmeldung.noteMathe ? anmeldung.noteMathe.toString() : undefined, noteMathe: anmeldung.noteMathe ? anmeldung.noteMathe.toString() : undefined,
sozialverhalten: anmeldung.sozialverhalten, sozialverhalten: anmeldung.sozialverhalten,
status: mapPrismaStatusToFrontend(anmeldung.status), // Notfallkontakt
processedAt: anmeldung.processedAt ? anmeldung.processedAt.getTime() : undefined, notfallVorname: anmeldung.notfallVorname,
notfallNachname: anmeldung.notfallNachname,
notfallTelefon: anmeldung.notfallTelefon,
wunsch1: anmeldung.wunsch1 ? { status: mapPrismaStatusToFrontend(anmeldung.status),
id: anmeldung.wunsch1.id, processedAt: anmeldung.processedAt ? anmeldung.processedAt.getTime() : undefined,
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 ? { wunsch1: anmeldung.wunsch1
id: anmeldung.zugewiesen.id, ? {
name: anmeldung.zugewiesen.name id: anmeldung.wunsch1.id,
} : undefined, 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,
zeitraum: anmeldung.praktikum ? { assignedDienststelle: anmeldung.zugewiesen
id: anmeldung.praktikum.id, ? {
bezeichnung: anmeldung.praktikum.bezeichnung, id: anmeldung.zugewiesen.id,
startDatum: anmeldung.praktikum.startDatum.toISOString(), name: anmeldung.zugewiesen.name
endDatum: anmeldung.praktikum.endDatum.toISOString() }
} : undefined, : undefined,
timestamp: anmeldung.timestamp ? anmeldung.timestamp.getTime() : Date.now(), zeitraum: anmeldung.praktikum
? {
id: anmeldung.praktikum.id,
bezeichnung: anmeldung.praktikum.bezeichnung,
startDatum: anmeldung.praktikum.startDatum.toISOString(),
endDatum: anmeldung.praktikum.endDatum.toISOString()
}
: undefined,
pdfs: anmeldung.pdfs || [] timestamp: anmeldung.timestamp ? anmeldung.timestamp.getTime() : Date.now(),
}));
return json(formattedAnmeldungen); pdfs: anmeldung.pdfs || []
} 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 }); 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,
vorname: anmeldung.vorname,
nachname: anmeldung.nachname,
geburtsdatum: anmeldung.geburtsdatum,
alter: anmeldung.alter,
strasse: anmeldung.strasse,
hausnummer: anmeldung.hausnummer,
plz: anmeldung.plz,
ort: anmeldung.ort,
telefon: anmeldung.telefon,
email: anmeldung.email,
schulart: formatSchulart(anmeldung.schulart),
schulklasse: anmeldung.schulklasse,
noteDeutsch: anmeldung.noteDeutsch,
noteMathe: anmeldung.noteMathe,
sozialverhalten: anmeldung.sozialverhalten || '-',
notfallVorname: anmeldung.notfallVorname || '-',
notfallNachname: anmeldung.notfallNachname || '-',
notfallTelefon: anmeldung.notfallTelefon || '-',
processedAt: anmeldung.processedAt
? new Date(anmeldung.processedAt).toLocaleDateString('de-DE')
: '-'
});
});
// Abwechselnde Zeilenfarben // Datenzeilen formatieren
if (i % 2 === 0) { for (let i = 2; i <= anmeldungen.length + 1; i++) {
row.fill = { const row = worksheet.getRow(i);
type: 'pattern', row.alignment = { vertical: 'middle' };
pattern: 'solid',
fgColor: { argb: 'FFF2F2F2' }
};
}
}
// Rahmen für alle Zellen // Abwechselnde Zeilenfarben
worksheet.eachRow((row, rowNumber) => { if (i % 2 === 0) {
row.eachCell((cell) => { row.fill = {
cell.border = { type: 'pattern',
top: { style: 'thin', color: { argb: 'FFD0D0D0' } }, pattern: 'solid',
left: { style: 'thin', color: { argb: 'FFD0D0D0' } }, fgColor: { argb: 'FFF2F2F2' }
bottom: { style: 'thin', color: { argb: 'FFD0D0D0' } }, };
right: { style: 'thin', color: { argb: 'FFD0D0D0' } } }
}; }
});
});
// Filter aktivieren // Rahmen für alle Zellen
worksheet.autoFilter = { worksheet.eachRow((row, rowNumber) => {
from: 'A1', row.eachCell((cell) => {
to: `R${anmeldungen.length + 1}` cell.border = {
}; top: { style: 'thin', color: { argb: 'FFD0D0D0' } },
left: { style: 'thin', color: { argb: 'FFD0D0D0' } },
bottom: { style: 'thin', color: { argb: 'FFD0D0D0' } },
right: { style: 'thin', color: { argb: 'FFD0D0D0' } }
};
});
});
// Zusammenfassung am Ende // Filter aktivieren (angepasst auf neue Spaltenanzahl: A bis U = 21 Spalten)
const summaryRow = worksheet.addRow([]); worksheet.autoFilter = {
const totalRow = worksheet.addRow([ from: 'A1',
`Gesamt: ${anmeldungen.length} angenommene Anmeldungen`, to: `U${anmeldungen.length + 1}`
'', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '', '' };
]);
totalRow.font = { bold: true };
// Excel-Datei als Buffer generieren // Zusammenfassung am Ende
const buffer = await workbook.xlsx.writeBuffer(); const summaryRow = worksheet.addRow([]);
const totalRow = worksheet.addRow([
`Gesamt: ${anmeldungen.length} angenommene Anmeldungen`,
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
'',
''
]);
totalRow.font = { bold: true };
// Dateiname generieren // Excel-Datei als Buffer generieren
const dateiname = `Praktikanten_${zeitraum.bezeichnung.replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`; const buffer = await workbook.xlsx.writeBuffer();
// Als Download zurückgeben // Dateiname generieren
return new Response(buffer, { const dateiname = `Praktikanten_${zeitraum.bezeichnung.replace(/[^a-zA-Z0-9]/g, '_')}_${new Date().toISOString().split('T')[0]}.xlsx`;
status: 200,
headers: {
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': `attachment; filename="${dateiname}"`,
'Content-Length': buffer.byteLength.toString()
}
});
} catch (error) { // Als Download zurückgeben
console.error('Fehler beim Exportieren:', error); return new Response(buffer, {
return json({ error: 'Fehler beim Exportieren', details: (error as Error).message }, { status: 500 }); 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 });
} }
} }