diff --git a/ATTACHMENT_FEATURE.md b/ATTACHMENT_FEATURE.md
new file mode 100644
index 00000000..cbfbcaba
--- /dev/null
+++ b/ATTACHMENT_FEATURE.md
@@ -0,0 +1,138 @@
+# Attachment Feature Documentation
+
+## Overview
+
+The attachment feature allows users to upload and manage file attachments for their applications. Each application can have up to 30 attachments with a combined maximum size of 100MB.
+
+## Database Schema
+
+### `attachments` Table
+Stores the actual file data and metadata:
+- `id` (INT, PRIMARY KEY) - Unique identifier
+- `filename` (VARCHAR 255) - Original filename
+- `content_type` (VARCHAR 100) - MIME type of the file
+- `size` (INT) - File size in bytes
+- `data` (LONGTEXT) - Base64 encoded file content
+- `created_at` (DATETIME) - Upload timestamp
+
+### `application_attachments` Table
+Links attachments to applications (many-to-many relationship):
+- `id` (INT, PRIMARY KEY) - Unique identifier
+- `application_id` (INT) - References the application
+- `attachment_id` (INT) - References the attachment
+- `created_at` (DATETIME) - Link creation timestamp
+- Unique constraint on (`application_id`, `attachment_id`)
+
+## API Endpoints
+
+### Upload Attachment
+`POST /api/applications/{pa_id}/attachments`
+- Requires authentication (PA-KEY or MASTER-KEY)
+- Request body: multipart/form-data with file
+- Returns: AttachmentUploadResponse with attachment ID, filename, and size
+- Validates:
+ - Maximum 30 attachments per application
+ - Individual file size limit of 25MB per file
+ - Total size limit of 100MB across all attachments
+
+### List Attachments
+`GET /api/applications/{pa_id}/attachments`
+- Requires authentication (PA-KEY or MASTER-KEY)
+- Returns: Array of AttachmentInfo objects
+
+### Download Attachment
+`GET /api/applications/{pa_id}/attachments/{attachment_id}`
+- Requires authentication (PA-KEY or MASTER-KEY)
+- Returns: File content with appropriate content-type header
+
+### Delete Attachment
+`DELETE /api/applications/{pa_id}/attachments/{attachment_id}`
+- Requires authentication (PA-KEY or MASTER-KEY)
+- Removes both the attachment and the link to the application
+
+## Frontend Component
+
+### AttachmentManager Component
+Located at: `frontend/src/components/Attachments/AttachmentManager.tsx`
+
+Features:
+- File upload with progress tracking
+- List view with file type icons
+- Download functionality
+- Delete with confirmation dialog
+- Real-time validation of limits (30 files, 100MB total)
+- Responsive design with Material-UI
+
+Props:
+- `paId` (string) - Application ID
+- `paKey` (string, optional) - Application key for authentication
+- `readOnly` (boolean) - Disable upload/delete actions
+- `isAdmin` (boolean) - Use master key for authentication
+
+## Implementation Details
+
+### Security
+- All endpoints require authentication via PA-KEY or MASTER-KEY headers
+- Files are stored as Base64 encoded data in the database
+- Application ownership is verified before allowing access to attachments
+
+### File Type Support
+- All file types are supported
+- Special icons for:
+ - Images (image/*)
+ - PDFs (application/pdf)
+ - Word documents (application/msword, .docx)
+ - Generic files (all others)
+
+### Performance Considerations
+- Files are stored in the database as Base64 strings (increases size by ~33%)
+- Individual files are limited to 25MB to prevent database issues
+- Large files may impact database performance
+- The data column uses MySQL LONGTEXT type (supports up to 4GB)
+- Consider implementing file streaming for very large files
+- Progress tracking uses XMLHttpRequest for better upload experience
+
+## Migration
+
+To add the attachment tables to an existing database, run:
+
+```sql
+mysql -u your_user -p your_database < src/migrations/add_attachments_tables.sql
+```
+
+If you already created the tables but need to fix the data column type:
+
+```sql
+mysql -u your_user -p your_database < src/migrations/alter_attachments_data_column.sql
+```
+
+## Usage Example
+
+### In ViewApplicationPage
+```tsx
+
+```
+
+### In AdminApplicationView
+```tsx
+
+```
+
+## Future Enhancements
+
+1. **File Preview**: Add preview functionality for images and PDFs
+2. **Drag & Drop**: Implement drag-and-drop file upload
+3. **Bulk Upload**: Allow multiple files to be uploaded at once
+4. **File Compression**: Compress files before storing to save space
+5. **External Storage**: Move file storage to S3 or similar service for better scalability
+6. **Virus Scanning**: Integrate virus scanning for uploaded files
+7. **File Versioning**: Track file versions/updates
\ No newline at end of file
diff --git a/frontend/src/components/ApplicationForm/ApplicationForm.tsx b/frontend/src/components/ApplicationForm/ApplicationForm.tsx
index 71384dab..34422478 100644
--- a/frontend/src/components/ApplicationForm/ApplicationForm.tsx
+++ b/frontend/src/components/ApplicationForm/ApplicationForm.tsx
@@ -99,8 +99,6 @@ const ApplicationForm: React.FC = ({
exkursionGenehmigt: false,
exkursionBezuschusst: false,
},
- comparativeOffers: initialData.comparativeOffers ?? false,
- fakultaet: initialData.fakultaet ?? false,
};
}
return {
@@ -151,10 +149,6 @@ const ApplicationForm: React.FC = ({
exkursionGenehmigt: false,
exkursionBezuschusst: false,
},
-
- // Attachments
- comparativeOffers: false,
- fakultaet: false,
};
});
@@ -320,8 +314,7 @@ const ApplicationForm: React.FC = ({
if (
!formData.qsmFlags?.stellenfinanzierungen ||
!formData.qsmFlags?.studierende ||
- !formData.qsmFlags?.individuell ||
- !formData.fakultaet
+ !formData.qsmFlags?.individuell
) {
setError("Bitte bestätigen Sie alle Pflichtangaben");
return;
diff --git a/frontend/src/components/Attachments/AttachmentManager.tsx b/frontend/src/components/Attachments/AttachmentManager.tsx
new file mode 100644
index 00000000..cc0d1c03
--- /dev/null
+++ b/frontend/src/components/Attachments/AttachmentManager.tsx
@@ -0,0 +1,429 @@
+import React, { useState, useRef } from "react";
+import {
+ Box,
+ Paper,
+ Typography,
+ Button,
+ IconButton,
+ List,
+ ListItem,
+ ListItemText,
+ ListItemSecondaryAction,
+ LinearProgress,
+ Alert,
+ Tooltip,
+ Chip,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ CircularProgress,
+} from "@mui/material";
+import {
+ CloudUpload,
+ Delete,
+ Download,
+ InsertDriveFile,
+ PictureAsPdf,
+ Image,
+ Description,
+} from "@mui/icons-material";
+import { useApplicationStore } from "../../store/applicationStore";
+
+interface Attachment {
+ id: number;
+ filename: string;
+ content_type: string;
+ size: number;
+ created_at: string;
+}
+
+interface AttachmentManagerProps {
+ paId: string;
+ paKey?: string;
+ readOnly?: boolean;
+ isAdmin?: boolean;
+}
+
+const AttachmentManager: React.FC = ({
+ paId,
+ paKey,
+ readOnly = false,
+ isAdmin = false,
+}) => {
+ const [attachments, setAttachments] = useState([]);
+ const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [error, setError] = useState(null);
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [attachmentToDelete, setAttachmentToDelete] = useState(
+ null,
+ );
+ const [loading, setLoading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const { masterKey } = useApplicationStore();
+
+ // Helper function to format file size
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
+ };
+
+ // Helper function to get file icon based on content type
+ const getFileIcon = (contentType: string) => {
+ if (contentType.startsWith("image/")) return ;
+ if (contentType === "application/pdf") return ;
+ if (
+ contentType === "application/msword" ||
+ contentType ===
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ ) {
+ return ;
+ }
+ return ;
+ };
+
+ // Load attachments
+ const loadAttachments = async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const headers: HeadersInit = {};
+ if (isAdmin && masterKey) {
+ headers["X-MASTER-KEY"] = masterKey;
+ } else if (paKey) {
+ headers["X-PA-KEY"] = paKey;
+ }
+
+ const response = await fetch(`/api/applications/${paId}/attachments`, {
+ headers,
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to load attachments");
+ }
+
+ const data = await response.json();
+ setAttachments(data);
+ } catch (err) {
+ setError("Fehler beim Laden der Anhänge");
+ console.error("Error loading attachments:", err);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Load attachments on mount
+ React.useEffect(() => {
+ loadAttachments();
+ }, [paId, paKey, isAdmin]);
+
+ // Handle file upload
+ const handleFileUpload = async (
+ event: React.ChangeEvent,
+ ) => {
+ const files = event.target.files;
+ if (!files || files.length === 0) return;
+
+ setError(null);
+ const file = files[0];
+
+ // Check individual file size limit (25MB per file)
+ const maxFileSize = 25 * 1024 * 1024; // 25MB
+ if (file.size > maxFileSize) {
+ setError(
+ `Die Datei ist zu groß. Maximale Dateigröße: ${formatFileSize(maxFileSize)}`,
+ );
+ return;
+ }
+
+ // Check file count limit
+ if (attachments.length >= 30) {
+ setError("Maximale Anzahl von 30 Anhängen erreicht");
+ return;
+ }
+
+ // Check total size limit (100MB)
+ const totalSize = attachments.reduce((sum, att) => sum + att.size, 0);
+ if (totalSize + file.size > 100 * 1024 * 1024) {
+ setError("Die Gesamtgröße aller Anhänge würde 100MB überschreiten");
+ return;
+ }
+
+ setUploading(true);
+ setUploadProgress(0);
+
+ try {
+ const formData = new FormData();
+ formData.append("file", file);
+
+ const headers: HeadersInit = {};
+ if (isAdmin && masterKey) {
+ headers["X-MASTER-KEY"] = masterKey;
+ } else if (paKey) {
+ headers["X-PA-KEY"] = paKey;
+ }
+
+ const xhr = new XMLHttpRequest();
+
+ // Track upload progress
+ xhr.upload.addEventListener("progress", (event) => {
+ if (event.lengthComputable) {
+ const percentComplete = (event.loaded / event.total) * 100;
+ setUploadProgress(percentComplete);
+ }
+ });
+
+ // Handle completion
+ xhr.addEventListener("load", () => {
+ if (xhr.status >= 200 && xhr.status < 300) {
+ loadAttachments();
+ setUploading(false);
+ setUploadProgress(0);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ } else {
+ throw new Error(`Upload failed with status ${xhr.status}`);
+ }
+ });
+
+ // Handle errors
+ xhr.addEventListener("error", () => {
+ throw new Error("Upload failed");
+ });
+
+ // Send request
+ xhr.open("POST", `/api/applications/${paId}/attachments`);
+ Object.entries(headers).forEach(([key, value]) => {
+ xhr.setRequestHeader(key, value);
+ });
+ xhr.send(formData);
+ } catch (err) {
+ setError("Fehler beim Hochladen der Datei");
+ console.error("Error uploading file:", err);
+ setUploading(false);
+ setUploadProgress(0);
+ }
+ };
+
+ // Handle file download
+ const handleDownload = async (attachmentId: number, filename: string) => {
+ try {
+ const headers: HeadersInit = {};
+ if (isAdmin && masterKey) {
+ headers["X-MASTER-KEY"] = masterKey;
+ } else if (paKey) {
+ headers["X-PA-KEY"] = paKey;
+ }
+
+ const response = await fetch(
+ `/api/applications/${paId}/attachments/${attachmentId}`,
+ { headers },
+ );
+
+ if (!response.ok) {
+ throw new Error("Download failed");
+ }
+
+ const blob = await response.blob();
+ 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);
+ } catch (err) {
+ setError("Fehler beim Herunterladen der Datei");
+ console.error("Error downloading file:", err);
+ }
+ };
+
+ // Handle file deletion
+ const handleDelete = async () => {
+ if (!attachmentToDelete) return;
+
+ try {
+ const headers: HeadersInit = {};
+ if (isAdmin && masterKey) {
+ headers["X-MASTER-KEY"] = masterKey;
+ } else if (paKey) {
+ headers["X-PA-KEY"] = paKey;
+ }
+
+ const response = await fetch(
+ `/api/applications/${paId}/attachments/${attachmentToDelete}`,
+ {
+ method: "DELETE",
+ headers,
+ },
+ );
+
+ if (!response.ok) {
+ throw new Error("Delete failed");
+ }
+
+ loadAttachments();
+ setDeleteDialogOpen(false);
+ setAttachmentToDelete(null);
+ } catch (err) {
+ setError("Fehler beim Löschen der Datei");
+ console.error("Error deleting file:", err);
+ }
+ };
+
+ // Calculate total size
+ const totalSize = attachments.reduce((sum, att) => sum + att.size, 0);
+ const totalSizeMB = (totalSize / (1024 * 1024)).toFixed(2);
+
+ return (
+
+
+
+ Anhänge
+
+
+ = 30 ? "error" : "default"}
+ />
+ = 100 * 1024 * 1024 ? "error" : "default"}
+ />
+
+
+
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+ {!readOnly && (
+
+ = 30}
+ />
+ }
+ onClick={() => fileInputRef.current?.click()}
+ disabled={uploading || attachments.length >= 30}
+ fullWidth
+ >
+ Datei hochladen
+
+
+ )}
+
+ {uploading && (
+
+
+
+ Hochladen... {Math.round(uploadProgress)}%
+
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : attachments.length === 0 ? (
+
+ Keine Anhänge vorhanden
+
+ ) : (
+
+ {attachments.map((attachment) => (
+
+
+ {getFileIcon(attachment.content_type)}
+
+
+
+
+
+ handleDownload(attachment.id, attachment.filename)
+ }
+ >
+
+
+
+ {!readOnly && (
+
+ {
+ setAttachmentToDelete(attachment.id);
+ setDeleteDialogOpen(true);
+ }}
+ >
+
+
+
+ )}
+
+
+ ))}
+
+ )}
+
+ {/* Delete confirmation dialog */}
+
+
+ );
+};
+
+export default AttachmentManager;
diff --git a/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx b/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx
index 288dfefd..dafe1ed1 100644
--- a/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx
+++ b/frontend/src/components/LoadingSpinner/LoadingSpinner.tsx
@@ -1,11 +1,11 @@
-import React from 'react';
+import React from "react";
import {
Box,
CircularProgress,
Typography,
Backdrop,
- Paper
-} from '@mui/material';
+ Paper,
+} from "@mui/material";
interface LoadingSpinnerProps {
/**
@@ -23,7 +23,7 @@ interface LoadingSpinnerProps {
/**
* Custom color
*/
- color?: 'primary' | 'secondary' | 'inherit';
+ color?: "primary" | "secondary" | "inherit";
/**
* Minimum height when not overlay
*/
@@ -36,22 +36,22 @@ interface LoadingSpinnerProps {
const LoadingSpinner: React.FC = ({
overlay = false,
- text = 'Wird geladen...',
+ text = "Wird geladen...",
size = 40,
- color = 'primary',
+ color = "primary",
minHeight = 200,
- backdrop = true
+ backdrop = true,
}) => {
const spinnerContent = (
@@ -62,7 +62,7 @@ const LoadingSpinner: React.FC = ({
sx={{
fontWeight: 500,
maxWidth: 300,
- lineHeight: 1.4
+ lineHeight: 1.4,
}}
>
{text}
@@ -76,18 +76,23 @@ const LoadingSpinner: React.FC = ({
theme.zIndex.drawer + 1,
- backgroundColor: backdrop ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.5)',
- backdropFilter: backdrop ? 'blur(4px)' : 'none',
+ backgroundColor: backdrop
+ ? (theme) =>
+ theme.palette.mode === "dark"
+ ? "rgba(0, 0, 0, 0.9)"
+ : "rgba(255, 255, 255, 0.9)"
+ : "rgba(0, 0, 0, 0.5)",
+ backdropFilter: backdrop ? "blur(4px)" : "none",
}}
>
= ({
return (
{spinnerContent}
diff --git a/frontend/src/pages/AdminApplicationView.tsx b/frontend/src/pages/AdminApplicationView.tsx
index e53fc29e..b8de585d 100644
--- a/frontend/src/pages/AdminApplicationView.tsx
+++ b/frontend/src/pages/AdminApplicationView.tsx
@@ -30,6 +30,8 @@ import {
VpnKey,
ContentCopy,
SwapVert,
+ CheckBox,
+ CheckBoxOutlineBlank,
} from "@mui/icons-material";
import { useParams, useNavigate } from "react-router-dom";
import dayjs from "dayjs";
@@ -42,6 +44,7 @@ import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api";
// Components
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
+import AttachmentManager from "../components/Attachments/AttachmentManager";
// Utils
import { translateStatus, getStatusColor } from "../utils/statusTranslations";
@@ -151,6 +154,12 @@ const AdminApplicationView: React.FC = () => {
}
};
+ // Calculate total from costs
+ const calculateTotal = () => {
+ if (!formData?.costs) return 0;
+ return formData.costs.reduce((sum, cost) => sum + (cost.amountEur || 0), 0);
+ };
+
if (!isAdmin) {
return null; // Will redirect
}
@@ -431,125 +440,167 @@ const AdminApplicationView: React.FC = () => {
Beantragte Summe
-
- {formData.requestedAmountEur?.toLocaleString("de-DE", {
+
+ {calculateTotal().toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
- }) || "0,00 €"}
+ })}
{/* Financing Information */}
- {(formData.vsmFinancing || formData.qsmFinancing) && (
+ {(formData.vsmFinancing ||
+ formData.qsmFinancing ||
+ formData.financingCode) && (
-
+
{currentApplication.variant === "QSM"
? "Erfüllte Aufgabe der QSM"
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
-
- {currentApplication.variant === "QSM" &&
- (formData.financingCode || formData.qsmFinancing)
- ? QSM_FINANCING_LABELS[
- (formData.financingCode ||
- formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
- ] ||
- formData.financingCode ||
- formData.qsmFinancing
- : currentApplication.variant === "VSM" &&
- (formData.financingCode || formData.vsmFinancing)
- ? VSM_FINANCING_LABELS[
+
+
+ theme.palette.mode === "dark"
+ ? theme.palette.grey[900]
+ : theme.palette.grey[50],
+ mb: 3,
+ }}
+ >
+
+ {currentApplication.variant === "QSM" &&
+ (formData.financingCode || formData.qsmFinancing)
+ ? QSM_FINANCING_LABELS[
(formData.financingCode ||
- formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
+ formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
] ||
formData.financingCode ||
- formData.vsmFinancing
- : "-"}
-
+ formData.qsmFinancing
+ : currentApplication.variant === "VSM" &&
+ (formData.financingCode || formData.vsmFinancing)
+ ? VSM_FINANCING_LABELS[
+ (formData.financingCode ||
+ formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
+ ] ||
+ formData.financingCode ||
+ formData.vsmFinancing
+ : "-"}
+
+
{/* Display flags */}
{formData.vsmFlags && currentApplication.variant === "VSM" && (
-
+
Zusätzliche Angaben:
-
- • Die Maßnahme erfüllt Aufgaben der verfassten
- Studierendenschaft:{" "}
- {formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
-
-
- • Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
- {formData.vsmFlags.individuell ? "Ja" : "Nein"}
-
+
+ {formData.vsmFlags.aufgaben ? (
+
+ ) : (
+
+ )}
+
+ Die Maßnahme erfüllt Aufgaben der verfassten
+ Studierendenschaft
+
+
+
+ {formData.vsmFlags.individuell ? (
+
+ ) : (
+
+ )}
+
+ Es werden keine Einzelpersonen von der Maßnahme gefördert
+
+
)}
{formData.qsmFlags && currentApplication.variant === "QSM" && (
-
+
Zusätzliche Angaben:
-
- • Es handelt sich um Stellenfinanzierungen:{" "}
- {formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
-
-
- • Die Studierenden werden an der Planung und Durchführung
- der Maßnahme beteiligt:{" "}
- {formData.qsmFlags.studierende ? "Ja" : "Nein"}
-
-
- • Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
- {formData.qsmFlags.individuell ? "Ja" : "Nein"}
-
- {formData.qsmFlags.exkursionGenehmigt !== undefined && (
-
- • Die beantragte Exkursion wurde von den zuständigen
- Stellen genehmigt:{" "}
- {formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
+
+ {formData.qsmFlags.stellenfinanzierungen ? (
+
+ ) : (
+
+ )}
+
+ Es handelt sich um Stellenfinanzierungen
+
+
+ {formData.qsmFlags.studierende ? (
+
+ ) : (
+
+ )}
+
+ Die Studierenden werden an der Planung und Durchführung
+ der Maßnahme beteiligt
+
+
+
+ {formData.qsmFlags.individuell ? (
+
+ ) : (
+
+ )}
+
+ Es werden keine Einzelpersonen von der Maßnahme gefördert
+
+
+ {formData.qsmFlags.exkursionGenehmigt !== undefined && (
+
+ {formData.qsmFlags.exkursionGenehmigt ? (
+
+ ) : (
+
+ )}
+
+ Die beantragte Exkursion wurde von den zuständigen
+ Stellen genehmigt
+
+
)}
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
-
- • Die Exkursion wird bereits aus anderen Mitteln
- bezuschusst:{" "}
- {formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
-
+
+ {formData.qsmFlags.exkursionBezuschusst ? (
+
+ ) : (
+
+ )}
+
+ Die Exkursion wird bereits aus anderen Mitteln
+ bezuschusst
+
+
)}
)}
-
- {/* Attachments */}
-
-
- Anhänge:
-
-
- • Vergleichsangebote liegen bei:{" "}
- {formData.comparativeOffers ? "Ja" : "Nein"}
-
- {currentApplication.variant === "QSM" && (
-
- • Die Fakultät ist über den Antrag informiert:{" "}
- {formData.fakultaet ? "Ja" : "Nein"}
-
- )}
-
)}
@@ -591,10 +642,10 @@ const AdminApplicationView: React.FC = () => {
Gesamt:
- {formData.requestedAmountEur?.toLocaleString("de-DE", {
+ {calculateTotal().toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
- }) || "0,00 €"}
+ })}
@@ -614,10 +665,10 @@ const AdminApplicationView: React.FC = () => {
setNewStatus(e.target.value)}
- size="small"
+ sx={{ minWidth: "100px" }}
>
@@ -728,6 +779,9 @@ const AdminApplicationView: React.FC = () => {
+ {/* Attachments */}
+
+
{/* Delete Confirmation Dialog */}