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,
|
exkursionGenehmigt: false,
|
||||||
exkursionBezuschusst: false,
|
exkursionBezuschusst: false,
|
||||||
},
|
},
|
||||||
comparativeOffers: initialData.comparativeOffers ?? false,
|
|
||||||
fakultaet: initialData.fakultaet ?? false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -151,10 +149,6 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
|
|||||||
exkursionGenehmigt: false,
|
exkursionGenehmigt: false,
|
||||||
exkursionBezuschusst: false,
|
exkursionBezuschusst: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Attachments
|
|
||||||
comparativeOffers: false,
|
|
||||||
fakultaet: false,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -320,8 +314,7 @@ const ApplicationForm: React.FC<ApplicationFormProps> = ({
|
|||||||
if (
|
if (
|
||||||
!formData.qsmFlags?.stellenfinanzierungen ||
|
!formData.qsmFlags?.stellenfinanzierungen ||
|
||||||
!formData.qsmFlags?.studierende ||
|
!formData.qsmFlags?.studierende ||
|
||||||
!formData.qsmFlags?.individuell ||
|
!formData.qsmFlags?.individuell
|
||||||
!formData.fakultaet
|
|
||||||
) {
|
) {
|
||||||
setError("Bitte bestätigen Sie alle Pflichtangaben");
|
setError("Bitte bestätigen Sie alle Pflichtangaben");
|
||||||
return;
|
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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
Typography,
|
Typography,
|
||||||
Backdrop,
|
Backdrop,
|
||||||
Paper
|
Paper,
|
||||||
} from '@mui/material';
|
} from "@mui/material";
|
||||||
|
|
||||||
interface LoadingSpinnerProps {
|
interface LoadingSpinnerProps {
|
||||||
/**
|
/**
|
||||||
@ -23,7 +23,7 @@ interface LoadingSpinnerProps {
|
|||||||
/**
|
/**
|
||||||
* Custom color
|
* Custom color
|
||||||
*/
|
*/
|
||||||
color?: 'primary' | 'secondary' | 'inherit';
|
color?: "primary" | "secondary" | "inherit";
|
||||||
/**
|
/**
|
||||||
* Minimum height when not overlay
|
* Minimum height when not overlay
|
||||||
*/
|
*/
|
||||||
@ -36,22 +36,22 @@ interface LoadingSpinnerProps {
|
|||||||
|
|
||||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
overlay = false,
|
overlay = false,
|
||||||
text = 'Wird geladen...',
|
text = "Wird geladen...",
|
||||||
size = 40,
|
size = 40,
|
||||||
color = 'primary',
|
color = "primary",
|
||||||
minHeight = 200,
|
minHeight = 200,
|
||||||
backdrop = true
|
backdrop = true,
|
||||||
}) => {
|
}) => {
|
||||||
const spinnerContent = (
|
const spinnerContent = (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
textAlign: 'center',
|
textAlign: "center",
|
||||||
p: 3
|
p: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CircularProgress size={size} color={color} />
|
<CircularProgress size={size} color={color} />
|
||||||
@ -62,7 +62,7 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||||||
sx={{
|
sx={{
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
maxWidth: 300,
|
maxWidth: 300,
|
||||||
lineHeight: 1.4
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
@ -76,18 +76,23 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||||||
<Backdrop
|
<Backdrop
|
||||||
open={true}
|
open={true}
|
||||||
sx={{
|
sx={{
|
||||||
color: '#fff',
|
color: "#fff",
|
||||||
zIndex: (theme) => theme.zIndex.drawer + 1,
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
||||||
backgroundColor: backdrop ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.5)',
|
backgroundColor: backdrop
|
||||||
backdropFilter: backdrop ? 'blur(4px)' : 'none',
|
? (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
|
<Paper
|
||||||
elevation={3}
|
elevation={3}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
backgroundColor: 'background.paper',
|
backgroundColor: "background.paper",
|
||||||
color: 'text.primary',
|
color: "text.primary",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
}}
|
}}
|
||||||
@ -101,11 +106,11 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
alignItems: 'center',
|
alignItems: "center",
|
||||||
justifyContent: 'center',
|
justifyContent: "center",
|
||||||
minHeight: minHeight,
|
minHeight: minHeight,
|
||||||
width: '100%'
|
width: "100%",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{spinnerContent}
|
{spinnerContent}
|
||||||
|
|||||||
@ -30,6 +30,8 @@ import {
|
|||||||
VpnKey,
|
VpnKey,
|
||||||
ContentCopy,
|
ContentCopy,
|
||||||
SwapVert,
|
SwapVert,
|
||||||
|
CheckBox,
|
||||||
|
CheckBoxOutlineBlank,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
@ -42,6 +44,7 @@ import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api";
|
|||||||
|
|
||||||
// Components
|
// Components
|
||||||
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
||||||
|
import AttachmentManager from "../components/Attachments/AttachmentManager";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { translateStatus, getStatusColor } from "../utils/statusTranslations";
|
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) {
|
if (!isAdmin) {
|
||||||
return null; // Will redirect
|
return null; // Will redirect
|
||||||
}
|
}
|
||||||
@ -431,125 +440,167 @@ const AdminApplicationView: React.FC = () => {
|
|||||||
<Typography variant="subtitle2" color="text.secondary">
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
Beantragte Summe
|
Beantragte Summe
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" color="primary">
|
<Typography
|
||||||
{formData.requestedAmountEur?.toLocaleString("de-DE", {
|
variant="h4"
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontWeight: "bold" }}
|
||||||
|
>
|
||||||
|
{calculateTotal().toLocaleString("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
}) || "0,00 €"}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Financing Information */}
|
{/* Financing Information */}
|
||||||
{(formData.vsmFinancing || formData.qsmFinancing) && (
|
{(formData.vsmFinancing ||
|
||||||
|
formData.qsmFinancing ||
|
||||||
|
formData.financingCode) && (
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||||
{currentApplication.variant === "QSM"
|
{currentApplication.variant === "QSM"
|
||||||
? "Erfüllte Aufgabe der QSM"
|
? "Erfüllte Aufgabe der QSM"
|
||||||
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
|
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1">
|
|
||||||
{currentApplication.variant === "QSM" &&
|
<Paper
|
||||||
(formData.financingCode || formData.qsmFinancing)
|
variant="outlined"
|
||||||
? QSM_FINANCING_LABELS[
|
sx={{
|
||||||
(formData.financingCode ||
|
p: 2,
|
||||||
formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
|
backgroundColor: (theme) =>
|
||||||
] ||
|
theme.palette.mode === "dark"
|
||||||
formData.financingCode ||
|
? theme.palette.grey[900]
|
||||||
formData.qsmFinancing
|
: theme.palette.grey[50],
|
||||||
: currentApplication.variant === "VSM" &&
|
mb: 3,
|
||||||
(formData.financingCode || formData.vsmFinancing)
|
}}
|
||||||
? VSM_FINANCING_LABELS[
|
>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
|
{currentApplication.variant === "QSM" &&
|
||||||
|
(formData.financingCode || formData.qsmFinancing)
|
||||||
|
? QSM_FINANCING_LABELS[
|
||||||
(formData.financingCode ||
|
(formData.financingCode ||
|
||||||
formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
|
formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
|
||||||
] ||
|
] ||
|
||||||
formData.financingCode ||
|
formData.financingCode ||
|
||||||
formData.vsmFinancing
|
formData.qsmFinancing
|
||||||
: "-"}
|
: currentApplication.variant === "VSM" &&
|
||||||
</Typography>
|
(formData.financingCode || formData.vsmFinancing)
|
||||||
|
? VSM_FINANCING_LABELS[
|
||||||
|
(formData.financingCode ||
|
||||||
|
formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
|
||||||
|
] ||
|
||||||
|
formData.financingCode ||
|
||||||
|
formData.vsmFinancing
|
||||||
|
: "-"}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{/* Display flags */}
|
{/* Display flags */}
|
||||||
{formData.vsmFlags && currentApplication.variant === "VSM" && (
|
{formData.vsmFlags && currentApplication.variant === "VSM" && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle1"
|
||||||
color="text.secondary"
|
|
||||||
gutterBottom
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 500, mb: 2 }}
|
||||||
|
color="text.primary"
|
||||||
>
|
>
|
||||||
Zusätzliche Angaben:
|
Zusätzliche Angaben:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
• Die Maßnahme erfüllt Aufgaben der verfassten
|
{formData.vsmFlags.aufgaben ? (
|
||||||
Studierendenschaft:{" "}
|
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||||
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
|
) : (
|
||||||
</Typography>
|
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||||
<Typography variant="body2">
|
)}
|
||||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
<Typography variant="body1">
|
||||||
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
|
Die Maßnahme erfüllt Aufgaben der verfassten
|
||||||
</Typography>
|
Studierendenschaft
|
||||||
|
</Typography>
|
||||||
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formData.qsmFlags && currentApplication.variant === "QSM" && (
|
{formData.qsmFlags && currentApplication.variant === "QSM" && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle1"
|
||||||
color="text.secondary"
|
|
||||||
gutterBottom
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 500, mb: 2 }}
|
||||||
|
color="text.primary"
|
||||||
>
|
>
|
||||||
Zusätzliche Angaben:
|
Zusätzliche Angaben:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
• Es handelt sich um Stellenfinanzierungen:{" "}
|
{formData.qsmFlags.stellenfinanzierungen ? (
|
||||||
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
|
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||||
</Typography>
|
) : (
|
||||||
<Typography variant="body2">
|
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||||
• Die Studierenden werden an der Planung und Durchführung
|
)}
|
||||||
der Maßnahme beteiligt:{" "}
|
<Typography variant="body1">
|
||||||
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
|
Es handelt sich um Stellenfinanzierungen
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
|
||||||
{formData.qsmFlags.individuell ? "Ja" : "Nein"}
|
|
||||||
</Typography>
|
|
||||||
{formData.qsmFlags.exkursionGenehmigt !== undefined && (
|
|
||||||
<Typography variant="body2">
|
|
||||||
• Die beantragte Exkursion wurde von den zuständigen
|
|
||||||
Stellen genehmigt:{" "}
|
|
||||||
{formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</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>
|
||||||
|
</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 && (
|
||||||
|
<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 && (
|
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
|
||||||
<Typography variant="body2">
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
• Die Exkursion wird bereits aus anderen Mitteln
|
{formData.qsmFlags.exkursionBezuschusst ? (
|
||||||
bezuschusst:{" "}
|
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||||
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
|
) : (
|
||||||
</Typography>
|
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||||
|
)}
|
||||||
|
<Typography variant="body1">
|
||||||
|
Die Exkursion wird bereits aus anderen Mitteln
|
||||||
|
bezuschusst
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -591,10 +642,10 @@ const AdminApplicationView: React.FC = () => {
|
|||||||
Gesamt:
|
Gesamt:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="subtitle1" color="primary">
|
<Typography variant="subtitle1" color="primary">
|
||||||
{formData.requestedAmountEur?.toLocaleString("de-DE", {
|
{calculateTotal().toLocaleString("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
}) || "0,00 €"}
|
})}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
@ -614,10 +665,10 @@ const AdminApplicationView: React.FC = () => {
|
|||||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||||
<TextField
|
<TextField
|
||||||
select
|
select
|
||||||
fullWidth
|
size="small"
|
||||||
value={newStatus}
|
value={newStatus}
|
||||||
onChange={(e) => setNewStatus(e.target.value)}
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
size="small"
|
sx={{ minWidth: "100px" }}
|
||||||
>
|
>
|
||||||
<MenuItem value="new">Neu</MenuItem>
|
<MenuItem value="new">Neu</MenuItem>
|
||||||
<MenuItem value="in-review">In Prüfung</MenuItem>
|
<MenuItem value="in-review">In Prüfung</MenuItem>
|
||||||
@ -728,6 +779,9 @@ const AdminApplicationView: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<AttachmentManager paId={paId!} readOnly={false} isAdmin={true} />
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
|
|||||||
@ -18,7 +18,14 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
Alert,
|
Alert,
|
||||||
} from "@mui/material";
|
} 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 { useParams, useNavigate } from "react-router-dom";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
@ -30,6 +37,7 @@ import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api";
|
|||||||
|
|
||||||
// Components
|
// Components
|
||||||
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
|
||||||
|
import AttachmentManager from "../components/Attachments/AttachmentManager";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
import { translateStatus, getStatusColor } from "../utils/statusTranslations";
|
import { translateStatus, getStatusColor } from "../utils/statusTranslations";
|
||||||
@ -373,7 +381,11 @@ const ViewApplicationPage: React.FC = () => {
|
|||||||
<Typography variant="subtitle2" color="text.secondary">
|
<Typography variant="subtitle2" color="text.secondary">
|
||||||
Beantragte Summe
|
Beantragte Summe
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" color="primary">
|
<Typography
|
||||||
|
variant="h4"
|
||||||
|
color="primary"
|
||||||
|
sx={{ fontWeight: "bold" }}
|
||||||
|
>
|
||||||
{calculateTotal().toLocaleString("de-DE", {
|
{calculateTotal().toLocaleString("de-DE", {
|
||||||
style: "currency",
|
style: "currency",
|
||||||
currency: "EUR",
|
currency: "EUR",
|
||||||
@ -384,114 +396,152 @@ const ViewApplicationPage: React.FC = () => {
|
|||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
{/* Financing Information */}
|
{/* Financing Information */}
|
||||||
{(formData.vsmFinancing || formData.qsmFinancing) && (
|
{(formData.vsmFinancing ||
|
||||||
|
formData.qsmFinancing ||
|
||||||
|
formData.financingCode) && (
|
||||||
<Paper sx={{ p: 3, mb: 3 }}>
|
<Paper sx={{ p: 3, mb: 3 }}>
|
||||||
<Typography variant="h6" gutterBottom>
|
<Typography variant="h6" gutterBottom sx={{ mb: 3 }}>
|
||||||
{currentApplication.variant === "QSM"
|
{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"}
|
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1">
|
|
||||||
{currentApplication.variant === "QSM" &&
|
<Paper
|
||||||
(formData.financingCode || formData.qsmFinancing)
|
variant="outlined"
|
||||||
? QSM_FINANCING_LABELS[
|
sx={{
|
||||||
(formData.financingCode ||
|
p: 2,
|
||||||
formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
|
backgroundColor: (theme) =>
|
||||||
] ||
|
theme.palette.mode === "dark"
|
||||||
formData.financingCode ||
|
? theme.palette.grey[900]
|
||||||
formData.qsmFinancing
|
: theme.palette.grey[50],
|
||||||
: currentApplication.variant === "VSM" &&
|
mb: 3,
|
||||||
(formData.financingCode || formData.vsmFinancing)
|
}}
|
||||||
? VSM_FINANCING_LABELS[
|
>
|
||||||
|
<Typography variant="body1" sx={{ fontWeight: 500 }}>
|
||||||
|
{currentApplication.variant === "QSM" &&
|
||||||
|
(formData.financingCode || formData.qsmFinancing)
|
||||||
|
? QSM_FINANCING_LABELS[
|
||||||
(formData.financingCode ||
|
(formData.financingCode ||
|
||||||
formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
|
formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
|
||||||
] ||
|
] ||
|
||||||
formData.financingCode ||
|
formData.financingCode ||
|
||||||
formData.vsmFinancing
|
formData.qsmFinancing
|
||||||
: "-"}
|
: currentApplication.variant === "VSM" &&
|
||||||
</Typography>
|
(formData.financingCode || formData.vsmFinancing)
|
||||||
|
? VSM_FINANCING_LABELS[
|
||||||
|
(formData.financingCode ||
|
||||||
|
formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
|
||||||
|
] ||
|
||||||
|
formData.financingCode ||
|
||||||
|
formData.vsmFinancing
|
||||||
|
: "-"}
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{/* Display flags */}
|
{/* Display flags */}
|
||||||
{formData.vsmFlags && currentApplication.variant === "VSM" && (
|
{formData.vsmFlags && currentApplication.variant === "VSM" && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle1"
|
||||||
color="text.secondary"
|
|
||||||
gutterBottom
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 500, mb: 2 }}
|
||||||
|
color="text.primary"
|
||||||
>
|
>
|
||||||
Zusätzliche Angaben:
|
Zusätzliche Angaben:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
• Die Maßnahme erfüllt Aufgaben der verfassten
|
{formData.vsmFlags.aufgaben ? (
|
||||||
Studierendenschaft:{" "}
|
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||||
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
|
) : (
|
||||||
</Typography>
|
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||||
<Typography variant="body2">
|
)}
|
||||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
<Typography variant="body1">
|
||||||
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
|
Die Maßnahme erfüllt Aufgaben der verfassten
|
||||||
</Typography>
|
Studierendenschaft
|
||||||
|
</Typography>
|
||||||
|
</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>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{formData.qsmFlags && currentApplication.variant === "QSM" && (
|
{formData.qsmFlags && currentApplication.variant === "QSM" && (
|
||||||
<Box sx={{ mt: 2 }}>
|
<Box>
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle1"
|
||||||
color="text.secondary"
|
|
||||||
gutterBottom
|
gutterBottom
|
||||||
|
sx={{ fontWeight: 500, mb: 2 }}
|
||||||
|
color="text.primary"
|
||||||
>
|
>
|
||||||
Zusätzliche Angaben:
|
Zusätzliche Angaben:
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2">
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
• Es handelt sich um Stellenfinanzierungen:{" "}
|
{formData.qsmFlags.stellenfinanzierungen ? (
|
||||||
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
|
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||||
</Typography>
|
) : (
|
||||||
<Typography variant="body2">
|
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||||
• Die Studierenden werden an der Planung und Durchführung
|
)}
|
||||||
der Maßnahme beteiligt:{" "}
|
<Typography variant="body1">
|
||||||
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
|
Es handelt sich um Stellenfinanzierungen
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2">
|
|
||||||
• Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
|
|
||||||
{formData.qsmFlags.individuell ? "Ja" : "Nein"}
|
|
||||||
</Typography>
|
|
||||||
{formData.qsmFlags.exkursionGenehmigt !== undefined && (
|
|
||||||
<Typography variant="body2">
|
|
||||||
• Die beantragte Exkursion wurde von den zuständigen
|
|
||||||
Stellen genehmigt:{" "}
|
|
||||||
{formData.qsmFlags.exkursionGenehmigt ? "Ja" : "Nein"}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
|
</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>
|
||||||
|
</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 && (
|
||||||
|
<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 && (
|
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
|
||||||
<Typography variant="body2">
|
<Box sx={{ display: "flex", alignItems: "center", mb: 1 }}>
|
||||||
• Die Exkursion wird bereits aus anderen Mitteln
|
{formData.qsmFlags.exkursionBezuschusst ? (
|
||||||
bezuschusst:{" "}
|
<CheckBox color="primary" sx={{ mr: 1 }} />
|
||||||
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
|
) : (
|
||||||
</Typography>
|
<CheckBoxOutlineBlank sx={{ mr: 1 }} />
|
||||||
|
)}
|
||||||
|
<Typography variant="body1">
|
||||||
|
Die Exkursion wird bereits aus anderen Mitteln
|
||||||
|
bezuschusst
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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>
|
</Paper>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -622,6 +672,14 @@ const ViewApplicationPage: React.FC = () => {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* Attachments */}
|
||||||
|
<AttachmentManager
|
||||||
|
paId={paId!}
|
||||||
|
paKey={paKey || undefined}
|
||||||
|
readOnly={!isAdmin && currentApplication.status !== "new"}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={showDeleteDialog}
|
open={showDeleteDialog}
|
||||||
|
|||||||
@ -137,7 +137,6 @@ const initialFormData: Partial<FormData> = {
|
|||||||
costs: [],
|
costs: [],
|
||||||
requestedAmountEur: 0,
|
requestedAmountEur: 0,
|
||||||
variant: "VSM",
|
variant: "VSM",
|
||||||
comparativeOffers: false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: ApplicationState = {
|
const initialState: ApplicationState = {
|
||||||
@ -1158,10 +1157,6 @@ function createFormJsonFromData(formData: Partial<FormData>): string {
|
|||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
requestedAmountEur: "pa-requested-amount-euro-sum",
|
requestedAmountEur: "pa-requested-amount-euro-sum",
|
||||||
|
|
||||||
// Attachments
|
|
||||||
comparativeOffers: "pa-anh-vergleichsangebote",
|
|
||||||
fakultaet: "pa-anh-fakultaet",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add basic fields
|
// 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);
|
const jsonString = JSON.stringify(formJson);
|
||||||
return btoa(unescape(encodeURIComponent(jsonString))); // Base64 encode with UTF-8 support
|
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,
|
individuell: pa.project.financing.vsm.flags.individuell || false,
|
||||||
}
|
}
|
||||||
: undefined,
|
: 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
|
# Build Applicant
|
||||||
applicant_dict = _get(payload, "pa.applicant", {}) or {}
|
applicant_dict = _get(payload, "pa.applicant", {}) or {}
|
||||||
applicant = Applicant(
|
applicant = Applicant(
|
||||||
type=applicant_dict.get("type"),
|
type=applicant_dict.get("type") or "person",
|
||||||
institution=Institution(
|
institution=Institution(
|
||||||
name=_get(applicant_dict, "institution.name"),
|
name=_get(applicant_dict, "institution.name"),
|
||||||
type=_get(applicant_dict, "institution.type"),
|
type=_get(applicant_dict, "institution.type"),
|
||||||
|
|||||||
@ -40,6 +40,7 @@ from sqlalchemy import (
|
|||||||
create_engine, Column, Integer, String, Text, DateTime, JSON as SAJSON,
|
create_engine, Column, Integer, String, Text, DateTime, JSON as SAJSON,
|
||||||
select, func, UniqueConstraint
|
select, func, UniqueConstraint
|
||||||
)
|
)
|
||||||
|
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy import text as sql_text
|
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():
|
def init_db():
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
@ -190,6 +217,20 @@ class SearchQuery(BaseModel):
|
|||||||
limit: int = 50
|
limit: int = 50
|
||||||
offset: int = 0
|
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
|
# Auth-Helpers
|
||||||
# -------------------------------------------------------------
|
# -------------------------------------------------------------
|
||||||
@ -747,3 +788,191 @@ def search_applications(
|
|||||||
"created_at": r[3].isoformat(), "updated_at": r[4].isoformat()}
|
"created_at": r[3].isoformat(), "updated_at": r[4].isoformat()}
|
||||||
for r in rows
|
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