excel export und Plätze vorhanden Prüfung
This commit is contained in:
949
package-lock.json
generated
949
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -48,6 +48,7 @@
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.19.0",
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"bcryptjs": "^3.0.2"
|
||||
"bcryptjs": "^3.0.2",
|
||||
"exceljs": "^4.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -23,7 +23,8 @@
|
||||
|
||||
let fehler = '';
|
||||
let success = false;
|
||||
let dienststellen: any[];
|
||||
let dienststellen: any[] = [];
|
||||
let isLoadingDienststellen = false;
|
||||
|
||||
let fileInputKey = 0;
|
||||
let noteDeutsch = '';
|
||||
@@ -122,6 +123,34 @@
|
||||
// Prüfen ob Formular gültig ist
|
||||
$: 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 => {
|
||||
if (d.plaetze <= 0) return false;
|
||||
|
||||
@@ -144,9 +173,6 @@
|
||||
["Gymnasium", "KGS_Gymnasialzweig", "Fachoberschule"].includes(schulart);
|
||||
|
||||
onMount(async () => {
|
||||
const resDienstelle = await fetch('/api/dienststellen');
|
||||
dienststellen = await resDienstelle.json();
|
||||
|
||||
const resZeitraeume = await fetch('/api/zeitraeume');
|
||||
zeitraeume = await resZeitraeume.json();
|
||||
});
|
||||
@@ -188,6 +214,7 @@
|
||||
alterFehler = '';
|
||||
notenFehler = '';
|
||||
sozialverhaltenFehler = '';
|
||||
dienststellen = [];
|
||||
}
|
||||
|
||||
async function anmelden() {
|
||||
@@ -352,6 +379,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zeitraum-Auswahl -->
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<select bind:value={zeitraum} required class="input">
|
||||
<option value="" disabled selected hidden>Wunschzeitraum fürs Praktikum</option>
|
||||
@@ -373,28 +401,54 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Wunschdienststellen -->
|
||||
<!-- Wunschdienststellen - erst auswählbar wenn Zeitraum gewählt -->
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<select bind:value={wunsch1Id} required>
|
||||
<option value="" disabled selected>1. Wunschdienststelle</option>
|
||||
{#each filteredDienststellen as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if isLoadingDienststellen}
|
||||
<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}
|
||||
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select bind:value={wunsch2Id} required>
|
||||
<option value="" disabled selected>2. Wunschdienststelle</option>
|
||||
{#each filteredDienststellen.filter(d => d.id !== wunsch1Id) as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={wunsch2Id} required disabled={!zeitraum || !wunsch1Id} class="input" class:opacity-50={!zeitraum || !wunsch1Id}>
|
||||
<option value="" disabled selected>2. Wunschdienststelle</option>
|
||||
{#each filteredDienststellen.filter(d => d.id != wunsch1Id) as d}
|
||||
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select bind:value={wunsch3Id} required>
|
||||
<option value="" disabled selected>3. Wunschdienststelle</option>
|
||||
{#each filteredDienststellen.filter(d => d.id !== wunsch1Id && d.id !== wunsch2Id) as d}
|
||||
<option value={d.id}>{d.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select bind:value={wunsch3Id} required disabled={!zeitraum || !wunsch2Id} class="input" class:opacity-50={!zeitraum || !wunsch2Id}>
|
||||
<option value="" disabled selected>3. Wunschdienststelle</option>
|
||||
{#each filteredDienststellen.filter(d => d.id != wunsch1Id && d.id != wunsch2Id) as d}
|
||||
<option value={d.id}>{d.name} ({d.plaetze} {d.plaetze === 1 ? 'Platz' : 'Plätze'} frei)</option>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<!-- Motivation -->
|
||||
@@ -431,16 +485,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Button - deaktiviert bei Validierungsfehlern -->
|
||||
<!-- Button - deaktiviert bei Validierungsfehlern oder fehlenden Pflichtfeldern -->
|
||||
<button
|
||||
type="submit"
|
||||
disabled={formHatFehler}
|
||||
disabled={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
|
||||
class="w-full py-3 rounded-xl transition-all"
|
||||
class:bg-blue-600={!formHatFehler}
|
||||
class:hover:bg-blue-700={!formHatFehler}
|
||||
class:text-white={!formHatFehler}
|
||||
class:bg-gray-400={formHatFehler}
|
||||
class:cursor-not-allowed={formHatFehler}
|
||||
class:bg-blue-600={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
|
||||
class:hover:bg-blue-700={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
|
||||
class:text-white={!formHatFehler && zeitraum && wunsch1Id && wunsch2Id && wunsch3Id}
|
||||
class:bg-gray-400={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
|
||||
class:cursor-not-allowed={formHatFehler || !zeitraum || !wunsch1Id || !wunsch2Id || !wunsch3Id}
|
||||
>
|
||||
Jetzt anmelden
|
||||
</button>
|
||||
@@ -483,6 +537,10 @@
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
.input:disabled {
|
||||
background-color: #f3f4f6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.input-error {
|
||||
border-color: #dc2626;
|
||||
background-color: #fef2f2;
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
wunsch1?: { id: number; name: string };
|
||||
wunsch2?: { id: number; name: string };
|
||||
wunsch3?: { id: number; name: string };
|
||||
zeitraum?: { id: number; bezeichnung: string };
|
||||
timestamp: number;
|
||||
id: number;
|
||||
status?: 'pending' | 'accepted' | 'rejected';
|
||||
@@ -26,12 +27,20 @@
|
||||
processedAt?: number;
|
||||
}
|
||||
|
||||
interface Zeitraum {
|
||||
id: number;
|
||||
bezeichnung: string;
|
||||
startDatum: string;
|
||||
endDatum: string;
|
||||
}
|
||||
|
||||
interface EmailConfig {
|
||||
subject: string;
|
||||
template: string;
|
||||
}
|
||||
|
||||
let anmeldungen: Anmeldung[] = [];
|
||||
let zeitraeume: Zeitraum[] = [];
|
||||
let isLoading = true;
|
||||
let error = '';
|
||||
|
||||
@@ -72,6 +81,11 @@ Ihr Praktikumsteam`;
|
||||
} | null = null;
|
||||
let emailCopied = false;
|
||||
|
||||
// Export Modal State
|
||||
let showExportModal = false;
|
||||
let selectedExportZeitraum = '';
|
||||
let isExporting = false;
|
||||
|
||||
// Status-Badge Funktionen
|
||||
function getStatusColor(status: string): string {
|
||||
switch (status) {
|
||||
@@ -104,6 +118,14 @@ Ihr Praktikumsteam`;
|
||||
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() {
|
||||
try {
|
||||
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() {
|
||||
try {
|
||||
isLoadingEmailConfig = true;
|
||||
@@ -209,8 +242,8 @@ Ihr Praktikumsteam`;
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const errorText = await res.text();
|
||||
throw new Error(`Fehler beim Annehmen (${res.status}): ${errorText}`);
|
||||
const errorData = await res.json();
|
||||
throw new Error(errorData.error || `Fehler beim Annehmen (${res.status})`);
|
||||
}
|
||||
|
||||
showDialog = false;
|
||||
@@ -282,6 +315,64 @@ Ihr Praktikumsteam`;
|
||||
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}>) {
|
||||
if (!confirm('Diese Anmeldung wirklich ablehnen?')) return;
|
||||
|
||||
@@ -329,6 +420,7 @@ Ihr Praktikumsteam`;
|
||||
|
||||
onMount(() => {
|
||||
loadAnmeldungen();
|
||||
loadZeitraeume();
|
||||
loadEmailConfig();
|
||||
});
|
||||
</script>
|
||||
@@ -344,8 +436,8 @@ Ihr Praktikumsteam`;
|
||||
/>
|
||||
|
||||
<main class="max-w-7xl mx-auto px-4 py-6">
|
||||
<!-- Filter und E-Mail Konfiguration -->
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<!-- Filter, Export und E-Mail Konfiguration -->
|
||||
<div class="mb-6 flex flex-wrap justify-between items-center gap-4">
|
||||
<!-- Status Filter -->
|
||||
<div class="flex items-center space-x-4">
|
||||
<label for="status-filter" class="text-sm font-medium text-gray-700">Filter:</label>
|
||||
@@ -361,21 +453,36 @@ Ihr Praktikumsteam`;
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- E-Mail Konfiguration Button -->
|
||||
<button
|
||||
on:click={() => showEmailConfig = !showEmailConfig}
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
|
||||
disabled={isLoadingEmailConfig}
|
||||
>
|
||||
{#if isLoadingEmailConfig}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{:else}
|
||||
<!-- 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="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="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>
|
||||
{/if}
|
||||
E-Mail-Vorlage konfigurieren
|
||||
</button>
|
||||
Excel-Export
|
||||
</button>
|
||||
|
||||
<!-- E-Mail Konfiguration Button -->
|
||||
<button
|
||||
on:click={() => showEmailConfig = !showEmailConfig}
|
||||
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center"
|
||||
disabled={isLoadingEmailConfig}
|
||||
>
|
||||
{#if isLoadingEmailConfig}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
{: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="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>
|
||||
{/if}
|
||||
E-Mail-Vorlage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Übersicht -->
|
||||
@@ -507,6 +614,14 @@ Ihr Praktikumsteam`;
|
||||
<h3 class="text-sm font-medium text-red-800">Fehler</h3>
|
||||
<p class="mt-1 text-sm text-red-700">{error}</p>
|
||||
</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>
|
||||
{/if}
|
||||
@@ -555,6 +670,97 @@ Ihr Praktikumsteam`;
|
||||
/>
|
||||
{/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 -->
|
||||
{#if showEmailPreview && emailPreviewData}
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
|
||||
@@ -30,7 +30,6 @@ export async function GET() {
|
||||
nachname: anmeldung.nachname,
|
||||
email: anmeldung.email,
|
||||
|
||||
// === FEHLENDE FELDER HINZUGEFÜGT ===
|
||||
geburtsdatum: anmeldung.geburtsdatum,
|
||||
strasse: anmeldung.strasse,
|
||||
hausnummer: anmeldung.hausnummer,
|
||||
@@ -41,18 +40,14 @@ export async function GET() {
|
||||
schulklasse: anmeldung.schulklasse,
|
||||
motivation: anmeldung.motivation,
|
||||
alter: anmeldung.alter,
|
||||
// === ENDE FEHLENDE FELDER ===
|
||||
|
||||
// Noten als String konvertieren (falls sie als Int gespeichert sind)
|
||||
noteDeutsch: anmeldung.noteDeutsch ? anmeldung.noteDeutsch.toString() : undefined,
|
||||
noteMathe: anmeldung.noteMathe ? anmeldung.noteMathe.toString() : undefined,
|
||||
sozialverhalten: anmeldung.sozialverhalten,
|
||||
|
||||
// Status-Mapping für Frontend
|
||||
status: mapPrismaStatusToFrontend(anmeldung.status),
|
||||
processedAt: anmeldung.processedAt ? anmeldung.processedAt.getTime() : undefined,
|
||||
|
||||
// Wünsche - sicherstellen dass sie existieren
|
||||
wunsch1: anmeldung.wunsch1 ? {
|
||||
id: anmeldung.wunsch1.id,
|
||||
name: anmeldung.wunsch1.name
|
||||
@@ -66,13 +61,11 @@ export async function GET() {
|
||||
name: anmeldung.wunsch3.name
|
||||
} : undefined,
|
||||
|
||||
// Zugewiesene Dienststelle
|
||||
assignedDienststelle: anmeldung.zugewiesen ? {
|
||||
id: anmeldung.zugewiesen.id,
|
||||
name: anmeldung.zugewiesen.name
|
||||
} : undefined,
|
||||
|
||||
// Praktikumszeitraum
|
||||
zeitraum: anmeldung.praktikum ? {
|
||||
id: anmeldung.praktikum.id,
|
||||
bezeichnung: anmeldung.praktikum.bezeichnung,
|
||||
@@ -80,17 +73,15 @@ export async function GET() {
|
||||
endDatum: anmeldung.praktikum.endDatum.toISOString()
|
||||
} : undefined,
|
||||
|
||||
// Timestamp
|
||||
timestamp: anmeldung.timestamp ? anmeldung.timestamp.getTime() : Date.now(),
|
||||
|
||||
// PDFs
|
||||
pdfs: anmeldung.pdfs || []
|
||||
}));
|
||||
|
||||
return json(formattedAnmeldungen);
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Anmeldungen:', error);
|
||||
return json({ error: 'Fehler beim Laden der Anmeldungen', details: error.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 });
|
||||
}
|
||||
|
||||
// Prüfen ob ZeitraumPlaetze Eintrag existiert
|
||||
// Prüfen ob ZeitraumPlaetze Eintrag existiert und freie Plätze vorhanden sind
|
||||
const zeitraumPlaetze = await prisma.zeitraumPlaetze.findUnique({
|
||||
where: {
|
||||
zeitraumId_dienststelleId: {
|
||||
@@ -141,26 +132,42 @@ export async function POST({ request, url }) {
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Prüfen ob noch Plätze frei sind (plaetze > 0)
|
||||
if (zeitraumPlaetze.plaetze <= 0) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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
|
||||
prisma.anmeldung.update({
|
||||
await tx.anmeldung.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'ANGENOMMEN',
|
||||
zugewiesenId: dienststelleId,
|
||||
processedAt: new Date()
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
// Plätze in ZeitraumPlaetze reduzieren
|
||||
prisma.zeitraumPlaetze.update({
|
||||
await tx.zeitraumPlaetze.update({
|
||||
where: {
|
||||
zeitraumId_dienststelleId: {
|
||||
zeitraumId: zeitraumId,
|
||||
@@ -172,13 +179,21 @@ export async function POST({ request, url }) {
|
||||
decrement: 1
|
||||
}
|
||||
}
|
||||
})
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
} catch (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 });
|
||||
} catch (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 });
|
||||
}
|
||||
|
||||
// Einfach löschen - Plätze werden NICHT zurückgegeben (gewolltes Verhalten)
|
||||
await prisma.anmeldung.delete({
|
||||
where: { id }
|
||||
});
|
||||
@@ -254,13 +270,13 @@ export async function DELETE({ url }) {
|
||||
return json({ success: true });
|
||||
} catch (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
|
||||
function mapPrismaStatusToFrontend(prismaStatus) {
|
||||
const statusMap = {
|
||||
function mapPrismaStatusToFrontend(prismaStatus: string): string {
|
||||
const statusMap: Record<string, string> = {
|
||||
'OFFEN': 'pending',
|
||||
'BEARBEITUNG': 'processing',
|
||||
'ANGENOMMEN': 'accepted',
|
||||
|
||||
183
src/routes/api/admin/export/+server.ts
Normal file
183
src/routes/api/admin/export/+server.ts
Normal 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;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/routes/api/dienststellen/+server.ts
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -10,8 +11,60 @@ async function getPrismaClient() {
|
||||
return prismaPromise;
|
||||
}
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
const prisma = await getPrismaClient(); // Hier Prisma Client holen
|
||||
const dienststellen = await prisma.dienststelle.findMany({ orderBy: { name: 'asc' } });
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const prisma = await getPrismaClient();
|
||||
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);
|
||||
};
|
||||
Reference in New Issue
Block a user