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

@@ -0,0 +1,7 @@
-- Migration 017: Add zielgruppen (target groups) to event categories
-- Links categories to user groups for visibility filtering
ALTER TABLE veranstaltung_kategorien
ADD COLUMN IF NOT EXISTS zielgruppen TEXT[] NOT NULL DEFAULT '{}';
-- Comment for documentation
COMMENT ON COLUMN veranstaltung_kategorien.zielgruppen IS 'Array of Authentik group names this category is linked to';

View File

@@ -10,6 +10,7 @@ export interface VeranstaltungKategorie {
beschreibung?: string | null;
farbe?: string | null;
icon?: string | null;
zielgruppen: string[];
erstellt_von?: string | null;
erstellt_am: Date;
aktualisiert_am: Date;
@@ -76,6 +77,7 @@ export const CreateKategorieSchema = z.object({
.regex(/^#[0-9a-fA-F]{6}$/, 'Farbe muss ein gültiger Hex-Farbwert sein (z.B. #1976d2)')
.optional(),
icon: z.string().max(100).optional(),
zielgruppen: z.array(z.string()).optional(),
});
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;

View File

@@ -310,7 +310,7 @@ class BookingService {
logger.info('Created new iCal token for user', { userId });
}
const baseUrl = process.env.ICAL_BASE_URL || 'http://localhost:3000';
const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || 'http://localhost:3000').replace(/\/$/, '');
const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`;
return { token, subscribeUrl };
}

View File

@@ -104,7 +104,7 @@ class EventsService {
/** Returns all event categories ordered by name. */
async getKategorien(): Promise<VeranstaltungKategorie[]> {
const result = await pool.query(`
SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien
ORDER BY name ASC
`);
@@ -114,6 +114,7 @@ class EventsService {
beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null,
icon: row.icon ?? null,
zielgruppen: row.zielgruppen ?? [],
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
@@ -123,10 +124,10 @@ class EventsService {
/** Creates a new event category. */
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
const result = await pool.query(
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, userId]
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, erstellt_von)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`,
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId]
);
const row = result.rows[0];
return {
@@ -135,6 +136,7 @@ class EventsService {
beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null,
icon: row.icon ?? null,
zielgruppen: row.zielgruppen ?? [],
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am),
aktualisiert_am: new Date(row.aktualisiert_am),
@@ -151,11 +153,12 @@ class EventsService {
if (data.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung); }
if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
if (data.icon !== undefined) { fields.push(`icon = $${idx++}`); values.push(data.icon); }
if (data.zielgruppen !== undefined) { fields.push(`zielgruppen = $${idx++}`); values.push(data.zielgruppen); }
if (fields.length === 0) {
// Nothing to update — return the existing record
const existing = await pool.query(
`SELECT id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am
`SELECT id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am
FROM veranstaltung_kategorien WHERE id = $1`,
[id]
);
@@ -163,7 +166,8 @@ class EventsService {
const row = existing.rows[0];
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};
}
@@ -174,14 +178,15 @@ class EventsService {
const result = await pool.query(
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
WHERE id = $${idx}
RETURNING id, name, beschreibung, farbe, icon, erstellt_von, erstellt_am, aktualisiert_am`,
RETURNING id, name, beschreibung, farbe, icon, zielgruppen, erstellt_von, erstellt_am, aktualisiert_am`,
values
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return {
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, erstellt_von: row.erstellt_von ?? null,
farbe: row.farbe ?? null, icon: row.icon ?? null, zielgruppen: row.zielgruppen ?? [],
erstellt_von: row.erstellt_von ?? null,
erstellt_am: new Date(row.erstellt_am), aktualisiert_am: new Date(row.aktualisiert_am),
};
}
@@ -419,7 +424,7 @@ class EventsService {
token = inserted.rows[0].token;
}
const baseUrl = (process.env.ICAL_BASE_URL ?? '').replace(/\/$/, '');
const baseUrl = (process.env.ICAL_BASE_URL || process.env.CORS_ORIGIN || '').replace(/\/$/, '');
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
return { token, subscribeUrl };

View File

@@ -41,6 +41,7 @@ services:
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:?AUTHENTIK_CLIENT_SECRET is required}
AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://start.feuerwehr-rems.at/auth/callback}
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.feuerwehr-rems.at}
ICAL_BASE_URL: ${ICAL_BASE_URL:-https://start.feuerwehr-rems.at}
ports:
- "${BACKEND_PORT:-3000}:3000"
depends_on:

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 */}

View File

@@ -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)

View File

@@ -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;
}