stupa-pdf-api/frontend/src/pages/AdminApplicationView.tsx

882 lines
28 KiB
TypeScript

import React, { useEffect, useState } from "react";
import {
Container,
Paper,
Typography,
Box,
Button,
Grid,
Card,
CardContent,
Chip,
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
MenuItem,
IconButton,
Tooltip,
Alert,
} from "@mui/material";
import {
Download,
Delete,
Refresh,
ArrowBack,
Edit,
Save,
VpnKey,
ContentCopy,
SwapVert,
} from "@mui/icons-material";
import { useParams, useNavigate } from "react-router-dom";
import dayjs from "dayjs";
// Store
import { useApplicationStore } from "../store/applicationStore";
// Types
import { VSM_FINANCING_LABELS, QSM_FINANCING_LABELS } from "../types/api";
// Components
import LoadingSpinner from "../components/LoadingSpinner/LoadingSpinner";
// Utils
import { translateStatus, getStatusColor } from "../utils/statusTranslations";
const AdminApplicationView: React.FC = () => {
const navigate = useNavigate();
const { paId } = useParams<{ paId: string }>();
// Store
const {
currentApplication,
formData,
isLoading,
error,
successMessage,
loadApplicationAdmin,
updateApplicationStatusAdmin,
deleteApplicationAdmin,
downloadApplicationPdfAdmin,
resetApplicationCredentials,
isAdmin,
} = useApplicationStore();
// Local state
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [editingStatus, setEditingStatus] = useState(false);
const [newStatus, setNewStatus] = useState("");
const [showResetDialog, setShowResetDialog] = useState(false);
const [newCredentials, setNewCredentials] = useState<{
pa_id: string;
pa_key: string;
} | null>(null);
const [copiedField, setCopiedField] = useState<string | null>(null);
// Redirect if not admin
useEffect(() => {
if (!isAdmin) {
navigate("/");
return;
}
}, [isAdmin, navigate]);
// Load application on mount
useEffect(() => {
if (paId && isAdmin) {
loadApplicationAdmin(paId);
}
}, [paId, isAdmin, loadApplicationAdmin]);
// Update status when application loads
useEffect(() => {
if (currentApplication) {
setNewStatus(currentApplication.status);
}
}, [currentApplication]);
// Handle status change
const handleStatusChange = async () => {
if (paId && newStatus !== currentApplication?.status) {
const success = await updateApplicationStatusAdmin(paId, newStatus);
if (success) {
setEditingStatus(false);
}
} else {
setEditingStatus(false);
}
};
// Handle delete
const handleDelete = async () => {
if (paId) {
const success = await deleteApplicationAdmin(paId);
if (success) {
navigate("/admin");
}
}
};
// Handle refresh
const handleRefresh = () => {
if (paId) {
loadApplicationAdmin(paId);
}
};
// Handle reset credentials
const handleResetCredentials = async () => {
if (paId) {
const result = await resetApplicationCredentials(paId);
if (result) {
setNewCredentials(result);
}
}
};
// Copy to clipboard function
const copyToClipboard = (text: string, field: string) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
// Handle download PDF
const handleDownloadPdf = async () => {
if (paId) {
await downloadApplicationPdfAdmin(paId);
}
};
if (!isAdmin) {
return null; // Will redirect
}
if (!paId) {
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Alert severity="error">Keine Antrags-ID angegeben.</Alert>
</Container>
);
}
if (isLoading) {
return <LoadingSpinner overlay text="Antrag wird geladen..." />;
}
if (error) {
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Alert
severity="error"
action={
<Button size="small" onClick={handleRefresh}>
<Refresh />
</Button>
}
>
{error}
</Alert>
</Container>
);
}
if (!currentApplication) {
return (
<Container maxWidth="md" sx={{ mt: 4 }}>
<Alert severity="warning">Antrag nicht gefunden.</Alert>
</Container>
);
}
return (
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
{/* Header */}
<Box sx={{ mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
mb: 2,
}}
>
<Box>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate("/admin")}
sx={{ mb: 2 }}
>
Zurück zum Dashboard
</Button>
<Typography variant="h3" component="h1" gutterBottom>
Antrag {currentApplication.pa_id}
</Typography>
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
{editingStatus ? (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
select
size="small"
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
sx={{ minWidth: 100 }}
>
<MenuItem value="new">Neu</MenuItem>
<MenuItem value="in-review">In Prüfung</MenuItem>
<MenuItem value="approved">Genehmigt</MenuItem>
<MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField>
<IconButton size="small" onClick={handleStatusChange}>
<Save />
</IconButton>
</Box>
) : (
<Chip
label={translateStatus(currentApplication.status)}
color={getStatusColor(currentApplication.status)}
onClick={() => setEditingStatus(true)}
sx={{ cursor: "pointer" }}
/>
)}
<Chip
label={currentApplication.variant}
variant="outlined"
size="small"
/>
</Box>
</Box>
{/* Action Buttons */}
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
<Tooltip title="Aktualisieren">
<IconButton onClick={handleRefresh}>
<Refresh />
</IconButton>
</Tooltip>
<Tooltip title="Bearbeiten">
<IconButton
onClick={() => navigate(`/admin/applications/${paId}/edit`)}
>
<Edit />
</IconButton>
</Tooltip>
<Tooltip title="Zugangsdaten zurücksetzen">
<IconButton onClick={() => setShowResetDialog(true)}>
<VpnKey />
</IconButton>
</Tooltip>
<Tooltip title="Löschen">
<IconButton
color="error"
onClick={() => setShowDeleteDialog(true)}
>
<Delete />
</IconButton>
</Tooltip>
</Box>
</Box>
{/* Success/Error Messages */}
{successMessage && (
<Alert severity="success" sx={{ mb: 2 }}>
{successMessage}
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
</Box>
<Grid container spacing={3}>
{/* Main Content */}
<Grid item xs={12} md={8}>
{/* Application Details */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h5" gutterBottom>
Antragsinformationen
</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Antragssteller
</Typography>
<Typography variant="body1">
{formData.firstName} {formData.lastName}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
E-Mail
</Typography>
<Typography variant="body1">
{formData.email ? (
<a
href={`mailto:${formData.email}`}
style={{ color: "inherit" }}
>
{formData.email}
</a>
) : (
"-"
)}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Telefon
</Typography>
<Typography variant="body1">
{formData.phone ? (
<a
href={`tel:${formData.phone}`}
style={{ color: "inherit" }}
>
{formData.phone}
</a>
) : (
"-"
)}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Rolle
</Typography>
<Typography variant="body1">{formData.role || "-"}</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Institution
</Typography>
<Typography variant="body1">
{formData.institutionName || "-"}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Fakultät
</Typography>
<Typography variant="body1">
{formData.course || "-"}
</Typography>
</Grid>
<Grid item xs={12}>
<Divider sx={{ my: 2 }} />
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" color="text.secondary">
Projektname
</Typography>
<Typography variant="h6">{formData.projectName}</Typography>
</Grid>
<Grid item xs={12}>
<Typography variant="subtitle2" color="text.secondary">
Projektbeschreibung
</Typography>
<Typography variant="body1" sx={{ whiteSpace: "pre-line" }}>
{formData.description}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Startdatum
</Typography>
<Typography variant="body1">
{formData.startDate
? dayjs(formData.startDate).format("DD.MM.YYYY")
: "-"}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Enddatum
</Typography>
<Typography variant="body1">
{formData.endDate
? dayjs(formData.endDate).format("DD.MM.YYYY")
: "-"}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Teilnehmerzahl
</Typography>
<Typography variant="body1">
{formData.participants || "-"}
</Typography>
</Grid>
<Grid item xs={12} sm={6}>
<Typography variant="subtitle2" color="text.secondary">
Beantragte Summe
</Typography>
<Typography variant="h6" color="primary">
{formData.requestedAmountEur?.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
</Typography>
</Grid>
</Grid>
</Paper>
{/* Financing Information */}
{(formData.vsmFinancing || formData.qsmFinancing) && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
{currentApplication.variant === "QSM"
? "Erfüllte Aufgabe der QSM"
: "Erfüllte Aufgabe der VS nach §65 des Landeshochschulgesetzes"}
</Typography>
<Typography variant="body1">
{currentApplication.variant === "QSM" &&
(formData.financingCode || formData.qsmFinancing)
? QSM_FINANCING_LABELS[
(formData.financingCode ||
formData.qsmFinancing) as keyof typeof QSM_FINANCING_LABELS
] ||
formData.financingCode ||
formData.qsmFinancing
: currentApplication.variant === "VSM" &&
(formData.financingCode || formData.vsmFinancing)
? VSM_FINANCING_LABELS[
(formData.financingCode ||
formData.vsmFinancing) as keyof typeof VSM_FINANCING_LABELS
] ||
formData.financingCode ||
formData.vsmFinancing
: "-"}
</Typography>
{/* Display flags */}
{formData.vsmFlags && currentApplication.variant === "VSM" && (
<Box sx={{ mt: 2 }}>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
>
Zusätzliche Angaben:
</Typography>
<Typography variant="body2">
Die Maßnahme erfüllt Aufgaben der verfassten
Studierendenschaft:{" "}
{formData.vsmFlags.aufgaben ? "Ja" : "Nein"}
</Typography>
<Typography variant="body2">
Es werden keine Einzelpersonen von der Maßnahme gefördert:{" "}
{formData.vsmFlags.individuell ? "Ja" : "Nein"}
</Typography>
</Box>
)}
{formData.qsmFlags && currentApplication.variant === "QSM" && (
<Box sx={{ mt: 2 }}>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
>
Zusätzliche Angaben:
</Typography>
<Typography variant="body2">
Es handelt sich um Stellenfinanzierungen:{" "}
{formData.qsmFlags.stellenfinanzierungen ? "Ja" : "Nein"}
</Typography>
<Typography variant="body2">
Die Studierenden werden an der Planung und Durchführung
der Maßnahme beteiligt:{" "}
{formData.qsmFlags.studierende ? "Ja" : "Nein"}
</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>
)}
{formData.qsmFlags.exkursionBezuschusst !== undefined && (
<Typography variant="body2">
Die Exkursion wird bereits aus anderen Mitteln
bezuschusst:{" "}
{formData.qsmFlags.exkursionBezuschusst ? "Ja" : "Nein"}
</Typography>
)}
</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>
)}
{/* Costs Breakdown */}
{formData.costs && formData.costs.length > 0 && (
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Kostenpositionen
</Typography>
{formData.costs
.filter((cost) => cost.amountEur && cost.amountEur > 0)
.map((cost, index) => (
<Box
key={index}
sx={{
display: "flex",
justifyContent: "space-between",
py: 1,
}}
>
<Typography variant="body2">{cost.name}</Typography>
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
{cost.amountEur?.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
</Typography>
</Box>
))}
<Divider sx={{ my: 1 }} />
<Box
sx={{
display: "flex",
justifyContent: "space-between",
fontWeight: "bold",
}}
>
<Typography variant="subtitle1" color="text.primary">
Gesamt:
</Typography>
<Typography variant="subtitle1" color="primary">
{formData.requestedAmountEur?.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
}) || "0,00 €"}
</Typography>
</Box>
</Paper>
)}
</Grid>
{/* Sidebar */}
<Grid item xs={12} md={4}>
{/* Status Card */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Status & Zeitstempel
</Typography>
<Box sx={{ mb: 2 }}>
{editingStatus ? (
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
<TextField
select
fullWidth
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
size="small"
>
<MenuItem value="new">Neu</MenuItem>
<MenuItem value="in-review">In Prüfung</MenuItem>
<MenuItem value="approved">Genehmigt</MenuItem>
<MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField>
<Button
size="small"
onClick={handleStatusChange}
startIcon={<Save />}
>
Speichern
</Button>
</Box>
) : (
<Chip
label={translateStatus(currentApplication.status)}
color={getStatusColor(currentApplication.status)}
onClick={() => setEditingStatus(true)}
sx={{ cursor: "pointer" }}
/>
)}
</Box>
<Typography variant="body2" color="text.secondary">
Erstellt:{" "}
{dayjs(currentApplication.created_at).format(
"DD.MM.YYYY HH:mm",
)}
</Typography>
<Typography variant="body2" color="text.secondary">
Zuletzt geändert:{" "}
{dayjs(currentApplication.updated_at).format(
"DD.MM.YYYY HH:mm",
)}
</Typography>
</CardContent>
</Card>
{/* Participating Faculties */}
{formData.participatingFaculties && (
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
Teilnehmende Fakultäten
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1 }}>
{Object.entries(formData.participatingFaculties).map(
([faculty, participating]) =>
participating ? (
<Chip
key={faculty}
label={faculty.toUpperCase()}
size="small"
color="primary"
variant="outlined"
/>
) : null,
)}
</Box>
</CardContent>
</Card>
)}
{/* Admin Actions */}
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Admin-Aktionen
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Button
fullWidth
variant="outlined"
startIcon={<Download />}
onClick={handleDownloadPdf}
>
PDF herunterladen
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<Edit />}
onClick={() => navigate(`/admin/applications/${paId}/edit`)}
>
Antrag bearbeiten
</Button>
<Button
fullWidth
variant="outlined"
startIcon={<SwapVert />}
onClick={() => setEditingStatus(true)}
disabled={editingStatus}
>
Status ändern
</Button>
<Button
fullWidth
variant="outlined"
color="error"
startIcon={<Delete />}
onClick={() => setShowDeleteDialog(true)}
>
Antrag löschen
</Button>
</Box>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Delete Confirmation Dialog */}
<Dialog
open={showDeleteDialog}
onClose={() => setShowDeleteDialog(false)}
>
<DialogTitle>Antrag löschen</DialogTitle>
<DialogContent>
<Typography>
Sind Sie sicher, dass Sie den Antrag {paId} unwiderruflich löschen
möchten? Diese Aktion kann nicht rückgängig gemacht werden.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowDeleteDialog(false)}>Abbrechen</Button>
<Button
onClick={() => {
handleDelete();
setShowDeleteDialog(false);
}}
color="error"
variant="contained"
>
Löschen
</Button>
</DialogActions>
</Dialog>
{/* Reset Credentials Dialog */}
<Dialog
open={showResetDialog}
onClose={() => {
setShowResetDialog(false);
setNewCredentials(null);
setCopiedField(null);
}}
>
<DialogTitle>Zugangsdaten zurücksetzen</DialogTitle>
<DialogContent>
{!newCredentials ? (
<Typography>
Möchten Sie neue Zugangsdaten für den Antrag {paId} generieren?
Die alten Zugangsdaten werden ungültig.
</Typography>
) : (
<Box>
<Alert severity="success" sx={{ mb: 2 }}>
Neue Zugangsdaten wurden generiert!
</Alert>
<Paper
variant="outlined"
sx={{
p: 2,
backgroundColor: (theme) =>
theme.palette.mode === "dark"
? theme.palette.grey[900]
: theme.palette.grey[50],
mb: 2,
}}
>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
>
Antrags-ID:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
}}
>
<Typography variant="body1" sx={{ fontFamily: "monospace" }}>
{newCredentials.pa_id}
</Typography>
<IconButton
size="small"
onClick={() => copyToClipboard(newCredentials.pa_id, "id")}
>
<ContentCopy fontSize="small" />
</IconButton>
{copiedField === "id" && (
<Typography variant="caption" color="success.main">
Kopiert!
</Typography>
)}
</Box>
<Typography
variant="subtitle2"
color="text.secondary"
gutterBottom
>
Neuer Zugangsschlüssel:
</Typography>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body1" sx={{ fontFamily: "monospace" }}>
{newCredentials.pa_key}
</Typography>
<IconButton
size="small"
onClick={() =>
copyToClipboard(newCredentials.pa_key, "key")
}
>
<ContentCopy fontSize="small" />
</IconButton>
{copiedField === "key" && (
<Typography variant="caption" color="success.main">
Kopiert!
</Typography>
)}
</Box>
</Paper>
<Alert severity="warning">
Bitte speichern Sie diese Zugangsdaten sicher ab und teilen Sie
sie dem Antragsteller mit.
</Alert>
</Box>
)}
</DialogContent>
<DialogActions>
<Button
onClick={() => {
setShowResetDialog(false);
setNewCredentials(null);
setCopiedField(null);
}}
>
{newCredentials ? "Schließen" : "Abbrechen"}
</Button>
{!newCredentials && (
<Button onClick={handleResetCredentials} variant="contained">
Zugangsdaten zurücksetzen
</Button>
)}
</DialogActions>
</Dialog>
</Container>
);
};
export default AdminApplicationView;