Add Help-Floater

This commit is contained in:
Frederik Beimgraben 2025-09-01 17:22:38 +02:00
parent 629086fdb6
commit 91372c73b4
6 changed files with 279 additions and 21 deletions

View File

@ -422,7 +422,7 @@ def _inject_meta_for_render(payload: Dict[str, Any], pa_id: str, pa_key: Optiona
p2["pa"]["meta"]["key"] = pa_key p2["pa"]["meta"]["key"] = pa_key
# Calculate total amount from costs dynamically # Calculate total amount from costs dynamically
project = p2.get("pa", {}).get("project", {}) project = p2.get("pa", {}).get("project", {}) or {}
costs = project.get("costs", []) costs = project.get("costs", [])
total_amount = 0.0 total_amount = 0.0
@ -446,7 +446,7 @@ def _sanitize_payload_for_db(payload: Dict[str, Any]) -> Dict[str, Any]:
meta["key"] = None meta["key"] = None
# Remove calculated total from database storage # Remove calculated total from database storage
project = p2.get("pa", {}).get("project", {}) project = p2.get("pa", {}).get("project", {}) or {}
if "totals" in project and "requestedAmountEur" in project["totals"]: if "totals" in project and "requestedAmountEur" in project["totals"]:
del project["totals"]["requestedAmountEur"] del project["totals"]["requestedAmountEur"]
@ -517,8 +517,8 @@ def create_application(
financing_data = project_data.get("financing", {}) financing_data = project_data.get("financing", {})
# Check which financing type has actual content (not just empty structure) # Check which financing type has actual content (not just empty structure)
qsm_data = financing_data.get("qsm", {}) qsm_data = financing_data.get("qsm", {}) or {}
vsm_data = financing_data.get("vsm", {}) vsm_data = financing_data.get("vsm", {}) or {}
# QSM has 'code' and 'flags' fields when filled # QSM has 'code' and 'flags' fields when filled
has_qsm_content = bool(qsm_data.get("code") or qsm_data.get("flags")) has_qsm_content = bool(qsm_data.get("code") or qsm_data.get("flags"))
@ -527,7 +527,7 @@ def create_application(
# Also check institution fields (VSM-specific) # Also check institution fields (VSM-specific)
# Note: Institution name alone doesn't determine variant, as QSM can also have institution name # Note: Institution name alone doesn't determine variant, as QSM can also have institution name
institution_data = pa_data.get("applicant", {}).get("institution", {}) institution_data = pa_data.get("applicant", {}).get("institution", {}) or {}
has_institution_type = bool(institution_data.get("type")) # Only type is VSM-specific has_institution_type = bool(institution_data.get("type")) # Only type is VSM-specific
# Determine variant based on which fields have actual content # Determine variant based on which fields have actual content
@ -746,12 +746,21 @@ def list_applications(
rows = db.execute(q).scalars().all() rows = db.execute(q).scalars().all()
result = [] result = []
for r in rows: for r in rows:
# Extract project name from payload if available # Extract project name and calculate total amount from payload if available
project_name = "" project_name = ""
total_amount = 0.0
if r.payload_json: if r.payload_json:
try: try:
payload = json.loads(r.payload_json) if isinstance(r.payload_json, str) else r.payload_json payload = json.loads(r.payload_json) if isinstance(r.payload_json, str) else r.payload_json
project_name = payload.get("pa", {}).get("project", {}).get("name", "") project = payload.get("pa", {}).get("project", {})
project_name = project.get("name", "")
# Calculate total from costs
costs = project.get("costs", [])
for cost in costs:
if isinstance(cost, dict) and "amountEur" in cost:
amount = cost.get("amountEur")
if amount is not None and isinstance(amount, (int, float)):
total_amount += float(amount)
except: except:
pass pass
@ -760,6 +769,7 @@ def list_applications(
"variant": "VSM" if r.variant == "COMMON" else r.variant, "variant": "VSM" if r.variant == "COMMON" else r.variant,
"status": r.status, "status": r.status,
"project_name": project_name, "project_name": project_name,
"total_amount": total_amount,
"created_at": r.created_at.isoformat(), "created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat() "updated_at": r.updated_at.isoformat()
}) })
@ -772,12 +782,21 @@ def list_applications(
app_row: Application = auth.get("app") app_row: Application = auth.get("app")
if not app_row: if not app_row:
raise HTTPException(status_code=404, detail="Application not found") raise HTTPException(status_code=404, detail="Application not found")
# Extract project name from payload if available # Extract project name and calculate total amount from payload if available
project_name = "" project_name = ""
total_amount = 0.0
if app_row.payload_json: if app_row.payload_json:
try: try:
payload = json.loads(app_row.payload_json) if isinstance(app_row.payload_json, str) else app_row.payload_json payload = json.loads(app_row.payload_json) if isinstance(app_row.payload_json, str) else app_row.payload_json
project_name = payload.get("pa", {}).get("project", {}).get("name", "") project = payload.get("pa", {}).get("project", {})
project_name = project.get("name", "")
# Calculate total from costs
costs = project.get("costs", [])
for cost in costs:
if isinstance(cost, dict) and "amountEur" in cost:
amount = cost.get("amountEur")
if amount is not None and isinstance(amount, (int, float)):
total_amount += float(amount)
except: except:
pass pass
@ -786,6 +805,7 @@ def list_applications(
"variant": "VSM" if app_row.variant == "COMMON" else app_row.variant, "variant": "VSM" if app_row.variant == "COMMON" else app_row.variant,
"status": app_row.status, "status": app_row.status,
"project_name": project_name, "project_name": project_name,
"total_amount": total_amount,
"created_at": app_row.created_at.isoformat(), "created_at": app_row.created_at.isoformat(),
"updated_at": app_row.updated_at.isoformat() "updated_at": app_row.updated_at.isoformat()
}] }]

