Attachments
This commit is contained in:
parent
6772e3d8f6
commit
dd1249fb35
138
ATTACHMENT_FEATURE.md
Normal file
138
ATTACHMENT_FEATURE.md
Normal file
@ -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
|
||||
<AttachmentManager
|
||||
paId={paId}
|
||||
paKey={paKey}
|
||||
readOnly={!isAdmin && currentApplication.status !== "new"}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
```
|
||||
|
||||
### In AdminApplicationView
|
||||
```tsx
|
||||
<AttachmentManager
|
||||
paId={paId}
|
||||
readOnly={false}
|
||||
isAdmin={true}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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
|
||||
@ -99,8 +99,6 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
|
||||
exkursionGenehmigt: false,
|
||||
exkursionBezuschusst: false,
|
||||
},
|
||||
comparativeOffers: initialData.comparativeOffers ?? false,
|
||||
fakultaet: initialData.fakultaet ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@ -151,10 +149,6 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
|
||||
exkursionGenehmigt: false,
|
||||
exkursionBezuschusst: false,
|
||||
},
|
||||
|
||||
// Attachments
|
||||
comparativeOffers: false,
|
||||
fakultaet: false,
|
||||
};
|
||||
});
|
||||
|
||||
@ -320,8 +314,7 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
|
||||
if (
|
||||
!formData.qsmFlags?.stellenfinanzierungen ||
|
||||
!formData.qsmFlags?.studierende ||
|
||||
!formData.qsmFlags?.individuell ||
|
||||
!formData.fakultaet
|
||||
!formData.qsmFlags?.individuell
|
||||
) {
|
||||
setError("Bitte bestätigen Sie alle Pflichtangaben");
|
||||
return;
|
||||
|
||||
429
frontend/src/components/Attachments/AttachmentManager.tsx
Normal file
429
frontend/src/components/Attachments/AttachmentManager.tsx
Normal file
@ -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<AttachmentManagerProps> = ({
|
||||
paId,
|
||||
paKey,
|
||||
readOnly = false,
|
||||
isAdmin = false,
|
||||
}) => {
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [attachmentToDelete, setAttachmentToDelete] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 <Image />;
|
||||
if (contentType === "application/pdf") return <PictureAsPdf />;
|
||||
if (
|
||||
contentType === "application/msword" ||
|
||||
contentType ===
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
) {
|
||||
return <Description />;
|
||||
}
|
||||
return <InsertDriveFile />;
|
||||
};
|
||||
|
||||
// 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<HTMLInputElement>,
|
||||
) => {
|
||||
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 (
|
||||
<Paper elevation={1} sx={{ p: 3, mt: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 2,
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Anhänge
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${attachments.length} / 30`}
|
||||
color={attachments.length >= 30 ? "error" : "default"}
|
||||
/>
|
||||
<Chip
|
||||
size="small"
|
||||
label={`${totalSizeMB} / 100 MB`}
|
||||
color={totalSize >= 100 * 1024 * 1024 ? "error" : "default"}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading || attachments.length >= 30}
|
||||
/>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudUpload />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || attachments.length >= 30}
|
||||
fullWidth
|
||||
>
|
||||
Datei hochladen
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{uploading && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress variant="determinate" value={uploadProgress} />
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 0.5 }}>
|
||||
Hochladen... {Math.round(uploadProgress)}%
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", py: 3 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : attachments.length === 0 ? (
|
||||
<Typography color="text.secondary" align="center" sx={{ py: 3 }}>
|
||||
Keine Anhänge vorhanden
|
||||
</Typography>
|
||||
) : (
|
||||
<List>
|
||||
{attachments.map((attachment) => (
|
||||
<ListItem
|
||||
key={attachment.id}
|
||||
sx={{
|
||||
borderBottom: "1px solid",
|
||||
borderColor: "divider",
|
||||
"&:last-child": { borderBottom: "none" },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mr: 2, color: "primary.main" }}>
|
||||
{getFileIcon(attachment.content_type)}
|
||||
</Box>
|
||||
<ListItemText
|
||||
primary={attachment.filename}
|
||||
secondary={`${formatFileSize(attachment.size)} • ${new Date(
|
||||
attachment.created_at,
|
||||
).toLocaleDateString("de-DE")}`}
|
||||
/>
|
||||
<ListItemSecondaryAction>
|
||||
<Tooltip title="Herunterladen">
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() =>
|
||||
handleDownload(attachment.id, attachment.filename)
|
||||
}
|
||||
>
|
||||
<Download />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{!readOnly && (
|
||||
<Tooltip title="Löschen">
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => {
|
||||
setAttachmentToDelete(attachment.id);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ListItemSecondaryAction>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog
|
||||
open={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
>
|
||||
<DialogTitle>Anhang löschen?</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Möchten Sie diesen Anhang wirklich löschen? Diese Aktion kann nicht
|
||||
rückgängig gemacht werden.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={handleDelete} color="error" variant="contained">
|
||||
Löschen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentManager;
|
||||
@ -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<LoadingSpinnerProps> = ({
|
||||
overlay = false,
|
||||
text = 'Wird geladen...',
|
||||
text = "Wird geladen...",
|
||||
size = 40,
|
||||
color = 'primary',
|
||||
color = "primary",
|
||||
minHeight = 200,
|
||||
backdrop = true
|
||||
backdrop = true,
|
||||
}) => {
|
||||
const spinnerContent = (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 2,
|
||||
textAlign: 'center',
|
||||
p: 3
|
||||
textAlign: "center",
|
||||
p: 3,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={size} color={color} />
|
||||
@ -62,7 +62,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
sx={{
|
||||
fontWeight: 500,
|
||||
maxWidth: 300,
|
||||
lineHeight: 1.4
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
@ -76,18 +76,23 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
<Backdrop
|
||||
open={true}
|
||||
sx={{
|
||||
color: '#fff',
|
||||
color: "#fff",
|
||||
zIndex: (theme) => 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",
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'background.paper',
|
||||
color: 'text.primary',
|
||||
backgroundColor: "background.paper",
|
||||
color: "text.primary",
|
||||
minWidth: 200,
|
||||
maxWidth: 400,
|
||||
}}
|
||||
@ -101,11 +106,11 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minHeight: minHeight,
|
||||
width: '100%'
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{spinnerContent}
|
||||
|
||||
@ -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,25 +440,43 @@ const AdminApplicationView: React.FC = () => {
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Beantragte Summe
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
{formData.requestedAmountEur?.toLocaleString("de-DE", {
|
||||
<Typography
|
||||
variant="h4"
|
||||
color="primary"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{calculateTotal().toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}) || "0,00 €"}
|
||||
})}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* Financing Information */}
|
||||
{(formData.vsmFinancing || formData.qsmFinancing) && (
|
||||
{(formData.vsmFinancing ||
|
||||
formData.qsmFinancing ||
|
||||
formData.financingCode) && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
{currentApplication.variant === "QSM"
|
||||
? "Erfüllte Aufgabe der QSM"
|
||||
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[50],
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{currentApplication.variant === "QSM" &&
|
||||
(formData.financingCode || formData.qsmFinancing)
|
||||
? QSM_FINANCING_LABELS[
|
||||
@ -468,88 +495,112 @@ const AdminApplicationView: React.FC = () => {
|
||||
formData.vsmFinancing
|
||||
: "-"}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Display flags */}
|
||||
{formData.vsmFlags && currentApplication.variant === "VSM" && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 500, mb: 2 }}
|
||||
color="text.primary"
|
||||
>
|
||||
Zusätzliche Angaben:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Die Maßnahme erfüllt Aufgaben der verfassten
|
||||
Studierendenschaft:{" "}
|
||||
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.vsmFlags.aufgaben ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die Maßnahme erfüllt Aufgaben der verfassten
|
||||
Studierendenschaft
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
||||
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.vsmFlags.individuell ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Es werden keine Einzelpersonen von der Maßnahme gefördert
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{formData.qsmFlags && currentApplication.variant === "QSM" && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 500, mb: 2 }}
|
||||
color="text.primary"
|
||||
>
|
||||
Zusätzliche Angaben:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Es handelt sich um Stellenfinanzierungen:{" "}
|
||||
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.stellenfinanzierungen ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Es handelt sich um Stellenfinanzierungen
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Die Studierenden werden an der Planung und Durchführung
|
||||
der Maßnahme beteiligt:{" "}
|
||||
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.studierende ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die Studierenden werden an der Planung und Durchführung
|
||||
der Maßnahme beteiligt
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
||||
{formData.qsmFlags.individuell ? "Ja" : "Nein"}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.individuell ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Es werden keine Einzelpersonen von der Maßnahme gefördert
|
||||
</Typography>
|
||||
</Box>
|
||||
{formData.qsmFlags.exkursionGenehmigt !== undefined && (
|
||||
<Typography variant="body2">
|
||||
• Die beantragte Exkursion wurde von den zuständigen
|
||||
Stellen genehmigt:{" "}
|
||||
{formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.exkursionGenehmigt ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die beantragte Exkursion wurde von den zuständigen
|
||||
Stellen genehmigt
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
|
||||
<Typography variant="body2">
|
||||
• Die Exkursion wird bereits aus anderen Mitteln
|
||||
bezuschusst:{" "}
|
||||
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.exkursionBezuschusst ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die Exkursion wird bereits aus anderen Mitteln
|
||||
bezuschusst
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
Anhänge:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Vergleichsangebote liegen bei:{" "}
|
||||
{formData.comparativeOffers ? "Ja" : "Nein"}
|
||||
</Typography>
|
||||
{currentApplication.variant === "QSM" && (
|
||||
<Typography variant="body2">
|
||||
• Die Fakultät ist über den Antrag informiert:{" "}
|
||||
{formData.fakultaet ? "Ja" : "Nein"}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@ -591,10 +642,10 @@ const AdminApplicationView: React.FC = () => {
|
||||
Gesamt:
|
||||
</Typography>
|
||||
<Typography variant="subtitle1" color="primary">
|
||||
{formData.requestedAmountEur?.toLocaleString("de-DE", {
|
||||
{calculateTotal().toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}) || "0,00 €"}
|
||||
})}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Paper>
|
||||
@ -614,10 +665,10 @@ const AdminApplicationView: React.FC = () => {
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<TextField
|
||||
select
|
||||
fullWidth
|
||||
size="small"
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
size="small"
|
||||
sx={{ minWidth: "100px" }}
|
||||
>
|
||||
<MenuItem value="new">Neu</MenuItem>
|
||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
||||
@ -728,6 +779,9 @@ const AdminApplicationView: React.FC = () => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Attachments */}
|
||||
<AttachmentManager paId={paId!} readOnly={false} isAdmin={true} />
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={showDeleteDialog}
|
||||
|
||||
@ -18,7 +18,14 @@ import {
|
||||
Tooltip,
|
||||
Alert,
|
||||
} from "@mui/material";
|
||||
import { Download, Edit, Delete, Refresh } from "@mui/icons-material";
|
||||
import {
|
||||
Download,
|
||||
Edit,
|
||||
Delete,
|
||||
Refresh,
|
||||
CheckBox,
|
||||
CheckBoxOutlineBlank,
|
||||
} from "@mui/icons-material";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
@ -30,6 +37,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";
|
||||
@ -373,7 +381,11 @@ const ViewApplicationPage: React.FC = () => {
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
Beantragte Summe
|
||||
</Typography>
|
||||
<Typography variant="h6" color="primary">
|
||||
<Typography
|
||||
variant="h4"
|
||||
color="primary"
|
||||
sx={{ fontWeight: "bold" }}
|
||||
>
|
||||
{calculateTotal().toLocaleString("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
@ -384,14 +396,28 @@ const ViewApplicationPage: React.FC = () => {
|
||||
</Paper>
|
||||
|
||||
{/* Financing Information */}
|
||||
{(formData.vsmFinancing || formData.qsmFinancing) && (
|
||||
{(formData.vsmFinancing ||
|
||||
formData.qsmFinancing ||
|
||||
formData.financingCode) && (
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||
{currentApplication.variant === "QSM"
|
||||
? "Erfüllte Aufgabe nach VWV zur Verwendung von QS-Mitteln"
|
||||
? "Erfüllte Aufgabe der QSM"
|
||||
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
|
||||
<Paper
|
||||
variant="outlined"
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === "dark"
|
||||
? theme.palette.grey[900]
|
||||
: theme.palette.grey[50],
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||
{currentApplication.variant === "QSM" &&
|
||||
(formData.financingCode || formData.qsmFinancing)
|
||||
? QSM_FINANCING_LABELS[
|
||||
@ -410,88 +436,112 @@ const ViewApplicationPage: React.FC = () => {
|
||||
formData.vsmFinancing
|
||||
: "-"}
|
||||
</Typography>
|
||||
</Paper>
|
||||
|
||||
{/* Display flags */}
|
||||
{formData.vsmFlags && currentApplication.variant === "VSM" && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 500, mb: 2 }}
|
||||
color="text.primary"
|
||||
>
|
||||
Zusätzliche Angaben:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Die Maßnahme erfüllt Aufgaben der verfassten
|
||||
Studierendenschaft:{" "}
|
||||
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.vsmFlags.aufgaben ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die Maßnahme erfüllt Aufgaben der verfassten
|
||||
Studierendenschaft
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
||||
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.vsmFlags.individuell ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Es werden keine Einzelpersonen von der Maßnahme gefördert
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{formData.qsmFlags && currentApplication.variant === "QSM" && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
variant="subtitle1"
|
||||
gutterBottom
|
||||
sx={{ fontWeight: 500, mb: 2 }}
|
||||
color="text.primary"
|
||||
>
|
||||
Zusätzliche Angaben:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Es handelt sich um Stellenfinanzierungen:{" "}
|
||||
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.stellenfinanzierungen ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Es handelt sich um Stellenfinanzierungen
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Die Studierenden werden an der Planung und Durchführung
|
||||
der Maßnahme beteiligt:{" "}
|
||||
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.studierende ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die Studierenden werden an der Planung und Durchführung
|
||||
der Maßnahme beteiligt
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
||||
{formData.qsmFlags.individuell ? "Ja" : "Nein"}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.individuell ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Es werden keine Einzelpersonen von der Maßnahme gefördert
|
||||
</Typography>
|
||||
</Box>
|
||||
{formData.qsmFlags.exkursionGenehmigt !== undefined && (
|
||||
<Typography variant="body2">
|
||||
• Die beantragte Exkursion wurde von den zuständigen
|
||||
Stellen genehmigt:{" "}
|
||||
{formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.exkursionGenehmigt ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die beantragte Exkursion wurde von den zuständigen
|
||||
Stellen genehmigt
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
|
||||
<Typography variant="body2">
|
||||
• Die Exkursion wird bereits aus anderen Mitteln
|
||||
bezuschusst:{" "}
|
||||
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||
{formData.qsmFlags.exkursionBezuschusst ? (
|
||||
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography variant="body1">
|
||||
Die Exkursion wird bereits aus anderen Mitteln
|
||||
bezuschusst
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Attachments */}
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
color="text.secondary"
|
||||
gutterBottom
|
||||
>
|
||||
Anhänge:
|
||||
</Typography>
|
||||
<Typography variant="body2">
|
||||
• Vergleichsangebote liegen bei:{" "}
|
||||
{formData.comparativeOffers ? "Ja" : "Nein"}
|
||||
</Typography>
|
||||
{currentApplication.variant === "QSM" && (
|
||||
<Typography variant="body2">
|
||||
• Die Fakultät ist über den Antrag informiert:{" "}
|
||||
{formData.fakultaet ? "Ja" : "Nein"}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
@ -622,6 +672,14 @@ const ViewApplicationPage: React.FC = () => {
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{/* Attachments */}
|
||||
<AttachmentManager
|
||||
paId={paId!}
|
||||
paKey={paKey || undefined}
|
||||
readOnly={!isAdmin && currentApplication.status !== "new"}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog
|
||||
open={showDeleteDialog}
|
||||
|
||||
@ -137,7 +137,6 @@ const initialFormData: Partial<FormData> = {
|
||||
costs: [],
|
||||
requestedAmountEur: 0,
|
||||
variant: "VSM",
|
||||
comparativeOffers: false,
|
||||
};
|
||||
|
||||
const initialState: ApplicationState = {
|
||||
@ -1158,10 +1157,6 @@ function createFormJsonFromData(formData: Partial<FormData>): string {
|
||||
|
||||
// Totals
|
||||
requestedAmountEur: "pa-requested-amount-euro-sum",
|
||||
|
||||
// Attachments
|
||||
comparativeOffers: "pa-anh-vergleichsangebote",
|
||||
fakultaet: "pa-anh-fakultaet",
|
||||
};
|
||||
|
||||
// Add basic fields
|
||||
@ -1229,16 +1224,6 @@ function createFormJsonFromData(formData: Partial<FormData>): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments
|
||||
if (formData.comparativeOffers !== undefined) {
|
||||
formJson["pa-anh-vergleichsangebote"] = {
|
||||
"/V": formData.comparativeOffers,
|
||||
};
|
||||
}
|
||||
if (formData.fakultaet !== undefined) {
|
||||
formJson["pa-anh-fakultaet"] = { "/V": formData.fakultaet };
|
||||
}
|
||||
|
||||
const jsonString = JSON.stringify(formJson);
|
||||
return btoa(unescape(encodeURIComponent(jsonString))); // Base64 encode with UTF-8 support
|
||||
}
|
||||
@ -1303,7 +1288,5 @@ function convertPayloadToFormData(payload: RootPayload): Partial<FormData> {
|
||||
individuell: pa.project.financing.vsm.flags.individuell || false,
|
||||
}
|
||||
: undefined,
|
||||
comparativeOffers: pa.attachments.comparativeOffers || false,
|
||||
fakultaet: pa.attachments.fakultaet || false,
|
||||
};
|
||||
}
|
||||
|
||||
32
src/migrations/add_attachments_tables.sql
Normal file
32
src/migrations/add_attachments_tables.sql
Normal file
@ -0,0 +1,32 @@
|
||||
-- Migration: Add attachment tables
|
||||
-- Description: Add tables for storing file attachments for applications
|
||||
-- Date: 2024
|
||||
|
||||
-- Create attachments table to store file data
|
||||
CREATE TABLE IF NOT EXISTS `attachments` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`content_type` VARCHAR(100) NOT NULL,
|
||||
`size` INT NOT NULL,
|
||||
`data` LONGTEXT NOT NULL, -- Base64 encoded file data
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Create junction table to link applications and attachments
|
||||
CREATE TABLE IF NOT EXISTS `application_attachments` (
|
||||
`id` INT NOT NULL AUTO_INCREMENT,
|
||||
`application_id` INT NOT NULL,
|
||||
`attachment_id` INT NOT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_app_attachment` (`application_id`, `attachment_id`),
|
||||
INDEX `idx_application_id` (`application_id`),
|
||||
INDEX `idx_attachment_id` (`attachment_id`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Note: Foreign key constraints are not added because the Application table
|
||||
-- doesn't have a direct foreign key relationship setup in the current schema.
|
||||
-- The application_id references the id column in the applications table.
|
||||
10
src/migrations/alter_attachments_data_column.sql
Normal file
10
src/migrations/alter_attachments_data_column.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Migration: Alter attachments table data column to LONGTEXT
|
||||
-- Description: Change data column from TEXT to LONGTEXT to support larger files
|
||||
-- Date: 2024
|
||||
|
||||
-- Check if the attachments table exists and alter the data column
|
||||
ALTER TABLE `attachments`
|
||||
MODIFY COLUMN `data` LONGTEXT NOT NULL COMMENT 'Base64 encoded file data';
|
||||
|
||||
-- This migration fixes the issue where TEXT type (max 65,535 bytes) was too small
|
||||
-- for Base64 encoded files. LONGTEXT supports up to 4GB of data.
|
||||
@ -372,7 +372,7 @@ def payload_to_model(payload: Dict[str, Any]) -> RootPayload:
|
||||
# Build Applicant
|
||||
applicant_dict = _get(payload, "pa.applicant", {}) or {}
|
||||
applicant = Applicant(
|
||||
type=applicant_dict.get("type"),
|
||||
type=applicant_dict.get("type") or "person",
|
||||
institution=Institution(
|
||||
name=_get(applicant_dict, "institution.name"),
|
||||
type=_get(applicant_dict, "institution.type"),
|
||||
|
||||
@ -40,6 +40,7 @@ from sqlalchemy import (
|
||||
create_engine, Column, Integer, String, Text, DateTime, JSON as SAJSON,
|
||||
select, func, UniqueConstraint
|
||||
)
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy import text as sql_text
|
||||
@ -108,6 +109,32 @@ class Application(Base):
|
||||
)
|
||||
|
||||
|
||||
class Attachment(Base):
|
||||
__tablename__ = "attachments"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
filename = Column(String(255), nullable=False)
|
||||
content_type = Column(String(100), nullable=False)
|
||||
size = Column(Integer, nullable=False)
|
||||
data = Column(LONGTEXT, nullable=False) # Base64 encoded blob
|
||||
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ApplicationAttachment(Base):
|
||||
__tablename__ = "application_attachments"
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
application_id = Column(Integer, nullable=False, index=True)
|
||||
attachment_id = Column(Integer, nullable=False, index=True)
|
||||
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("application_id", "attachment_id", name="uq_app_attachment"),
|
||||
)
|
||||
|
||||
|
||||
def init_db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
@ -190,6 +217,20 @@ class SearchQuery(BaseModel):
|
||||
limit: int = 50
|
||||
offset: int = 0
|
||||
|
||||
|
||||
class AttachmentInfo(BaseModel):
|
||||
id: int
|
||||
filename: str
|
||||
content_type: str
|
||||
size: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AttachmentUploadResponse(BaseModel):
|
||||
attachment_id: int
|
||||
filename: str
|
||||
size: int
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Auth-Helpers
|
||||
# -------------------------------------------------------------
|
||||
@ -747,3 +788,191 @@ def search_applications(
|
||||
"created_at": r[3].isoformat(), "updated_at": r[4].isoformat()}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# -------------------------------------------------------------
|
||||
# Attachment Endpoints
|
||||
# -------------------------------------------------------------
|
||||
|
||||
@app.post("/applications/{pa_id}/attachments", response_model=AttachmentUploadResponse)
|
||||
async def upload_attachment(
|
||||
pa_id: str,
|
||||
file: UploadFile = File(...),
|
||||
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
||||
key: Optional[str] = Query(None),
|
||||
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||
x_forwarded_for: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Upload an attachment for an application"""
|
||||
rate_limit_ip(x_forwarded_for or "")
|
||||
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key)
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Check if application exists
|
||||
app = db.query(Application).filter(Application.pa_id == pa_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
# Check attachment count limit (30 attachments max)
|
||||
attachment_count = db.query(ApplicationAttachment).filter(
|
||||
ApplicationAttachment.application_id == app.id
|
||||
).count()
|
||||
if attachment_count >= 30:
|
||||
raise HTTPException(status_code=400, detail="Maximum number of attachments (30) reached")
|
||||
|
||||
# Check total size limit (100MB)
|
||||
existing_attachments = db.query(Attachment).join(
|
||||
ApplicationAttachment, Attachment.id == ApplicationAttachment.attachment_id
|
||||
).filter(ApplicationAttachment.application_id == app.id).all()
|
||||
|
||||
total_size = sum(att.size for att in existing_attachments)
|
||||
file_content = await file.read()
|
||||
file_size = len(file_content)
|
||||
|
||||
if total_size + file_size > 100 * 1024 * 1024: # 100MB
|
||||
raise HTTPException(status_code=400, detail="Total attachment size would exceed 100MB limit")
|
||||
|
||||
# Create attachment
|
||||
attachment = Attachment(
|
||||
filename=file.filename,
|
||||
content_type=file.content_type or "application/octet-stream",
|
||||
size=file_size,
|
||||
data=base64.b64encode(file_content).decode('utf-8')
|
||||
)
|
||||
db.add(attachment)
|
||||
db.flush()
|
||||
|
||||
# Link to application
|
||||
app_attachment = ApplicationAttachment(
|
||||
application_id=app.id,
|
||||
attachment_id=attachment.id
|
||||
)
|
||||
db.add(app_attachment)
|
||||
db.commit()
|
||||
|
||||
return AttachmentUploadResponse(
|
||||
attachment_id=attachment.id,
|
||||
filename=attachment.filename,
|
||||
size=attachment.size
|
||||
)
|
||||
|
||||
|
||||
@app.get("/applications/{pa_id}/attachments", response_model=List[AttachmentInfo])
|
||||
def list_attachments(
|
||||
pa_id: str,
|
||||
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
||||
key: Optional[str] = Query(None),
|
||||
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||
x_forwarded_for: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""List all attachments for an application"""
|
||||
rate_limit_ip(x_forwarded_for or "")
|
||||
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key)
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Get application
|
||||
app = db.query(Application).filter(Application.pa_id == pa_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
# Get attachments
|
||||
attachments = db.query(Attachment).join(
|
||||
ApplicationAttachment, Attachment.id == ApplicationAttachment.attachment_id
|
||||
).filter(ApplicationAttachment.application_id == app.id).all()
|
||||
|
||||
return [
|
||||
AttachmentInfo(
|
||||
id=att.id,
|
||||
filename=att.filename,
|
||||
content_type=att.content_type,
|
||||
size=att.size,
|
||||
created_at=att.created_at
|
||||
)
|
||||
for att in attachments
|
||||
]
|
||||
|
||||
|
||||
@app.get("/applications/{pa_id}/attachments/{attachment_id}")
|
||||
def download_attachment(
|
||||
pa_id: str,
|
||||
attachment_id: int,
|
||||
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
||||
key: Optional[str] = Query(None),
|
||||
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||
x_forwarded_for: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Download a specific attachment"""
|
||||
rate_limit_ip(x_forwarded_for or "")
|
||||
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key)
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Get application
|
||||
app = db.query(Application).filter(Application.pa_id == pa_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
# Check if attachment belongs to this application
|
||||
app_attachment = db.query(ApplicationAttachment).filter(
|
||||
ApplicationAttachment.application_id == app.id,
|
||||
ApplicationAttachment.attachment_id == attachment_id
|
||||
).first()
|
||||
if not app_attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found for this application")
|
||||
|
||||
# Get attachment
|
||||
attachment = db.query(Attachment).filter(Attachment.id == attachment_id).first()
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Decode and return file
|
||||
file_data = base64.b64decode(attachment.data)
|
||||
return StreamingResponse(
|
||||
io.BytesIO(file_data),
|
||||
media_type=attachment.content_type,
|
||||
headers={"Content-Disposition": f"attachment; filename={attachment.filename}"}
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/applications/{pa_id}/attachments/{attachment_id}")
|
||||
def delete_attachment(
|
||||
pa_id: str,
|
||||
attachment_id: int,
|
||||
x_pa_key: Optional[str] = Header(None, alias="X-PA-KEY"),
|
||||
key: Optional[str] = Query(None),
|
||||
x_master_key: Optional[str] = Header(None, alias="X-MASTER-KEY"),
|
||||
x_forwarded_for: Optional[str] = Header(None),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Delete a specific attachment"""
|
||||
rate_limit_ip(x_forwarded_for or "")
|
||||
auth = _auth_from_request(db, pa_id, x_pa_key or key, None, x_master_key)
|
||||
if not auth:
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
# Get application
|
||||
app = db.query(Application).filter(Application.pa_id == pa_id).first()
|
||||
if not app:
|
||||
raise HTTPException(status_code=404, detail="Application not found")
|
||||
|
||||
# Check if attachment belongs to this application
|
||||
app_attachment = db.query(ApplicationAttachment).filter(
|
||||
ApplicationAttachment.application_id == app.id,
|
||||
ApplicationAttachment.attachment_id == attachment_id
|
||||
).first()
|
||||
if not app_attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found for this application")
|
||||
|
||||
# Delete link and attachment
|
||||
db.delete(app_attachment)
|
||||
attachment = db.query(Attachment).filter(Attachment.id == attachment_id).first()
|
||||
if attachment:
|
||||
db.delete(attachment)
|
||||
|
||||
db.commit()
|
||||
return {"detail": "Attachment deleted successfully"}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user