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