View File

@ -10,7 +10,7 @@ import CssBaseline from "@mui/material/CssBaseline";
import { SnackbarProvider } from "notistack"; import { SnackbarProvider } from "notistack";
import { QueryClient, QueryClientProvider } from "react-query"; import { QueryClient, QueryClientProvider } from "react-query";
import { ReactQueryDevtools } from "react-query/devtools"; import { ReactQueryDevtools } from "react-query/devtools";
import { IconButton, Box } from "@mui/material"; import { Fab, Box } from "@mui/material";
import { Brightness4, Brightness7 } from "@mui/icons-material"; import { Brightness4, Brightness7 } from "@mui/icons-material";
// Components // Components
@ -25,6 +25,7 @@ import HelpPage from "./pages/HelpPage";
import EditApplicationPage from "./pages/EditApplicationPage"; import EditApplicationPage from "./pages/EditApplicationPage";
import ErrorBoundary from "./components/ErrorBoundary/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary/ErrorBoundary";
import LoadingSpinner from "./components/LoadingSpinner/LoadingSpinner"; import LoadingSpinner from "./components/LoadingSpinner/LoadingSpinner";
import HelpButton from "./components/HelpButton";
// Store // Store
import { useApplicationStore } from "./store/applicationStore"; import { useApplicationStore } from "./store/applicationStore";
@ -245,24 +246,28 @@ const App: React.FC = () => {
<Box <Box
sx={{ sx={{
position: "fixed", position: "fixed",
bottom: 16, bottom: 24,
right: 16, right: 24,
zIndex: 1200, zIndex: 1200,
display: "flex",
flexDirection: "column",
gap: 1,
}} }}
> >
<IconButton <Fab
onClick={handleToggleDarkMode} onClick={handleToggleDarkMode}
color="inherit" size="large"
sx={{ sx={{
bgcolor: "background.paper", bgcolor: "background.paper",
boxShadow: 2, boxShadow: 2,
"&:hover": { "&:hover": {
bgcolor: "action.hover", bgcolor: "action.hover",
}, },
color: darkMode ? "white" : "black",
}} }}
> >
{darkMode ? <Brightness7 /> : <Brightness4 />} {darkMode ? <Brightness7 /> : <Brightness4 />}
</IconButton> </Fab>
</Box> </Box>
<SnackbarProvider <SnackbarProvider
maxSnack={3} maxSnack={3}
@ -351,6 +356,9 @@ const App: React.FC = () => {
{process.env.NODE_ENV === "development" && ( {process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
)} )}
{/* Help Button - Always visible */}
<HelpButton />
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</ErrorBoundary> </ErrorBoundary>

View File

@ -0,0 +1,192 @@
import React, { useState } from "react";
import {
Fab,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Typography,
Divider,
Box,
} from "@mui/material";
import { Help, Phone, Event, BugReport, Close } from "@mui/icons-material";
interface HelpButtonProps {
className?: string;
}
const HelpButton: React.FC<HelpButtonProps> = ({ className }) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handlePhoneClick = () => {
window.location.href = "tel:+4971212711099";
handleClose();
};
const handleBookingClick = () => {
window.open(
"https://app.reclaim.ai/m/verfasste-studierendenschaft/help",
"_blank",
"noopener,noreferrer",
);
handleClose();
};
const handleFeedbackClick = () => {
window.open(
"https://cloud.reutlingen.university/apps/forms/s/gZwfmxt7q2YBDXm5dBwicqmn",
"_blank",
"noopener,noreferrer",
);
handleClose();
};
return (
<>
<Fab
color="primary"
aria-label="help"
onClick={handleClick}
className={className}
size="large"
sx={{
position: "fixed",
bottom: 96,
right: 24,
zIndex: 1300,
boxShadow: 3,
"&:hover": {
boxShadow: 6,
},
}}
>
{open ? <Close /> : <Help />}
</Fab>
<Menu
anchorEl={anchorEl}
open={open}
onClose={handleClose}
anchorOrigin={{
vertical: "top",
horizontal: "left",
}}
transformOrigin={{
vertical: "bottom",
horizontal: "right",
}}
PaperProps={{
sx: {
minWidth: 280,
boxShadow: 3,
borderRadius: 2,
},
}}
>
{/* Header */}
<Box
sx={{
px: 2,
py: 1.5,
backgroundColor: "primary.main",
color: "white",
}}
>
<Typography
variant="subtitle1"
fontWeight="bold"
sx={{ color: "white" }}
>
Hilfe & Kontakt
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
STUPA Reutlingen University
</Typography>
</Box>
<Divider />
{/* Phone Contact */}
<MenuItem onClick={handlePhoneClick} sx={{ py: 1.5 }}>
<ListItemIcon>
<Phone color="primary" />
</ListItemIcon>
<ListItemText>
<Typography variant="body1" fontWeight="medium">
STUPA-Büro anrufen
</Typography>
<Typography variant="body2" color="text.secondary">
+49 7121 271 1099
</Typography>
</ListItemText>
</MenuItem>
<Divider variant="inset" component="li" />
{/* Booking Link */}
<MenuItem onClick={handleBookingClick} sx={{ py: 1.5 }}>
<ListItemIcon>
<Event color="primary" />
</ListItemIcon>
<ListItemText>
<Typography variant="body1" fontWeight="medium">
Termin vereinbaren
</Typography>
<Typography variant="body2" color="text.secondary">
Über Reclaim buchen
</Typography>
</ListItemText>
</MenuItem>
<Divider variant="inset" component="li" />
{/* Feedback Form */}
<MenuItem onClick={handleFeedbackClick} sx={{ py: 1.5 }}>
<ListItemIcon>
<BugReport color="primary" />
</ListItemIcon>
<ListItemText>
<Typography variant="body1" fontWeight="medium">
Feedback & Fehler melden
</Typography>
<Typography variant="body2" color="text.secondary">
Formular ausfüllen
</Typography>
</ListItemText>
</MenuItem>
<Divider />
{/* Footer Info */}
<Box
sx={{
px: 2,
py: 1,
backgroundColor: (theme) =>
theme.palette.mode === "dark" ? "grey.900" : "grey.50",
}}
>
<Typography
variant="caption"
color="text.secondary"
align="center"
display="block"
>
Bei technischen Problemen oder Fragen zur Antragstellung
</Typography>
</Box>
</Menu>
</>
);
};
export default HelpButton;

