Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/app/admin/AdminSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ import {
Images,
Gavel,
Vote,
Route,
BookText,
GraduationCap,
} from "lucide-react";

import {
Expand Down Expand Up @@ -185,6 +188,29 @@ const groups: AdminGroup[] = [
},
],
},
{
title: "admin:categories.plugg",
entries: [
{
title: "admin:programs.self",
url: "/admin/programs",
permissions: [[ActionEnum.MANAGE, TargetEnum.PLUGG]],
icon: GraduationCap,
},
{
title: "admin:specialisations.self",
url: "/admin/specialisations",
permissions: [[ActionEnum.MANAGE, TargetEnum.PLUGG]],
icon: Route,
},
{
title: "admin:courses.self",
url: "/admin/courses",
permissions: [[ActionEnum.MANAGE, TargetEnum.PLUGG]],
icon: BookText,
},
],
},
{
title: "admin:categories.elections",
entries: [
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/albums/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export default function AlbumPage({ params }: AlbumPageProps) {
});

if (Number.isNaN(albumId)) {
return <LoadingErrorCard error={new Error("Invalid album ID")} />;
return <LoadingErrorCard error={"Invalid album ID"} />;
}
if (error) {
return <LoadingErrorCard error={error} />;
Expand Down
244 changes: 244 additions & 0 deletions src/app/admin/courses/CourseEditForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { useEffect, useState } from "react";
import { z } from "zod";
import {
deleteCourseMutation,
getAllCoursesQueryKey,
getCourseOptions,
updateCourseMutation,
getCoursesByProgramYearQueryKey,
getCoursesBySpecialisationQueryKey,
} from "@/api/@tanstack/react-query.gen";
import { AssociationTypeEnum, type CourseRead, type CourseUpdate } from "@/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import AdminForm from "@/widgets/AdminForm";
import AssociatedImageManager from "@/components/AssociatedImageManager";

const MAX_COURSE_TITLE = 200;
const MAX_COURSE_CODE = 100;
const MAX_COURSE_DESC = 10000;

const courseEditSchema = z.object({
id: z.number(),
title: z.string().trim().min(1).max(MAX_COURSE_TITLE),
course_code: z.string().max(MAX_COURSE_CODE).optional(),
description: z.string().max(MAX_COURSE_DESC).optional(),
});

interface CourseEditFormProps {
item: CourseRead | null;
onClose: () => void;
}

export default function CourseEditForm({ onClose, item }: CourseEditFormProps) {
const { t } = useTranslation("admin");

const [convertedItem, setConvertedItem] = useState<z.infer<
typeof courseEditSchema
> | null>(null);
const [associatedImageId, setAssociatedImageId] = useState<number | null>(
null,
);

useEffect(() => {
if (item) {
const convertedItem = {
id: item.course_id,
title: item.title,
course_code: item.course_code ?? "",
description: item.description ?? "",
} as z.infer<typeof courseEditSchema>;
setConvertedItem(convertedItem);
setAssociatedImageId(item.associated_img_id ?? null);
} else {
setAssociatedImageId(null);
}
}, [item]);

const queryClient = useQueryClient();

async function syncCourseAfterImageChange(courseId: number) {
try {
const updatedCourse = await queryClient.fetchQuery({
...getCourseOptions({ path: { course_id: courseId } }),
});

setAssociatedImageId(updatedCourse.associated_img_id ?? null);

queryClient.invalidateQueries({
queryKey: getAllCoursesQueryKey(),
});
invalidateCourseQueries(item?.program_years, item?.specialisations);
invalidateCourseQueries(
updatedCourse.program_years,
updatedCourse.specialisations,
);
} catch {
toast.error(t("admin:associated_image.sync_error"));
}
}

function invalidateCourseQueries(
programYears: CourseRead["program_years"] = [],
specialisations: CourseRead["specialisations"] = [],
) {
// This only invalidates the program years and specialisations that are related to the course,
// not all the program years and specialisations.
const programYearIds = new Set(
programYears.map((py) => py.program_year_id),
);
const specialisationIds = new Set(
specialisations.map((s) => s.specialisation_id),
);

for (const programYearId of programYearIds) {
queryClient.invalidateQueries({
queryKey: getCoursesByProgramYearQueryKey({
path: { program_year_id: programYearId },
}),
});
}

for (const specialisationId of specialisationIds) {
queryClient.invalidateQueries({
queryKey: getCoursesBySpecialisationQueryKey({
path: { specialisation_id: specialisationId },
}),
});
}
}

const updateCourse = useMutation({
...updateCourseMutation(),
throwOnError: false,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: getAllCoursesQueryKey(),
});
invalidateCourseQueries(item?.program_years, item?.specialisations);
invalidateCourseQueries(data?.program_years, data?.specialisations);
toast.success(t("courses.edit_success"));
},
onError: (error) => {
toast.error(
typeof error?.detail === "string"
? error.detail
: t("courses.edit_error"),
);
onClose();
},
});

