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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -37,6 +37,7 @@ export const eventsApi = {
|
||||
beschreibung?: string;
|
||||
farbe?: string;
|
||||
icon?: string;
|
||||
zielgruppen?: string[];
|
||||
}): Promise<VeranstaltungKategorie> {
|
||||
return api
|
||||
.post<ApiResponse<VeranstaltungKategorie>>('/api/events/kategorien', data)
|
||||
@@ -46,7 +47,7 @@ export const eventsApi = {
|
||||
/** Update an existing event category */
|
||||
updateKategorie(
|
||||
id: string,
|
||||
data: Partial<{ name: string; beschreibung?: string; farbe?: string; icon?: string }>
|
||||
data: Partial<{ name: string; beschreibung?: string; farbe?: string; icon?: string; zielgruppen?: string[] }>
|
||||
): Promise<VeranstaltungKategorie> {
|
||||
return api
|
||||
.patch<ApiResponse<VeranstaltungKategorie>>(`/api/events/kategorien/${id}`, data)
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface VeranstaltungKategorie {
|
||||
beschreibung?: string | null;
|
||||
farbe: string; // hex color e.g. '#1976d2'
|
||||
icon?: string | null; // MUI icon name
|
||||
zielgruppen: string[];
|
||||
erstellt_am: string;
|
||||
aktualisiert_am: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user