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;
|
beschreibung?: string | null;
|
||||||
farbe?: string | null;
|
farbe?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
|
zielgruppen: string[];
|
||||||
erstellt_von?: string | null;
|
erstellt_von?: string | null;
|
||||||
erstellt_am: Date;
|
erstellt_am: Date;
|
||||||
aktualisiert_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)')
|
.regex(/^#[0-9a-fA-F]{6}$/, 'Farbe muss ein gültiger Hex-Farbwert sein (z.B. #1976d2)')
|
||||||
.optional(),
|
.optional(),
|
||||||
icon: z.string().max(100).optional(),
|
icon: z.string().max(100).optional(),
|
||||||
|
zielgruppen: z.array(z.string()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;
|
export type CreateKategorieData = z.infer<typeof CreateKategorieSchema>;
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ class BookingService {
|
|||||||
logger.info('Created new iCal token for user', { userId });
|
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}`;
|
const subscribeUrl = `${baseUrl}/api/bookings/calendar.ics?token=${token}`;
|
||||||
return { token, subscribeUrl };
|
return { token, subscribeUrl };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class EventsService {
|
|||||||
/** Returns all event categories ordered by name. */
|
/** Returns all event categories ordered by name. */
|
||||||
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
async getKategorien(): Promise<VeranstaltungKategorie[]> {
|
||||||
const result = await pool.query(`
|
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
|
FROM veranstaltung_kategorien
|
||||||
ORDER BY name ASC
|
ORDER BY name ASC
|
||||||
`);
|
`);
|
||||||
@@ -114,6 +114,7 @@ class EventsService {
|
|||||||
beschreibung: row.beschreibung ?? null,
|
beschreibung: row.beschreibung ?? null,
|
||||||
farbe: row.farbe ?? null,
|
farbe: row.farbe ?? null,
|
||||||
icon: row.icon ?? null,
|
icon: row.icon ?? null,
|
||||||
|
zielgruppen: row.zielgruppen ?? [],
|
||||||
erstellt_von: row.erstellt_von ?? null,
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
erstellt_am: new Date(row.erstellt_am),
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
aktualisiert_am: new Date(row.aktualisiert_am),
|
aktualisiert_am: new Date(row.aktualisiert_am),
|
||||||
@@ -123,10 +124,10 @@ class EventsService {
|
|||||||
/** Creates a new event category. */
|
/** Creates a new event category. */
|
||||||
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
|
async createKategorie(data: CreateKategorieData, userId: string): Promise<VeranstaltungKategorie> {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, erstellt_von)
|
`INSERT INTO veranstaltung_kategorien (name, beschreibung, farbe, icon, zielgruppen, erstellt_von)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
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`,
|
||||||
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, userId]
|
[data.name, data.beschreibung ?? null, data.farbe ?? null, data.icon ?? null, data.zielgruppen ?? [], userId]
|
||||||
);
|
);
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
return {
|
return {
|
||||||
@@ -135,6 +136,7 @@ class EventsService {
|
|||||||
beschreibung: row.beschreibung ?? null,
|
beschreibung: row.beschreibung ?? null,
|
||||||
farbe: row.farbe ?? null,
|
farbe: row.farbe ?? null,
|
||||||
icon: row.icon ?? null,
|
icon: row.icon ?? null,
|
||||||
|
zielgruppen: row.zielgruppen ?? [],
|
||||||
erstellt_von: row.erstellt_von ?? null,
|
erstellt_von: row.erstellt_von ?? null,
|
||||||
erstellt_am: new Date(row.erstellt_am),
|
erstellt_am: new Date(row.erstellt_am),
|
||||||
aktualisiert_am: new Date(row.aktualisiert_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.beschreibung !== undefined) { fields.push(`beschreibung = $${idx++}`); values.push(data.beschreibung); }
|
||||||
if (data.farbe !== undefined) { fields.push(`farbe = $${idx++}`); values.push(data.farbe); }
|
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.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) {
|
if (fields.length === 0) {
|
||||||
// Nothing to update — return the existing record
|
// Nothing to update — return the existing record
|
||||||
const existing = await pool.query(
|
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`,
|
FROM veranstaltung_kategorien WHERE id = $1`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
@@ -163,7 +166,8 @@ class EventsService {
|
|||||||
const row = existing.rows[0];
|
const row = existing.rows[0];
|
||||||
return {
|
return {
|
||||||
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
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),
|
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(
|
const result = await pool.query(
|
||||||
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
|
`UPDATE veranstaltung_kategorien SET ${fields.join(', ')}
|
||||||
WHERE id = $${idx}
|
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
|
values
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) return null;
|
if (result.rows.length === 0) return null;
|
||||||
const row = result.rows[0];
|
const row = result.rows[0];
|
||||||
return {
|
return {
|
||||||
id: row.id, name: row.name, beschreibung: row.beschreibung ?? null,
|
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),
|
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;
|
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}`;
|
const subscribeUrl = `${baseUrl}/api/events/calendar.ics?token=${token}`;
|
||||||
|
|
||||||
return { token, subscribeUrl };
|
return { token, subscribeUrl };
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ services:
|
|||||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:?AUTHENTIK_CLIENT_SECRET is required}
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:?AUTHENTIK_CLIENT_SECRET is required}
|
||||||
AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://start.feuerwehr-rems.at/auth/callback}
|
AUTHENTIK_REDIRECT_URI: ${AUTHENTIK_REDIRECT_URI:-https://start.feuerwehr-rems.at/auth/callback}
|
||||||
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.feuerwehr-rems.at}
|
NEXTCLOUD_URL: ${NEXTCLOUD_URL:-https://cloud.feuerwehr-rems.at}
|
||||||
|
ICAL_BASE_URL: ${ICAL_BASE_URL:-https://start.feuerwehr-rems.at}
|
||||||
ports:
|
ports:
|
||||||
- "${BACKEND_PORT:-3000}:3000"
|
- "${BACKEND_PORT:-3000}:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -435,29 +435,6 @@ function MonthCalendar({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Chip,
|
Chip,
|
||||||
|
Checkbox,
|
||||||
|
FormGroup,
|
||||||
|
FormControlLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add,
|
Add,
|
||||||
@@ -34,7 +37,7 @@ import DashboardLayout from '../components/dashboard/DashboardLayout';
|
|||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { eventsApi } from '../services/events';
|
import { eventsApi } from '../services/events';
|
||||||
import type { VeranstaltungKategorie } from '../types/events.types';
|
import type { VeranstaltungKategorie, GroupInfo } from '../types/events.types';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Category Form Dialog
|
// Category Form Dialog
|
||||||
@@ -45,6 +48,7 @@ interface KategorieFormData {
|
|||||||
beschreibung: string;
|
beschreibung: string;
|
||||||
farbe: string;
|
farbe: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
zielgruppen: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: KategorieFormData = {
|
const EMPTY_FORM: KategorieFormData = {
|
||||||
@@ -52,6 +56,7 @@ const EMPTY_FORM: KategorieFormData = {
|
|||||||
beschreibung: '',
|
beschreibung: '',
|
||||||
farbe: '#1976d2',
|
farbe: '#1976d2',
|
||||||
icon: '',
|
icon: '',
|
||||||
|
zielgruppen: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
interface KategorieDialogProps {
|
interface KategorieDialogProps {
|
||||||
@@ -59,9 +64,10 @@ interface KategorieDialogProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
editing: VeranstaltungKategorie | null;
|
editing: VeranstaltungKategorie | null;
|
||||||
|
groups: GroupInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogProps) {
|
function KategorieDialog({ open, onClose, onSaved, editing, groups }: KategorieDialogProps) {
|
||||||
const notification = useNotification();
|
const notification = useNotification();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [form, setForm] = useState<KategorieFormData>({ ...EMPTY_FORM });
|
const [form, setForm] = useState<KategorieFormData>({ ...EMPTY_FORM });
|
||||||
@@ -74,6 +80,7 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
|
|||||||
beschreibung: editing.beschreibung ?? '',
|
beschreibung: editing.beschreibung ?? '',
|
||||||
farbe: editing.farbe,
|
farbe: editing.farbe,
|
||||||
icon: editing.icon ?? '',
|
icon: editing.icon ?? '',
|
||||||
|
zielgruppen: editing.zielgruppen ?? [],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setForm({ ...EMPTY_FORM });
|
setForm({ ...EMPTY_FORM });
|
||||||
@@ -84,6 +91,15 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
|
|||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
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 () => {
|
const handleSave = async () => {
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
notification.showError('Name ist erforderlich');
|
notification.showError('Name ist erforderlich');
|
||||||
@@ -96,6 +112,7 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
|
|||||||
beschreibung: form.beschreibung.trim() || undefined,
|
beschreibung: form.beschreibung.trim() || undefined,
|
||||||
farbe: form.farbe,
|
farbe: form.farbe,
|
||||||
icon: form.icon.trim() || undefined,
|
icon: form.icon.trim() || undefined,
|
||||||
|
zielgruppen: form.zielgruppen,
|
||||||
};
|
};
|
||||||
if (editing) {
|
if (editing) {
|
||||||
await eventsApi.updateKategorie(editing.id, payload);
|
await eventsApi.updateKategorie(editing.id, payload);
|
||||||
@@ -171,6 +188,29 @@ function KategorieDialog({ open, onClose, onSaved, editing }: KategorieDialogPro
|
|||||||
placeholder="z.B. EmojiEvents"
|
placeholder="z.B. EmojiEvents"
|
||||||
helperText="Name eines MUI Material Icons"
|
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>
|
</Stack>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
@@ -244,6 +284,7 @@ export default function VeranstaltungKategorien() {
|
|||||||
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
user?.groups?.some((g) => ['dashboard_admin', 'dashboard_moderator'].includes(g)) ?? false;
|
||||||
|
|
||||||
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
const [kategorien, setKategorien] = useState<VeranstaltungKategorie[]>([]);
|
||||||
|
const [groups, setGroups] = useState<GroupInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -258,8 +299,12 @@ export default function VeranstaltungKategorien() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await eventsApi.getKategorien();
|
const [data, groupData] = await Promise.all([
|
||||||
|
eventsApi.getKategorien(),
|
||||||
|
eventsApi.getGroups(),
|
||||||
|
]);
|
||||||
setKategorien(data);
|
setKategorien(data);
|
||||||
|
setGroups(groupData);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien';
|
const msg = e instanceof Error ? e.message : 'Fehler beim Laden der Kategorien';
|
||||||
setError(msg);
|
setError(msg);
|
||||||
@@ -324,13 +369,14 @@ export default function VeranstaltungKategorien() {
|
|||||||
<TableCell sx={{ fontWeight: 700 }}>Name</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Name</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Beschreibung</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Beschreibung</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 700 }}>Icon</TableCell>
|
<TableCell sx={{ fontWeight: 700 }}>Icon</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 700 }}>Gruppen</TableCell>
|
||||||
{canManage && <TableCell align="right" sx={{ fontWeight: 700 }}>Aktionen</TableCell>}
|
{canManage && <TableCell align="right" sx={{ fontWeight: 700 }}>Aktionen</TableCell>}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{kategorien.length === 0 ? (
|
{kategorien.length === 0 ? (
|
||||||
<TableRow>
|
<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">
|
<Typography variant="body2" color="text.secondary">
|
||||||
Noch keine Kategorien vorhanden.
|
Noch keine Kategorien vorhanden.
|
||||||
</Typography>
|
</Typography>
|
||||||
@@ -386,6 +432,27 @@ export default function VeranstaltungKategorien() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</TableCell>
|
</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 */}
|
{/* Actions */}
|
||||||
{canManage && (
|
{canManage && (
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
@@ -424,6 +491,7 @@ export default function VeranstaltungKategorien() {
|
|||||||
onClose={() => { setFormOpen(false); setEditingKat(null); }}
|
onClose={() => { setFormOpen(false); setEditingKat(null); }}
|
||||||
onSaved={loadKategorien}
|
onSaved={loadKategorien}
|
||||||
editing={editingKat}
|
editing={editingKat}
|
||||||
|
groups={groups}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirm Dialog */}
|
{/* Delete Confirm Dialog */}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const eventsApi = {
|
|||||||
beschreibung?: string;
|
beschreibung?: string;
|
||||||
farbe?: string;
|
farbe?: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
zielgruppen?: string[];
|
||||||
}): Promise<VeranstaltungKategorie> {
|
}): Promise<VeranstaltungKategorie> {
|
||||||
return api
|
return api
|
||||||
.post<ApiResponse<VeranstaltungKategorie>>('/api/events/kategorien', data)
|
.post<ApiResponse<VeranstaltungKategorie>>('/api/events/kategorien', data)
|
||||||
@@ -46,7 +47,7 @@ export const eventsApi = {
|
|||||||
/** Update an existing event category */
|
/** Update an existing event category */
|
||||||
updateKategorie(
|
updateKategorie(
|
||||||
id: string,
|
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> {
|
): Promise<VeranstaltungKategorie> {
|
||||||
return api
|
return api
|
||||||
.patch<ApiResponse<VeranstaltungKategorie>>(`/api/events/kategorien/${id}`, data)
|
.patch<ApiResponse<VeranstaltungKategorie>>(`/api/events/kategorien/${id}`, data)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface VeranstaltungKategorie {
|
|||||||
beschreibung?: string | null;
|
beschreibung?: string | null;
|
||||||
farbe: string; // hex color e.g. '#1976d2'
|
farbe: string; // hex color e.g. '#1976d2'
|
||||||
icon?: string | null; // MUI icon name
|
icon?: string | null; // MUI icon name
|
||||||
|
zielgruppen: string[];
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
aktualisiert_am: string;
|
aktualisiert_am: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user