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
# Calculate total amount from costs dynamically
project = p2.get("pa", {}).get("project", {})
project = p2.get("pa", {}).get("project", {}) or {}
costs = project.get("costs", [])
total_amount = 0.0
@ -446,7 +446,7 @@ def _sanitize_payload_for_db(payload: Dict[str, Any]) -> Dict[str, Any]:
meta["key"] = None
# 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"]:
del project["totals"]["requestedAmountEur"]
@ -517,8 +517,8 @@ def create_application(
financing_data = project_data.get("financing", {})
# Check which financing type has actual content (not just empty structure)
qsm_data = financing_data.get("qsm", {})
vsm_data = financing_data.get("vsm", {})
qsm_data = financing_data.get("qsm", {}) or {}
vsm_data = financing_data.get("vsm", {}) or {}
# QSM has 'code' and 'flags' fields when filled
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)
# 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
# Determine variant based on which fields have actual content
@ -746,12 +746,21 @@ def list_applications(
rows = db.execute(q).scalars().all()
result = []
for r in rows:
# Extract project name from payload if available
# Extract project name and calculate total amount from payload if available
project_name = ""
total_amount = 0.0
if r.payload_json:
try:
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:
pass
@ -760,6 +769,7 @@ def list_applications(
"variant": "VSM" if r.variant == "COMMON" else r.variant,
"status": r.status,
"project_name": project_name,
"total_amount": total_amount,
"created_at": r.created_at.isoformat(),
"updated_at": r.updated_at.isoformat()
})
@ -772,12 +782,21 @@ def list_applications(
app_row: Application = auth.get("app")
if not app_row:
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 = ""
total_amount = 0.0
if app_row.payload_json:
try:
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:
pass
@ -786,6 +805,7 @@ def list_applications(
"variant": "VSM" if app_row.variant == "COMMON" else app_row.variant,
"status": app_row.status,
"project_name": project_name,
"total_amount": total_amount,
"created_at": app_row.created_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 { QueryClient, QueryClientProvider } from "react-query";
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";
// Components
@ -25,6 +25,7 @@ import HelpPage from "./pages/HelpPage";
import EditApplicationPage from "./pages/EditApplicationPage";
import ErrorBoundary from "./components/ErrorBoundary/ErrorBoundary";
import LoadingSpinner from "./components/LoadingSpinner/LoadingSpinner";
import HelpButton from "./components/HelpButton";
// Store
import { useApplicationStore } from "./store/applicationStore";
@ -245,24 +246,28 @@ const App: React.FC = () => {
<Box
sx={{
position: "fixed",
bottom: 16,
right: 16,
bottom: 24,
right: 24,
zIndex: 1200,
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
<IconButton
<Fab
onClick={handleToggleDarkMode}
color="inherit"
size="large"
sx={{
bgcolor: "background.paper",
boxShadow: 2,
"&:hover": {
bgcolor: "action.hover",
},
color: darkMode ? "white" : "black",
}}
>
{darkMode ? <Brightness7 /> : <Brightness4 />}
</IconButton>
</Fab>
</Box>
<SnackbarProvider
maxSnack={3}
@ -351,6 +356,9 @@ const App: React.FC = () => {
{process.env.NODE_ENV === "development" && (
<ReactQueryDevtools initialIsOpen={false} />
)}
{/* Help Button - Always visible */}
<HelpButton />
</ThemeProvider>
</QueryClientProvider>
</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,
approved: applicationList.filter((app) => app.status === "approved").length,
rejected: applicationList.filter((app) => app.status === "rejected").length,
totalAmount: applicationList.reduce(
(sum, app) => sum + (app.total_amount || 0),
0,
),
};
return (
@ -154,7 +158,7 @@ const AdminDashboard: React.FC = () => {
{/* Statistics Cards */}
<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>
<CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="primary">
@ -166,7 +170,7 @@ const AdminDashboard: React.FC = () => {
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<Grid item xs={12} sm={6} md={2}>
<Card>
<CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="info.main">
@ -178,7 +182,7 @@ const AdminDashboard: React.FC = () => {
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<Grid item xs={12} sm={6} md={2}>
<Card>
<CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="warning.main">
@ -190,7 +194,7 @@ const AdminDashboard: React.FC = () => {
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<Grid item xs={12} sm={6} md={2}>
<Card>
<CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="success.main">
@ -202,7 +206,7 @@ const AdminDashboard: React.FC = () => {
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={2.4}>
<Grid item xs={12} sm={6} md={2}>
<Card>
<CardContent sx={{ textAlign: "center" }}>
<Typography variant="h4" color="error.main">
@ -214,6 +218,29 @@ const AdminDashboard: React.FC = () => {
</CardContent>
</Card>
</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>
{/* Filters and Search */}
@ -285,6 +312,7 @@ const AdminDashboard: React.FC = () => {
<TableCell>Projektname</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Summe</TableCell>
<TableCell>Erstellt</TableCell>
<TableCell>Geändert</TableCell>
<TableCell align="right">Aktionen</TableCell>
@ -293,7 +321,7 @@ const AdminDashboard: React.FC = () => {
<TableBody>
{applicationList.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={7} align="center">
<TableCell colSpan={8} align="center">
<Typography color="text.secondary">
Keine Anträge gefunden
</Typography>
@ -340,6 +368,14 @@ const AdminDashboard: React.FC = () => {
<MenuItem value="rejected">Abgelehnt</MenuItem>
</TextField>
</TableCell>
<TableCell align="right">
<Typography variant="body2" sx={{ fontWeight: "medium" }}>
{(application.total_amount || 0).toLocaleString("de-DE", {
style: "currency",
currency: "EUR",
})}
</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">
{dayjs(application.created_at).format("DD.MM.YY HH:mm")}

View File

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