const removeCourse = useMutation({
...deleteCourseMutation(),
throwOnError: false,
onSuccess: (data) => {
queryClient.invalidateQueries({
queryKey: getAllCoursesQueryKey(),
});
invalidateCourseQueries(item?.program_years, item?.specialisations);
invalidateCourseQueries(data?.program_years, data?.specialisations);
toast.success(t("courses.remove_success"));
},
onError: (error) => {
toast.error(
typeof error?.detail === "string"
? error.detail
: t("courses.remove_error"),
);
onClose();
},
});

function handleFormSubmit(values: z.infer<typeof courseEditSchema>) {
const updatedCourse: CourseUpdate = {
title: values.title,
course_code: values.course_code?.trim() ? values.course_code : null,
description: values.description?.trim() ? values.description : null,
};

updateCourse.mutate(
{
path: { course_id: values.id },
body: updatedCourse,
},
{
onSuccess: () => {
onClose();
},
},
);
}

function handleRemoveSubmit(data: z.infer<typeof courseEditSchema>) {
removeCourse.mutate(
{ path: { course_id: data.id } },
{
onSuccess: () => {
onClose();
},
},
);
}

return (
<AdminForm
title={t("courses.edit_course")}
formType="edit"
gridCols={2}
open={!!item}
onOpenChange={(isOpen) => {
if (!isOpen) onClose();
}}
inputFields={[
{
variant: "text",
name: "title",
label: t("courses.title"),
placeholder: t("courses.title"),
colSpan: 1,
},
{
variant: "text",
name: "course_code",
label: t("courses.course_code"),
placeholder: t("courses.course_code"),
colSpan: 1,
},
{
variant: "textarea",
name: "description",
label: t("courses.description"),
placeholder: t("courses.description"),
rows: 8,
colSpan: 2,
},
]}
zodSchema={courseEditSchema}
onSubmit={handleFormSubmit}
useDeleteButton
onDelete={handleRemoveSubmit}
showDialogButton={false}
editItem={convertedItem || undefined}
setEditItem={setConvertedItem}
customButtons={
<AssociatedImageManager
associationType={AssociationTypeEnum.COURSE}
associationId={item?.course_id ?? null}
associatedImageId={associatedImageId}
onImageChanged={() => {
if (!item?.course_id) {
return;
}

void syncCourseAfterImageChange(item.course_id);
}}
/>
}
requireConfirmationToDelete={true}
confirmDeleteDialogTitle={t("courses.confirm_remove")}
confirmDeleteDialogDescription={t("courses.confirm_remove_text")}
/>
);
}
103 changes: 103 additions & 0 deletions src/app/admin/courses/CourseForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useState } from "react";
import { z } from "zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCourseMutation,
getAllCoursesOptions,
getAllCoursesQueryKey,
} from "@/api/@tanstack/react-query.gen";
import type { CourseCreate } from "@/api";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import AdminForm from "@/widgets/AdminForm";

const MAX_COURSE_TITLE = 200;
const MAX_COURSE_CODE = 100;
const MAX_COURSE_DESC = 10000;

const courseSchema = z.object({
title: z.string().trim().min(1).max(MAX_COURSE_TITLE),
course_code: z.string().max(MAX_COURSE_CODE).optional(),
description: z.string().max(MAX_COURSE_DESC).optional(),
});

export default function CourseForm() {
const [open, setOpen] = useState(false);
const { t } = useTranslation("admin");

const queryClient = useQueryClient();
const { data: allCourses = [] } = useQuery({
...getAllCoursesOptions(),
refetchOnWindowFocus: false,
});

const createCourse = useMutation({
...createCourseMutation(),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getAllCoursesQueryKey(),
});
toast.success(t("courses.create_success"));
setOpen(false);
},
onError: (error) => {
toast.error(
typeof error?.detail === "string"
? error.detail
: t("courses.create_error"),
);
setOpen(false);
},
});

function onSubmit(values: z.infer<typeof courseSchema>) {
const payload: CourseCreate = {
title: values.title,
course_code: values.course_code?.trim() ? values.course_code : null,
description: values.description?.trim() ? values.description : null,
};

createCourse.mutate({ body: payload });
}

return (
<AdminForm
title={t("courses.create_course")}
formType="add"
gridCols={2}
open={open}
onOpenChange={setOpen}
defaultValues={{
title: "",
course_code: "",
description: "",
}}
inputFields={[
{
variant: "text",
name: "title",
label: t("courses.title"),
placeholder: t("courses.title"),
colSpan: 1,
},
{
variant: "text",
name: "course_code",
label: t("courses.course_code"),
placeholder: t("courses.course_code"),
colSpan: 1,
},
{
variant: "textarea",
name: "description",
label: t("courses.description"),
placeholder: t("courses.description"),
rows: 8,
colSpan: 2,
},
]}
zodSchema={courseSchema}
onSubmit={onSubmit}
/>
);
}
Loading