View File

@ -0,0 +1 @@
export { default } from "./HelpButton";

View File

@ -126,6 +126,10 @@ const AdminDashboard: React.FC = () => {
.length, .length,
approved: applicationList.filter((app) => app.status === "approved").length, approved: applicationList.filter((app) => app.status === "approved").length,
rejected: applicationList.filter((app) => app.status === "rejected").length, rejected: applicationList.filter((app) => app.status === "rejected").length,
totalAmount: applicationList.reduce(
(sum, app) => sum + (app.total_amount || 0),
0,
),
}; };
return ( return (
@ -154,7 +158,7 @@ const AdminDashboard: React.FC = () => {
{/* Statistics Cards */} {/* Statistics Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}> <Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={2.4}> <Grid item xs={12} sm={6} md={2}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="primary"> <Typography variant="h4" color="primary">
@ -166,7 +170,7 @@ const AdminDashboard: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2.4}> <Grid item xs={12} sm={6} md={2}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="info.main"> <Typography variant="h4" color="info.main">
@ -178,7 +182,7 @@ const AdminDashboard: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2.4}> <Grid item xs={12} sm={6} md={2}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="warning.main"> <Typography variant="h4" color="warning.main">
@ -190,7 +194,7 @@ const AdminDashboard: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2.4}> <Grid item xs={12} sm={6} md={2}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="success.main"> <Typography variant="h4" color="success.main">
@ -202,7 +206,7 @@ const AdminDashboard: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2.4}> <Grid item xs={12} sm={6} md={2}>
<Card> <Card>
<CardContent sx={{ textAlign: "center" }}> <CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="error.main"> <Typography variant="h4" color="error.main">
@ -214,6 +218,29 @@ const AdminDashboard: React.FC = () => {
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
<Grid item xs={12} sm={6} md={2}>
<Card>
<CardContent sx={{ textAlign: "center" }}>
<Typography
variant="h5"
color="secondary.main"
sx={{
fontWeight: "bold",
fontSize: { xs: "1rem", sm: "1.25rem" },
}}
>
{stats.totalAmount.toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
maximumFractionDigits: 0,
})}
</Typography>
<Typography variant="body2" color="text.secondary">
Gesamtsumme
</Typography>
</CardContent>
</Card>
</Grid>
</Grid> </Grid>
{/* Filters and Search */} {/* Filters and Search */}
@ -285,6 +312,7 @@ const AdminDashboard: React.FC = () => {
<TableCell>Projektname</TableCell> <TableCell>Projektname</TableCell>
<TableCell>Typ</TableCell> <TableCell>Typ</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell align="right">Summe</TableCell>
<TableCell>Erstellt</TableCell> <TableCell>Erstellt</TableCell>
<TableCell>Geändert</TableCell> <TableCell>Geändert</TableCell>
<TableCell align="right">Aktionen</TableCell> <TableCell align="right">Aktionen</TableCell>
@ -293,7 +321,7 @@ const AdminDashboard: React.FC = () => {
<TableBody> <TableBody>
{applicationList.length === 0 && !isLoading ? ( {applicationList.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} align="center"> <TableCell colSpan={8} align="center">
<Typography color="text.secondary"> <Typography color="text.secondary">
Keine Anträge gefunden Keine Anträge gefunden
</Typography> </Typography>
@ -340,6 +368,14 @@ const AdminDashboard: React.FC = () => {
<MenuItem value="rejected">Abgelehnt</MenuItem> <MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField> </TextField>
</TableCell> </TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
{(application.total_amount || 0).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
})}
</Typography>
</TableCell>
<TableCell> <TableCell>
<Typography variant="body2"> <Typography variant="body2">
{dayjs(application.created_at).format("DD.MM.YY HH:mm")} {dayjs(application.created_at).format("DD.MM.YY HH:mm")}

View File

@ -151,6 +151,7 @@ export interface ApplicationListItem {
variant: string; variant: string;
status: string; status: string;
project_name?: string; project_name?: string;
total_amount?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }