diff --git a/src/api/courseFilters.ts b/src/api/courseFilters.ts new file mode 100644 index 0000000..e03c528 --- /dev/null +++ b/src/api/courseFilters.ts @@ -0,0 +1,111 @@ +import { apiFetch } from "./http"; + +/** + * Admin-verwaltete Kurs-Filter ("Kurskürzel"-Chips). Der Backend speichert nur + * die Liste der Filter-Strings; das eigentliche Filtern der Kursliste passiert + * client-seitig (siehe Courses.tsx). Lesen ist für alle eingeloggten User offen + * (Lecturer + Admin), Schreiben (POST/PATCH/DELETE) ist Admin-only — ein + * Lecturer bekommt vom Backend 403. + * + * Contract: `/api/v1/course-filters` (staging). Antworten folgen der üblichen + * `{success, message, data, pagination, request_id}`-Hülle wie /courses. + */ +export type CourseFilter = { + id: string; + name: string; + created_at: string; + updated_at: string; +}; + +type CourseFilterListResponse = { + success: boolean; + message: string; + data: CourseFilter[]; + pagination: { + page: number; + page_size: number; + total_items: number; + total_pages: number; + }; + errors: unknown; + timestamp: string; + request_id: string; +}; + +type CourseFilterItemResponse = { + success: boolean; + message: string; + data: CourseFilter; + errors: unknown; + timestamp: string; + request_id: string; +}; + +type CourseFilterDeleteResponse = { + success: boolean; + message: string; + data: null; + errors: unknown; + timestamp: string; + request_id: string; +}; + +/** + * List course filters, unwrapped to the bare array. The list is small and the + * chip-bar needs all of them at once, so we request the max page_size (100 — + * the backend cap in src/core/dependencies.py) to get everything in one shot, + * mirroring how Courses.tsx pulls the full course set. + */ +export async function listCourseFilters(params?: { + page?: number; + page_size?: number; + search?: string; +}): Promise { + const sp = new URLSearchParams(); + sp.set("page", String(params?.page ?? 1)); + sp.set("page_size", String(params?.page_size ?? 100)); + if (params?.search) sp.set("search", params.search); + + const res = await apiFetch( + `/api/v1/course-filters?${sp.toString()}`, + ); + return res.data || []; +} + +/** + * Create a course filter (admin only). Sends `{name}` ONLY — the backend schema + * is `extra="forbid"`, so any extra key (id/color/…) yields a 422. On a + * duplicate name the backend answers 409 with message + * "Course filter with name 'X' already exists". Callers should catch `ApiError` + * and branch on `.status` (409 / 422) — see http.ts. + */ +export async function createCourseFilter(name: string): Promise { + const res = await apiFetch(`/api/v1/course-filters`, { + method: "POST", + body: JSON.stringify({ name }), + }); + return res.data; +} + +/** Rename a course filter (admin only). Body is `{name}` only. 404 unknown id, + * 409 name taken, 422 empty/invalid. */ +export async function updateCourseFilter( + id: string, + name: string, +): Promise { + const res = await apiFetch( + `/api/v1/course-filters/${id}`, + { + method: "PATCH", + body: JSON.stringify({ name }), + }, + ); + return res.data; +} + +/** Delete a course filter (admin only). 200 with `data: null`; 404 unknown id. */ +export async function deleteCourseFilter(id: string): Promise { + await apiFetch(`/api/v1/course-filters/${id}`, { + method: "DELETE", + }); +} diff --git a/src/pages/Courses.tsx b/src/pages/Courses.tsx index 47404e3..15c3283 100644 --- a/src/pages/Courses.tsx +++ b/src/pages/Courses.tsx @@ -1,12 +1,22 @@ -import { useEffect, useMemo, useState, ChangeEvent } from "react"; +import { useEffect, useMemo, useState, ChangeEvent, KeyboardEvent } from "react"; import { useNavigate } from "react-router-dom"; -import { BookOpen, ChevronRight, Server } from "lucide-react"; +import { BookOpen, Server, Plus, X, Pencil, Check } from "lucide-react"; +import { toast } from "sonner@2.0.3"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Input } from "../components/ui/input"; import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { getMyCourses, CourseDto } from "../api/courses"; import { getKeycloakGroups, KeycloakGroup } from "../api/keycloak"; +import { + listCourseFilters, + createCourseFilter, + updateCourseFilter, + deleteCourseFilter, + CourseFilter, +} from "../api/courseFilters"; +import { ApiError } from "../api/http"; +import { useCurrentUser } from "../auth/useCurrentUser"; import { useActiveOpenstackProject } from "../contexts/OpenstackProjectContext"; type CourseUi = { @@ -17,14 +27,42 @@ type CourseUi = { applications: Array<{ id: string; name: string; status: string; created_at?: string }>; }; +// Map an ApiError from the course-filters endpoints to a user-facing message. +// The backend answers 409 on a duplicate name (message already reads +// "Course filter with name 'X' already exists") and 422 on empty/invalid or +// unknown-key payloads — see appstore-backend src/schemas/course_filter.py. +function filterErrorMessage(e: unknown, fallback: string): string { + if (e instanceof ApiError) { + if (e.status === 409) return e.message || "Filter existiert bereits."; + if (e.status === 422) return "Ungültiger Filter-Name (1–255 Zeichen, nicht leer)."; + if (e.status === 403) return "Nur Admins dürfen Filter verwalten."; + if (e.status === 404) return "Filter nicht gefunden (evtl. bereits gelöscht)."; + return e.message || fallback; + } + return e instanceof Error ? e.message : fallback; +} + export function Courses() { const navigate = useNavigate(); + const { isAdmin } = useCurrentUser(); const { activeProjectId } = useActiveOpenstackProject(); const [items, setItems] = useState([]); const [keycloakGroups, setKeycloakGroups] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [prefixQuery, setPrefixQuery] = useState(""); + + // Admin-managed filter chips (fetched from the backend). The set of active + // chip *names* drives client-side filtering with OR semantics. + const [filters, setFilters] = useState([]); + const [filtersLoading, setFiltersLoading] = useState(true); + const [filtersError, setFiltersError] = useState(null); + const [activeFilters, setActiveFilters] = useState>(new Set()); + + // Admin management UI state. + const [newFilterName, setNewFilterName] = useState(""); + const [adding, setAdding] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editingName, setEditingName] = useState(""); useEffect(() => { let alive = true; @@ -41,8 +79,8 @@ export function Courses() { // contract introduced in PR #137 for /api/v1/deployments. // // Backend caps page_size at 100 (src/core/dependencies.py); request the - // max so the client-side prefix filter operates over the full course - // set rather than only the first page. + // max so the client-side filter operates over the full course set + // rather than only the first page. const [coursesRes, groupsRes] = await Promise.all([ getMyCourses({ page: 1, page_size: 100, openstack_project_id: activeProjectId }), getKeycloakGroups(), @@ -66,11 +104,56 @@ export function Courses() { }; }, [activeProjectId]); + // Load the admin-managed filter chips once on mount. GET is open to any + // logged-in user, so both lecturers and admins see the same chips. + const loadFilters = async () => { + try { + setFiltersLoading(true); + setFiltersError(null); + const list = await listCourseFilters(); + setFilters(list); + // Drop any active selections that no longer exist after a refetch. + setActiveFilters((prev) => { + const names = new Set(list.map((f) => f.name)); + const next = new Set(); + prev.forEach((n) => { + if (names.has(n)) next.add(n); + }); + return next; + }); + } catch (e) { + setFiltersError(e instanceof Error ? e.message : "Filter konnten nicht geladen werden"); + } finally { + setFiltersLoading(false); + } + }; + + useEffect(() => { + let alive = true; + (async () => { + try { + setFiltersLoading(true); + setFiltersError(null); + const list = await listCourseFilters(); + if (!alive) return; + setFilters(list); + } catch (e) { + if (!alive) return; + setFiltersError(e instanceof Error ? e.message : "Filter konnten nicht geladen werden"); + } finally { + if (alive) setFiltersLoading(false); + } + })(); + return () => { + alive = false; + }; + }, []); + const courses: CourseUi[] = useMemo(() => { return items.map((c) => { // Find the Keycloak group name by ID const keycloakGroup = keycloakGroups.find((g) => g.id === c.keycloak_course_id); - + return { id: c.id, code: c.keycloak_course_id, @@ -86,20 +169,88 @@ export function Courses() { }); }, [items, keycloakGroups]); - // Derive available prefix suggestions — restrict to canonical prefixes - const prefixSuggestions = useMemo(() => { - // Only show these canonical prefixes (order matters) - return ["WWI", "WI", "INF", "WIN"]; - }, [items, keycloakGroups]); - const filteredCourses = useMemo(() => { // Filter out courses without deployments const coursesWithDeployments = courses.filter((c) => c.applications.length > 0); - - if (!prefixQuery) return coursesWithDeployments; - const q = prefixQuery.toUpperCase(); - return coursesWithDeployments.filter((c) => (c.keycloakGroupName || c.name || "").toUpperCase().startsWith(q)); - }, [courses, prefixQuery]); + + if (activeFilters.size === 0) return coursesWithDeployments; + + // OR semantics across active chips: a course matches if ANY active chip + // term is a case-INSENSITIVE substring of its keycloak group name or its + // course name. (Was previously a case-SENSITIVE `startsWith` against a + // hardcoded prefix list.) + const terms = Array.from(activeFilters).map((t) => t.toLowerCase()); + return coursesWithDeployments.filter((c) => { + const haystacks = [c.keycloakGroupName || "", c.name || ""].map((s) => s.toLowerCase()); + return terms.some((t) => haystacks.some((h) => h.includes(t))); + }); + }, [courses, activeFilters]); + + const toggleFilter = (name: string) => { + setActiveFilters((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }; + + const handleAddFilter = async () => { + const name = newFilterName.trim(); + if (!name || adding) return; + try { + setAdding(true); + await createCourseFilter(name); + setNewFilterName(""); + toast.success(`Filter „${name}" hinzugefügt.`); + await loadFilters(); + } catch (e) { + toast.error(filterErrorMessage(e, "Filter konnte nicht angelegt werden.")); + } finally { + setAdding(false); + } + }; + + const handleDeleteFilter = async (f: CourseFilter) => { + try { + await deleteCourseFilter(f.id); + toast.success(`Filter „${f.name}" gelöscht.`); + await loadFilters(); + } catch (e) { + toast.error(filterErrorMessage(e, "Filter konnte nicht gelöscht werden.")); + } + }; + + const startEdit = (f: CourseFilter) => { + setEditingId(f.id); + setEditingName(f.name); + }; + + const cancelEdit = () => { + setEditingId(null); + setEditingName(""); + }; + + const handleSaveEdit = async (f: CourseFilter) => { + const name = editingName.trim(); + if (!name) { + toast.error("Filter-Name darf nicht leer sein."); + return; + } + if (name === f.name) { + cancelEdit(); + return; + } + try { + await updateCourseFilter(f.id, name); + toast.success(`Filter in „${name}" umbenannt.`); + cancelEdit(); + await loadFilters(); + } catch (e) { + toast.error(filterErrorMessage(e, "Filter konnte nicht umbenannt werden.")); + } + }; + const getStatusBadge = (status: string) => { switch (status) { case "running": @@ -124,37 +275,131 @@ export function Courses() { - {/* Prefix filter input + suggestions */} + {/* Admin-managed filter chip-bar. Chips toggle a client-side substring + filter (OR across active chips). Admins additionally get inline + add / rename / delete controls; non-admins see read-only chips. */}
- ) => setPrefixQuery(e.target.value.toUpperCase())} - /> -
- -
+
Kurs-Filter:
+ {activeFilters.size > 0 && ( + + )}
-
-
Schnellfilter:
-
- {prefixSuggestions.map((p) => ( - - ))} + {filtersLoading &&
Filter werden geladen…
} + {filtersError &&
{filtersError}
} + + {!filtersLoading && !filtersError && filters.length === 0 && ( +
+ {isAdmin ? "Noch keine Filter angelegt." : "Es sind keine Kurs-Filter definiert."}
+ )} + +
+ {filters.map((f) => { + const isActive = activeFilters.has(f.name); + if (editingId === f.id) { + return ( + + ) => setEditingName(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") handleSaveEdit(f); + if (e.key === "Escape") cancelEdit(); + }} + className="h-8 w-auto text-sm" + autoFocus + /> + + + + ); + } + return ( + + + {isAdmin && ( + <> + + + + )} + + ); + })}
+ + {isAdmin && ( +
+ ) => setNewFilterName(e.target.value)} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter") handleAddFilter(); + }} + /> + +
+ )}
{loading && ( @@ -198,11 +443,6 @@ export function Courses() { {course.keycloakGroupName}
- {/* Students unbekannt -> Badge optional oder Placeholder */} - {/* - - UNKNOWN - */}