excel export und Plätze vorhanden Prüfung

This commit is contained in:
titver968
2025-12-08 12:35:25 +01:00
parent b240c7ab12
commit 07824f2b6a
8 changed files with 1532 additions and 82 deletions

949
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -48,6 +48,7 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.19.0", "@prisma/client": "^6.19.0",
"@sveltejs/adapter-node": "^5.2.13", "@sveltejs/adapter-node": "^5.2.13",
"bcryptjs": "^3.0.2" "bcryptjs": "^3.0.2",
"exceljs": "^4.4.0"
} }
} }

Binary file not shown.

View File

@@ -23,7 +23,8 @@
let fehler = ''; let fehler = '';
let success = false; let success = false;
let dienststellen: any[]; let dienststellen: any[] = [];
let isLoadingDienststellen = false;
let fileInputKey = 0; let fileInputKey = 0;
let noteDeutsch = ''; let noteDeutsch = '';
@@ -122,6 +123,34 @@
// Prüfen ob Formular gültig ist // Prüfen ob Formular gültig ist
$: formHatFehler = alterFehler !== '' || notenFehler !== '' || sozialverhaltenFehler !== ''; $: formHatFehler = alterFehler !== '' || notenFehler !== '' || sozialverhaltenFehler !== '';
// Dienststellen laden wenn Zeitraum sich ändert
async function ladeDienststellen(zeitraumId: string) {
if (!zeitraumId) {
dienststellen = [];
return;
}
try {
isLoadingDienststellen = true;
const res = await fetch(`/api/dienststellen?zeitraumId=${zeitraumId}`);
dienststellen = await res.json();
} catch (err) {
console.error('Fehler beim Laden der Dienststellen:', err);
dienststellen = [];
} finally {
isLoadingDienststellen = false;
}
}
// Reaktiv: Wenn Zeitraum sich ändert, Dienststellen neu laden und Wünsche zurücksetzen
$: if (zeitraum) {
ladeDienststellen(zeitraum);
wunsch1Id = '';
wunsch2Id = '';
wunsch3Id = '';
}
// Filter: Nur Dienststellen mit freien Plätzen und Alterscheck für PK Mitte
$: filteredDienststellen = (dienststellen ?? []).filter(d => { $: filteredDienststellen = (dienststellen ?? []).filter(d => {
if (d.plaetze <= 0) return false; if (d.plaetze <= 0) return false;
@@ -144,9 +173,6 @@
["Gymnasium", "KGS_Gymnasialzweig", "Fachoberschule"].includes(schulart); ["Gymnasium", "KGS_Gymnasialzweig", "Fachoberschule"].includes(schulart);
onMount(async () => { onMount(async () => {
const resDienstelle = await fetch('/api/dienststellen');
dienststellen = await resDienstelle.json();
const resZeitraeume = await fetch('/api/zeitraeume'); const resZeitraeume = await fetch('/api/zeitraeume');
zeitraeume = await resZeitraeume.json(); zeitraeume = await resZeitraeume.json();
}); });
@@ -188,6 +214,7 @@
alterFehler = ''; alterFehler = '';
notenFehler = ''; notenFehler = '';
sozialverhaltenFehler = ''; sozialverhaltenFehler = '';
dienststellen = [];
} }
async function anmelden() { async function anmelden() {
@@ -352,6 +379,7 @@
</div> </div>
</div> </div>
<!-- Zeitraum-Auswahl -->
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<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>
@@ -373,28 +401,54 @@
</div> </div>
{/if} {/if}
<!-- Wunschdienststellen --> <!-- Wunschdienststellen - erst auswählbar wenn Zeitraum gewählt -->
<div class="grid grid-cols-1 gap-4"> <div class="grid grid-cols-1 gap-4">
<select bind:value={wunsch1Id} required> {#if isLoadingDienststellen}
<option value="" disabled selected>1. Wunschdienststelle</option> <div class="flex items-center justify-center py-4 text-gray-500">
<svg class="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Lade verfügbare Dienststellen...
</div>
{:else}
<select bind:value={wunsch1Id} required disabled={!zeitraum} class="input" class:opacity-50={!zeitraum}>
<option value="" disabled selected>
{zeitraum ? '1. Wunschdienststelle' : 'Bitte zuerst Zeitraum wählen'}
</option>
{#each filteredDienststellen as d} {#each filteredDienststellen as d}
<option value={d.id}>{d.name}</option> <option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
{/each} {/each}
</select> </select>
<select bind:value={wunsch2Id} required> <select bind:value={wunsch2Id} required disabled={!zeitraum || !wunsch1Id} class="input" class:opacity-50={!zeitraum || !wunsch1Id}>
<option value="" disabled selected>2. Wunschdienststelle</option> <option value="" disabled selected>2. Wunschdienststelle</option>
{#each filteredDienststellen.filter(d => d.id !== wunsch1Id) as d} {#each filteredDienststellen.filter(d => d.id != wunsch1Id) as d}
<option value={d.id}>{d.name}</option> <option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
{/each} {/each}
</select> </select>
<select bind:value={wunsch3Id} required> <select bind:value={wunsch3Id} required disabled={!zeitraum || !wunsch2Id} class="input" class:opacity-50={!zeitraum || !wunsch2Id}>
<option value="" disabled selected>3. Wunschdienststelle</option> <option value="" disabled selected>3. Wunschdienststelle</option>
{#each filteredDienststellen.filter(d => d.id !== wunsch1Id && d.id !== wunsch2Id) as d} {#each filteredDienststellen.filter(d => d.id != wunsch1Id && d.id != wunsch2Id) as d}
<option value={d.id}>{d.name}</option> <option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
{/each} {/each}
</select> </select>
{/if}
<!-- Hinweis wenn keine Dienststellen verfügbar -->
{#if zeitraum && !isLoadingDienststellen && filteredDienststellen.length === 0}
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded-xl">
<div class="flex items-start">
<svg class="w-5 h-5 text-yellow-500 mr-2 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
<p class="text-sm text-yellow-700">
Für den gewählten Zeitraum sind leider keine Praktikumsplätze mehr verfügbar. Bitte wähle einen anderen Zeitraum.
</p>
</div>
</div>
{/if}
</div> </div>
<!-- Motivation --> <!-- Motivation -->
@@ -431,16 +485,16 @@
</div> </div>
{/if} {/if}
<!-- Button - deaktiviert bei Validierungsfehlern --> <!-- Button - deaktiviert bei Validierungsfehlern oder fehlenden Pflichtfeldern -->
<button <button
type="submit" type="submit"
disabled={formHatFehler} 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} class:bg-blue-600={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
class:hover:bg-blue-700={!formHatFehler} class:hover:bg-blue-700={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
class:text-white={!formHatFehler} class:text-white={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
class:bg-gray-400={formHatFehler} class:bg-gray-400={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
class:cursor-not-allowed={formHatFehler} class:cursor-not-allowed={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
> >
Jetzt anmelden Jetzt anmelden
</button> </button>
@@ -483,6 +537,10 @@
box-shadow: 0 0 0 2px #3b82f6; box-shadow: 0 0 0 2px #3b82f6;
border-color: #3b82f6; border-color: #3b82f6;
} }
.input:disabled {
background-color: #f3f4f6;
cursor: not-allowed;
}
.input-error { .input-error {
border-color: #dc2626; border-color: #dc2626;
background-color: #fef2f2; background-color: #fef2f2;

View File

@@ -18,6 +18,7 @@
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 };
zeitraum?: { id: number; bezeichnung: string };
timestamp: number; timestamp: number;
id: number; id: number;
status?: 'pending' | 'accepted' | 'rejected'; status?: 'pending' | 'accepted' | 'rejected';
@@ -26,12 +27,20 @@
processedAt?: number; processedAt?: number;
} }
interface Zeitraum {
id: number;
bezeichnung: string;
startDatum: string;
endDatum: string;
}
interface EmailConfig { interface EmailConfig {
subject: string; subject: string;
template: string; template: string;
} }
let anmeldungen: Anmeldung[] = []; let anmeldungen: Anmeldung[] = [];
let zeitraeume: Zeitraum[] = [];
let isLoading = true; let isLoading = true;
let error = ''; let error = '';
@@ -72,6 +81,11 @@ Ihr Praktikumsteam`;
} | null = null; } | null = null;
let emailCopied = false; let emailCopied = false;
// Export Modal State
let showExportModal = false;
let selectedExportZeitraum = '';
let isExporting = false;
// Status-Badge Funktionen // Status-Badge Funktionen
function getStatusColor(status: string): string { function getStatusColor(status: string): string {
switch (status) { switch (status) {
@@ -104,6 +118,14 @@ Ihr Praktikumsteam`;
anmeldungen, statusFilter, filterAnmeldungen(); anmeldungen, statusFilter, filterAnmeldungen();
} }
// Zähle angenommene Anmeldungen pro Zeitraum
function getAcceptedCountForZeitraum(zeitraumId: number): number {
return anmeldungen.filter(a =>
a.status === 'accepted' &&
a.zeitraum?.id === zeitraumId
).length;
}
async function loadAnmeldungen() { async function loadAnmeldungen() {
try { try {
isLoading = true; isLoading = true;
@@ -136,6 +158,17 @@ Ihr Praktikumsteam`;
} }
} }
async function loadZeitraeume() {
try {
const res = await fetch('/api/zeitraeume');
if (res.ok) {
zeitraeume = await res.json();
}
} catch (err) {
console.error('Fehler beim Laden der Zeiträume:', err);
}
}
async function loadEmailConfig() { async function loadEmailConfig() {
try { try {
isLoadingEmailConfig = true; isLoadingEmailConfig = true;
@@ -209,8 +242,8 @@ Ihr Praktikumsteam`;
}); });
if (!res.ok) { if (!res.ok) {
const errorText = await res.text(); const errorData = await res.json();
throw new Error(`Fehler beim Annehmen (${res.status}): ${errorText}`); throw new Error(errorData.error || `Fehler beim Annehmen (${res.status})`);
} }
showDialog = false; showDialog = false;
@@ -282,6 +315,64 @@ Ihr Praktikumsteam`;
emailCopied = false; emailCopied = false;
} }
// Export-Funktionen
function openExportModal() {
selectedExportZeitraum = '';
showExportModal = true;
}
function closeExportModal() {
showExportModal = false;
selectedExportZeitraum = '';
}
async function exportToExcel() {
if (!selectedExportZeitraum) {
error = 'Bitte wählen Sie einen Zeitraum aus.';
return;
}
try {
isExporting = true;
error = '';
const res = await fetch(`/api/admin/export?zeitraumId=${selectedExportZeitraum}`);
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Fehler beim Exportieren');
}
// Download auslösen
const blob = await res.blob();
const contentDisposition = res.headers.get('Content-Disposition');
let filename = 'export.xlsx';
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
filename = match[1];
}
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
closeExportModal();
} catch (err) {
error = err instanceof Error ? err.message : 'Fehler beim Exportieren';
console.error(err);
} finally {
isExporting = false;
}
}
async function handleReject(event: CustomEvent<{id: number}>) { async function handleReject(event: CustomEvent<{id: number}>) {
if (!confirm('Diese Anmeldung wirklich ablehnen?')) return; if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
@@ -329,6 +420,7 @@ Ihr Praktikumsteam`;
onMount(() => { onMount(() => {
loadAnmeldungen(); loadAnmeldungen();
loadZeitraeume();
loadEmailConfig(); loadEmailConfig();
}); });
</script> </script>
@@ -344,8 +436,8 @@ Ihr Praktikumsteam`;
/> />
<main class="max-w-7xl mx-auto px-4 py-6"> <main class="max-w-7xl mx-auto px-4 py-6">
<!-- Filter und E-Mail Konfiguration --> <!-- Filter, Export und E-Mail Konfiguration -->
<div class="mb-6 flex justify-between items-center"> <div class="mb-6 flex flex-wrap justify-between items-center gap-4">
<!-- Status Filter --> <!-- Status Filter -->
<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>
@@ -361,6 +453,20 @@ Ihr Praktikumsteam`;
</select> </select>
</div> </div>
<!-- Buttons -->
<div class="flex items-center space-x-3">
<!-- Excel Export Button -->
<button
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"
disabled={anmeldungen.filter(a => a.status === 'accepted').length === 0}
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Excel-Export
</button>
<!-- E-Mail Konfiguration Button --> <!-- E-Mail Konfiguration Button -->
<button <button
on:click={() => showEmailConfig = !showEmailConfig} on:click={() => showEmailConfig = !showEmailConfig}
@@ -374,9 +480,10 @@ Ihr Praktikumsteam`;
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg> </svg>
{/if} {/if}
E-Mail-Vorlage konfigurieren E-Mail-Vorlage
</button> </button>
</div> </div>
</div>
<!-- Status Übersicht --> <!-- Status Übersicht -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
@@ -507,6 +614,14 @@ 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
on:click={() => error = ''}
class="ml-auto text-red-400 hover:text-red-600"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -555,6 +670,97 @@ Ihr Praktikumsteam`;
/> />
{/if} {/if}
<!-- Export Modal -->
{#if showExportModal}
<div class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-screen items-center justify-center p-4">
<!-- Backdrop -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
on:click={closeExportModal}
></div>
<!-- Modal -->
<div class="relative bg-white rounded-lg shadow-xl max-w-md w-full">
<!-- Header -->
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center">
<div class="flex items-center space-x-3">
<div class="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 class="text-lg font-medium text-gray-900">Excel-Export</h3>
</div>
<button
on:click={closeExportModal}
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Content -->
<div class="px-6 py-4">
<p class="text-sm text-gray-600 mb-4">
Wählen Sie einen Praktikumszeitraum aus, um alle angenommenen Anmeldungen als Excel-Datei zu exportieren.
</p>
<div>
<label for="export-zeitraum" class="block text-sm font-medium text-gray-700 mb-2">
Praktikumszeitraum
</label>
<select
id="export-zeitraum"
bind:value={selectedExportZeitraum}
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-green-500 focus:border-green-500"
disabled={isExporting}
>
<option value="" disabled>Zeitraum auswählen...</option>
{#each zeitraeume as z}
{@const count = getAcceptedCountForZeitraum(z.id)}
<option value={z.id} disabled={count === 0}>
{z.bezeichnung} ({count} angenommen)
</option>
{/each}
</select>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 border-t border-gray-200 bg-gray-50 flex justify-end space-x-3">
<button
on:click={closeExportModal}
class="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
disabled={isExporting}
>
Abbrechen
</button>
<button
on:click={exportToExcel}
class="bg-green-600 hover:bg-green-700 text-white px-5 py-2 rounded-md text-sm font-medium inline-flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!selectedExportZeitraum || isExporting}
>
{#if isExporting}
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Exportiere...
{:else}
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Exportieren
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- E-Mail Vorschau Modal --> <!-- E-Mail Vorschau Modal -->
{#if showEmailPreview && emailPreviewData} {#if showEmailPreview && emailPreviewData}
<div class="fixed inset-0 z-50 overflow-y-auto"> <div class="fixed inset-0 z-50 overflow-y-auto">

View File

@@ -30,7 +30,6 @@ export async function GET() {
nachname: anmeldung.nachname, nachname: anmeldung.nachname,
email: anmeldung.email, email: anmeldung.email,
// === FEHLENDE FELDER HINZUGEFÜGT ===
geburtsdatum: anmeldung.geburtsdatum, geburtsdatum: anmeldung.geburtsdatum,
strasse: anmeldung.strasse, strasse: anmeldung.strasse,
hausnummer: anmeldung.hausnummer, hausnummer: anmeldung.hausnummer,
@@ -41,18 +40,14 @@ export async function GET() {
schulklasse: anmeldung.schulklasse, schulklasse: anmeldung.schulklasse,
motivation: anmeldung.motivation, motivation: anmeldung.motivation,
alter: anmeldung.alter, alter: anmeldung.alter,
// === ENDE FEHLENDE FELDER ===
// Noten als String konvertieren (falls sie als Int gespeichert sind)
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-Mapping für Frontend
status: mapPrismaStatusToFrontend(anmeldung.status), status: mapPrismaStatusToFrontend(anmeldung.status),
processedAt: anmeldung.processedAt ? anmeldung.processedAt.getTime() : undefined, processedAt: anmeldung.processedAt ? anmeldung.processedAt.getTime() : undefined,
// Wünsche - sicherstellen dass sie existieren
wunsch1: anmeldung.wunsch1 ? { wunsch1: anmeldung.wunsch1 ? {
id: anmeldung.wunsch1.id, id: anmeldung.wunsch1.id,
name: anmeldung.wunsch1.name name: anmeldung.wunsch1.name
@@ -66,13 +61,11 @@ export async function GET() {
name: anmeldung.wunsch3.name name: anmeldung.wunsch3.name
} : undefined, } : undefined,
// Zugewiesene Dienststelle
assignedDienststelle: anmeldung.zugewiesen ? { assignedDienststelle: anmeldung.zugewiesen ? {
id: anmeldung.zugewiesen.id, id: anmeldung.zugewiesen.id,
name: anmeldung.zugewiesen.name name: anmeldung.zugewiesen.name
} : undefined, } : undefined,
// Praktikumszeitraum
zeitraum: anmeldung.praktikum ? { zeitraum: anmeldung.praktikum ? {
id: anmeldung.praktikum.id, id: anmeldung.praktikum.id,
bezeichnung: anmeldung.praktikum.bezeichnung, bezeichnung: anmeldung.praktikum.bezeichnung,
@@ -80,17 +73,15 @@ export async function GET() {
endDatum: anmeldung.praktikum.endDatum.toISOString() endDatum: anmeldung.praktikum.endDatum.toISOString()
} : undefined, } : undefined,
// Timestamp
timestamp: anmeldung.timestamp ? anmeldung.timestamp.getTime() : Date.now(), timestamp: anmeldung.timestamp ? anmeldung.timestamp.getTime() : Date.now(),
// PDFs
pdfs: anmeldung.pdfs || [] pdfs: anmeldung.pdfs || []
})); }));
return json(formattedAnmeldungen); return json(formattedAnmeldungen);
} catch (error) { } catch (error) {
console.error('Fehler beim Laden der Anmeldungen:', error); console.error('Fehler beim Laden der Anmeldungen:', error);
return json({ error: 'Fehler beim Laden der Anmeldungen', details: error.message }, { status: 500 }); return json({ error: 'Fehler beim Laden der Anmeldungen', details: (error as Error).message }, { status: 500 });
} }
} }
@@ -125,7 +116,7 @@ export async function POST({ request, url }) {
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 // 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: {
@@ -141,26 +132,42 @@ export async function POST({ request, url }) {
}, { status: 400 }); }, { status: 400 });
} }
// 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' error: 'Keine freien Plätze mehr für diese Dienststelle in diesem Zeitraum verfügbar.'
}, { status: 400 }); }, { status: 400 });
} }
// Anmeldung als angenommen markieren und Plätze in ZeitraumPlaetze reduzieren // Anmeldung als angenommen markieren und Plätze in ZeitraumPlaetze reduzieren
await prisma.$transaction([ // Verwendung von $transaction für Atomarität (Race Condition vermeiden)
await prisma.$transaction(async (tx) => {
// Nochmal prüfen innerhalb der Transaktion
const aktuellerStand = await tx.zeitraumPlaetze.findUnique({
where: {
zeitraumId_dienststelleId: {
zeitraumId: zeitraumId,
dienststelleId: dienststelleId
}
}
});
if (!aktuellerStand || aktuellerStand.plaetze <= 0) {
throw new Error('Keine freien Plätze mehr verfügbar');
}
// Anmeldung aktualisieren // Anmeldung aktualisieren
prisma.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
prisma.zeitraumPlaetze.update({ await tx.zeitraumPlaetze.update({
where: { where: {
zeitraumId_dienststelleId: { zeitraumId_dienststelleId: {
zeitraumId: zeitraumId, zeitraumId: zeitraumId,
@@ -172,13 +179,21 @@ export async function POST({ request, url }) {
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);
return json({ error: 'Fehler beim Annehmen der Anmeldung', details: error.message }, { status: 500 });
// Spezifische Fehlermeldung für "keine Plätze"
if ((error as Error).message === 'Keine freien Plätze mehr verfügbar') {
return json({
error: 'Keine freien Plätze mehr für diese Dienststelle in diesem Zeitraum verfügbar.'
}, { status: 400 });
}
return json({ error: 'Fehler beim Annehmen der Anmeldung', details: (error as Error).message }, { status: 500 });
} }
} }
@@ -235,7 +250,7 @@ export async function PATCH({ request, url }) {
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.message }, { status: 500 }); return json({ error: 'Fehler beim Aktualisieren der Anmeldung', details: (error as Error).message }, { status: 500 });
} }
} }
@@ -247,6 +262,7 @@ export async function DELETE({ url }) {
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)
await prisma.anmeldung.delete({ await prisma.anmeldung.delete({
where: { id } where: { id }
}); });
@@ -254,13 +270,13 @@ export async function DELETE({ url }) {
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.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) { function mapPrismaStatusToFrontend(prismaStatus: string): string {
const statusMap = { const statusMap: Record<string, string> = {
'OFFEN': 'pending', 'OFFEN': 'pending',
'BEARBEITUNG': 'processing', 'BEARBEITUNG': 'processing',
'ANGENOMMEN': 'accepted', 'ANGENOMMEN': 'accepted',

View File

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

View File

@@ -1,3 +1,4 @@
// src/routes/api/dienststellen/+server.ts
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -10,8 +11,60 @@ async function getPrismaClient() {
return prismaPromise; return prismaPromise;
} }
export const GET: RequestHandler = async () => { export const GET: RequestHandler = async ({ url }) => {
const prisma = await getPrismaClient(); // Hier Prisma Client holen const prisma = await getPrismaClient();
const dienststellen = await prisma.dienststelle.findMany({ orderBy: { name: 'asc' } }); const zeitraumId = url.searchParams.get('zeitraumId');
if (zeitraumId) {
// Dienststellen mit freien Plätzen für einen bestimmten Zeitraum
const zeitraumIdInt = parseInt(zeitraumId);
const dienststellen = await prisma.dienststelle.findMany({
orderBy: { name: 'asc' },
include: {
zeitraumPlaetze: {
where: { zeitraumId: zeitraumIdInt }
},
_count: {
select: {
zugewiesene: {
where: {
praktikumId: zeitraumIdInt,
status: 'ANGENOMMEN'
}
}
}
}
}
});
// Berechne freie Plätze pro Dienststelle
const result = dienststellen.map(d => {
const gesamtPlaetze = d.zeitraumPlaetze[0]?.plaetze ?? 0;
const belegtePlaetze = d._count.zugewiesene;
const freiePlaetze = Math.max(0, gesamtPlaetze - belegtePlaetze);
return {
id: d.id,
name: d.name,
plaetze: freiePlaetze,
gesamtPlaetze: gesamtPlaetze,
belegtePlaetze: belegtePlaetze
};
});
return json(result);
}
// Fallback: Alle Dienststellen ohne Zeitraum-Filter (für Admin-Bereich etc.)
const dienststellen = await prisma.dienststelle.findMany({
orderBy: { name: 'asc' },
select: {
id: true,
name: true,
plaetze: true
}
});
return json(dienststellen); return json(dienststellen);
}; };