calendar: add category-group links, fix iCal share URL, remove legend

- Link categories to user groups via new zielgruppen column on
  veranstaltung_kategorien (migration 017), editable in the category
  management UI with group checkboxes and chip display
- Fix broken iCal share link by adding ICAL_BASE_URL to docker-compose
  and falling back to CORS_ORIGIN when ICAL_BASE_URL is unset
- Remove the colored-dot legend footer from the month calendar view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-03-03 08:10:33 +01:00
parent 9a6b9511c8
commit ad069fde10
9 changed files with 101 additions and 39 deletions

View File

@@ -435,29 +435,6 @@ function MonthCalendar({
);
})}
</Box>
{/* Legend */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1.5, mt: 2 }}>
{Object.entries(TYP_DOT_COLOR).map(([typ, color]) => (
<Box key={typ} sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: color }} />
<Typography variant="caption" color="text.secondary">{typ}</Typography>
</Box>
))}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box
sx={{
width: 8, height: 8, borderRadius: '50%',
bgcolor: 'warning.main', border: '1.5px solid', borderColor: 'warning.dark',
}}
/>
<Typography variant="caption" color="text.secondary">Pflichtveranstaltung</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Box sx={{ width: 8, height: 8, borderRadius: '50%', bgcolor: '#1976d2' }} />
<Typography variant="caption" color="text.secondary">Veranstaltung</Typography>
</Box>
</Box>
</Box>
);
}

View File

@@ -23,6 +23,9 @@ import {
Paper,
Tooltip,
Chip,
Checkbox,
FormGroup,
FormControlLabel,
} from '@mui/material';
import {
Add,
@@ -34,7 +37,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
import { useAuth } from '../contexts/AuthContext';
import { useNotification } from '../contexts/NotificationContext';
import { eventsApi } from '../services/events';
import type { VeranstaltungKategorie } from '../types/events.types';
import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types';
// ---------------------------------------------------------------------------
// Category Form Dialog
@@ -45,6 +48,7 @@ interface KategorieFormData {
beschreibung: string;
farbe: string;
icon: string;
zielgruppen: string[];
}
const EMPTY_FORM: KategorieFormData = {
@@ -52,6 +56,7 @@ const EMPTY_FORM: KategorieFormData = {
beschreibung: '',
farbe: '#1976d2',
icon: '',
zielgruppen: [],
};
interface KategorieDialogProps {
@@ -59,9 +64,10 @@ interface KategorieDialogProps {
onClose: () => void;
onSaved: () => void;
editing: VeranstaltungKategorie | null;
groups: GroupInfo[];
}
function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogProps) {
function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieDialogProps) {
const notification = useNotification();
const [loading, setLoading] = useState(false);
const [form, setForm] = useState<KategorieFormData>({ ...EMPTY_FORM });
@@ -74,6 +80,7 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
beschreibung: editing.beschreibung ?? '',
farbe: editing.farbe,
icon: editing.icon ?? '',
zielgruppen: editing.zielgruppen ?? [],
});
} else {
setForm({ ...EMPTY_FORM });
@@ -84,6 +91,15 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
setForm((prev) => ({ ...prev, [field]: value }));
};
const handleGroupToggle = (groupId: string) => {
setForm((prev) => ({
...prev,
zielgruppen: prev.zielgruppen.includes(groupId)
? prev.zielgruppen.filter((g) => g !== groupId)
: [...prev.zielgruppen, groupId],
}));
};
const handleSave = async () => {
if (!form.name.trim()) {
notification.showError('Name ist erforderlich');
@@ -96,6 +112,7 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
beschreibung: form.beschreibung.trim() || undefined,
farbe: form.farbe,
icon: form.icon.trim() || undefined,
zielgruppen: form.zielgruppen,
};
if (editing) {
await eventsApi.updateKategorie(editing.id, payload);
@@ -171,6 +188,29 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
placeholder="z.B. EmojiEvents"
helperText="Name eines MUI Material Icons"
/>
{/* Group checkboxes */}
{groups.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Zielgruppen
</Typography>
<FormGroup>
{groups.map((group) => (
<FormControlLabel
key={group.id}
control={
<Checkbox
checked={form.zielgruppen.includes(group.id)}
onChange={() => handleGroupToggle(group.id)}
size="small"
/>
}
label={group.label}
/>
))}
</FormGroup>
</Box>
)}
</Stack>
</DialogContent>
<DialogActions>
@@ -244,6 +284,7 @@ export default function VeranstaltungKategorien() {
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
const [groups, setGroups] = useState<GroupInfo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -258,8 +299,12 @@ export default function VeranstaltungKategorien() {
setLoading(true);
setError(null);
try {
const data = await eventsApi.getKategorien();
const [data, groupData] = await Promise.all([
eventsApi.getKategorien(),
eventsApi.getGroups(),
]);
setKategorien(data);
setGroups(groupData);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien';
setError(msg);
@@ -324,13 +369,14 @@ export default function VeranstaltungKategorien() {
<TableCell sx={{ fontWeight: 700 }}>Name</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Beschreibung</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Icon</TableCell>
<TableCell sx={{ fontWeight: 700 }}>Gruppen</TableCell>
{canManage && <TableCell align="right" sx={{ fontWeight: 700 }}>Aktionen</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{kategorien.length === 0 ? (
<TableRow>
<TableCell colSpan={canManage ? 5 : 4} align="center" sx={{ py: 4 }}>
<TableCell colSpan={canManage ? 6 : 5} align="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
Noch keine Kategorien vorhanden.
</Typography>
@@ -386,6 +432,27 @@ export default function VeranstaltungKategorien() {
</Typography>
</TableCell>
{/* Gruppen */}
<TableCell>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{(kat.zielgruppen ?? []).length === 0
? <Typography variant="body2" color="text.secondary"></Typography>
: (kat.zielgruppen ?? []).map((gId) => {
const group = groups.find((g) => g.id === gId);
return (
<Chip
key={gId}
label={group?.label ?? gId}
size="small"
variant="outlined"
sx={{ fontSize: '0.75rem' }}
/>
);
})
}
</Box>
</TableCell>
{/* Actions */}
{canManage && (
<TableCell align="right">
@@ -424,6 +491,7 @@ export default function VeranstaltungKategorien() {
onClose={() => { setFormOpen(false); setEditingKat(null); }}
onSaved={loadKategorien}
editing={editingKat}
groups={groups}
/>
{/* Delete Confirm Dialog */}