diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_actions/update.ts b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_actions/update.ts new file mode 100644 index 00000000..d0674ae4 --- /dev/null +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_actions/update.ts @@ -0,0 +1,45 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { updateCategory } from "~/lib/nwd"; + +export default async function update( + categoryId: string, + data: { + title: string; + description: string; + handle: string; + weight: number; + parentCategoryId: string; + }, +) { + try { + await updateCategory(categoryId, data); + + revalidatePath("/secretariaat/diplomalijn/categorieen"); + + return { + message: "Success", + errors: {}, + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + message: "Error", + errors: error.issues.reduce( + (acc, issue) => { + acc[issue.path.join(".")] = issue.message; + return acc; + }, + {} as Record, + ), + }; + } + + return { + message: "Error", + errors: {}, + }; + } +} diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_components/category-table.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_components/category-table.tsx index 19a5a5aa..c5a2bdab 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_components/category-table.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/_components/category-table.tsx @@ -123,7 +123,7 @@ export default function ModuleTable({ : "", )} key={row.id} - href={`#TODO`} + href={`/secretariaat/diplomalijn/categorieen?bewerken=${row.id}`} > {row.getVisibleCells().map((cell, index) => ( >[number] | null; + parentCategories: Awaited>; +}) { + const [editCategoryId, setEditCategoryId] = useQueryState( + "bewerken", + parseAsString.withDefault(""), + ); + + const [state, formAction] = useFormState( + async (_prevState: unknown, formData: FormData) => { + const title = formData.get("title") as string; + const description = formData.get("description") as string; + const handle = formData.get("handle") as string; + const weight = Number(formData.get("weight")); + const parentCategoryId = formData.get("parentCategoryId") as string; + + const result = await update(editCategoryId, { + title, + handle, + weight, + description, + parentCategoryId, + }); + if (result.message === "Success") { + toast.success("Categorie is geüpdatet."); + await setEditCategoryId(null); + } + + return result; + }, + null, + ); + + if (!editCategory) { + return null; + } + + return ( + setEditCategoryId(null)}> + Categorie wijzigen + + Pas de gegevens van de categorie aan. + +
+ +
+ + + + + {state?.errors?.title ? ( + {state.errors.title} + ) : null} + + + + + {state?.errors?.handle ? ( + {state.errors.handle} + ) : null} + + + + + {state?.errors?.description ? ( + {state.errors.description} + ) : null} + + + + + + {parentCategories.map((category) => ( + + + {category.title ?? category.handle} + + + ))} + + {state?.errors?.parentCategoryId ? ( + {state.errors.parentCategoryId} + ) : null} + + + + + {state?.errors?.weight ? ( + {state.errors.weight} + ) : null} + + + +
+
+ + + + +
+
+ ); +} + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/page.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/page.tsx index 9a582ed6..83fb5fcc 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/page.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/categorieen/page.tsx @@ -1,17 +1,25 @@ import FlexSearch from "flexsearch"; import { Heading } from "~/app/(dashboard)/_components/heading"; -import { listCategories } from "~/lib/nwd"; +import { listCategories, listParentCategories } from "~/lib/nwd"; import Search from "../../../_components/search"; import CategoryTableCLient from "./_components/category-table"; +import EditDialog from "./_components/edit-dialog"; async function CategoryTable({ searchParams, }: { searchParams: Record; }) { - const categories = await listCategories(); + const [categories, parentCategories] = await Promise.all([ + listCategories(), + listParentCategories(), + ]); const searchQuery = searchParams?.query?.toString() ?? null; + const editCategoryId = searchParams?.bewerken?.toString() ?? null; + const editCategory = + categories.find((category) => category.id === editCategoryId) ?? null; + // Create a FlexSearch index const index = new FlexSearch.Index({ tokenize: "full", @@ -46,10 +54,17 @@ async function CategoryTable({ ); return ( - + <> + + + ); } diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/_components/program-table.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/_components/competencies-table.tsx similarity index 100% rename from apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/_components/program-table.tsx rename to apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/_components/competencies-table.tsx diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/page.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/page.tsx index 6bc1ef67..662079da 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/page.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/competenties/page.tsx @@ -2,8 +2,8 @@ import FlexSearch from "flexsearch"; import { Heading } from "~/app/(dashboard)/_components/heading"; import { listCompetencies } from "~/lib/nwd"; import Search from "../../../_components/search"; +import CompetencyTableClient from "./_components/competencies-table"; import EditDialog from "./_components/edit-dialog"; -import ProgramCompetencyClient from "./_components/program-table"; async function CompetencyTable({ searchParams, @@ -53,11 +53,11 @@ async function CompetencyTable({ return ( <> - - + ); } diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/cursussen/_components/course-table.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/cursussen/_components/course-table.tsx index ea9cbc89..f3265f4d 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/cursussen/_components/course-table.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/cursussen/_components/course-table.tsx @@ -63,7 +63,7 @@ export default function CourseTable({ {row.original.categories .filter((c) => c.parent?.id === category.id) .map((c) => ( - {c.title} + {c.title} ))} ), diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_actions/update.ts b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_actions/update.ts new file mode 100644 index 00000000..a4fb781a --- /dev/null +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_actions/update.ts @@ -0,0 +1,43 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { updateDiscipline } from "~/lib/nwd"; + +export default async function update( + disciplineId: string, + data: { + title: string; + handle: string; + weight: number; + }, +) { + try { + await updateDiscipline(disciplineId, data); + + revalidatePath("/secretariaat/diplomalijn/disciplines"); + + return { + message: "Success", + errors: {}, + }; + } catch (error) { + if (error instanceof z.ZodError) { + return { + message: "Error", + errors: error.issues.reduce( + (acc, issue) => { + acc[issue.path.join(".")] = issue.message; + return acc; + }, + {} as Record, + ), + }; + } + + return { + message: "Error", + errors: {}, + }; + } +} diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_components/discipline-table.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_components/discipline-table.tsx index b438b842..7d2be178 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_components/discipline-table.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/_components/discipline-table.tsx @@ -117,7 +117,7 @@ export default function ModuleTable({ : "", )} key={row.id} - href={`#TODO`} + href={`/secretariaat/diplomalijn/disciplines?bewerken=${row.id}`} > {row.getVisibleCells().map((cell, index) => ( >[number] | null; +}) { + const [editDisciplineId, setEditDisciplineId] = useQueryState( + "bewerken", + parseAsString.withDefault(""), + ); + + const [state, formAction] = useFormState( + async (_prevState: unknown, formData: FormData) => { + const title = formData.get("title") as string; + const handle = formData.get("handle") as string; + const weight = Number(formData.get("weight")); + + const result = await update(editDisciplineId, { + title, + handle, + weight, + }); + if (result.message === "Success") { + toast.success("Discipline is geüpdatet."); + await setEditDisciplineId(null); + } + + return result; + }, + null, + ); + + if (!editDiscipline) { + return null; + } + + return ( + setEditDisciplineId(null)}> + Discipline wijzigen + + Pas de gegevens van de discipline aan. + +
+ +
+ + + + + {state?.errors?.title ? ( + {state.errors.title} + ) : null} + + + + + {state?.errors?.handle ? ( + {state.errors.handle} + ) : null} + + + + + {state?.errors?.weight ? ( + {state.errors.weight} + ) : null} + + +
+
+ + + + +
+
+ ); +} + +function SubmitButton() { + const { pending } = useFormStatus(); + return ( + + ); +} diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/page.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/page.tsx index 98dbf74a..425735ad 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/page.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/disciplines/page.tsx @@ -3,6 +3,7 @@ import { Heading } from "~/app/(dashboard)/_components/heading"; import { listDisciplines } from "~/lib/nwd"; import Search from "../../../_components/search"; import DisciplineTableCLient from "./_components/discipline-table"; +import EditDialog from "./_components/edit-dialog"; async function DisciplineTable({ searchParams, @@ -12,6 +13,11 @@ async function DisciplineTable({ const disciplines = await listDisciplines(); const searchQuery = searchParams?.query?.toString() ?? null; + const editDisciplineId = searchParams?.bewerken?.toString() ?? null; + const editDiscipline = + disciplines.find((discipline) => discipline.id === editDisciplineId) ?? + null; + // Create a FlexSearch index const index = new FlexSearch.Index({ tokenize: "full", @@ -46,10 +52,13 @@ async function DisciplineTable({ ); return ( - + <> + + + ); } diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/materialen/page.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/materialen/page.tsx index 3fd9ac68..80df1871 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/materialen/page.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/materialen/page.tsx @@ -56,7 +56,7 @@ async function GearTypeTable({ gearTypes={paginatedGearTypes} totalItems={filteredGearTypes.length} /> - + ); } diff --git a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/modules/page.tsx b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/modules/page.tsx index 85bdf55c..374146b5 100644 --- a/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/modules/page.tsx +++ b/apps/web/src/app/(dashboard)/(management)/secretariaat/diplomalijn/modules/page.tsx @@ -56,7 +56,7 @@ async function ModuleTable({ modules={paginatedModules} totalItems={filteredModules.length} /> - + ); } diff --git a/apps/web/src/lib/nwd.ts b/apps/web/src/lib/nwd.ts index c5c89027..7bac46d7 100644 --- a/apps/web/src/lib/nwd.ts +++ b/apps/web/src/lib/nwd.ts @@ -353,6 +353,32 @@ export const listDisciplines = cache(async () => { }); }); +export const updateDiscipline = async ( + id: string, + fields: { + handle: string; + title?: string | null; + weight: number; + }, +) => { + return makeRequest(async () => { + const authUser = await getUserOrThrow(); + + if (authUser.email !== "info@nationaalwatersportdiploma.nl") { + throw new Error("Unauthorized"); + } + + await Course.Discipline.update({ + id: id, + title: fields.title, + handle: fields.handle, + weight: fields.weight, + }); + + return; + }); +}; + export const listDegrees = cache(async () => { return makeRequest(async () => { const degrees = await Course.Degree.list(); @@ -471,6 +497,51 @@ export const listCategories = cache(async () => { }); }); +export const updateCategory = async ( + id: string, + fields: { + handle: string; + title?: string | null; + description?: string | null; + parentCategoryId?: string | null; + weight: number; + }, +) => { + return makeRequest(async () => { + const authUser = await getUserOrThrow(); + + if (authUser.email !== "info@nationaalwatersportdiploma.nl") { + throw new Error("Unauthorized"); + } + + if (fields.parentCategoryId) { + const parentCategories = await listParentCategories(); + + if (!parentCategories.some((c) => c.id === fields.parentCategoryId)) { + throw new Error("Invalid parent category"); + } + + const isParentCategory = parentCategories.find((c) => c.id === id); + if (isParentCategory?.hasActiveChildren) { + throw new Error( + "A parent category with active children cannot be a child category", + ); + } + } + + await Course.Category.update({ + id: id, + title: fields.title, + description: fields.description, + handle: fields.handle, + parentCategoryId: fields.parentCategoryId, + weight: fields.weight, + }); + + return; + }); +}; + export const listParentCategories = cache(async () => { return makeRequest(async () => { const categories = await Course.Category.listParentCategories(); diff --git a/packages/core/src/models/course/category.test.ts b/packages/core/src/models/course/category.test.ts index dcb45b35..09ce3443 100644 --- a/packages/core/src/models/course/category.test.ts +++ b/packages/core/src/models/course/category.test.ts @@ -11,20 +11,78 @@ test('category crud', () => handle: 'handle-1', }) + // From handle + const categoryFromHandle = await Category.fromHandle('handle-1') + const output = { + id, + title: 'title-1', + handle: 'handle-1', + createdAt: categoryFromHandle!.createdAt, + updatedAt: categoryFromHandle!.updatedAt, + deletedAt: null, + description: null, + parent: null, + weight: 1, + } satisfies Output + + assert.deepStrictEqual(categoryFromHandle, output) + + // List const list = await Category.list() assert.equal(list.length, 1) const [item] = list - assert.deepStrictEqual(item, { + assert.deepStrictEqual(item, output) + + // Parent + const { id: parentId } = await Category.create({ + title: 'parent-1', + handle: 'parent-1', + }) + const parent = await Category.fromHandle('parent-1') + + // Update + const updated = await Category.update({ id, - title: 'title-1', - handle: 'handle-1', - createdAt: item!.createdAt, - updatedAt: item!.updatedAt, + title: 'title-2', + handle: 'handle-2', + weight: 2, + description: 'description-2', + parentCategoryId: parentId, + }) + + assert.strictEqual(updated.id, id) + + const categoryFromHandleAfterUpdate = await Category.fromHandle('handle-2') + const outputAfterUpdate = { + id, + title: 'title-2', + handle: 'handle-2', + createdAt: categoryFromHandle!.createdAt, + updatedAt: categoryFromHandleAfterUpdate!.updatedAt, + deletedAt: null, + description: 'description-2', + parent, + weight: 2, + } satisfies Output + + assert.deepStrictEqual(categoryFromHandleAfterUpdate, outputAfterUpdate) + + // Parent list + const parentList = await Category.listParentCategories() + + assert.equal(parentList.length, 1) + const [parentItem] = parentList + + assert.deepStrictEqual(parentItem, { + id: parentId, + title: 'parent-1', + handle: 'parent-1', + createdAt: parent!.createdAt, + updatedAt: parent!.updatedAt, deletedAt: null, - description: null, - parent: null, weight: 1, - } satisfies Output) + hasActiveChildren: true, + }) })) diff --git a/packages/core/src/models/course/category.ts b/packages/core/src/models/course/category.ts index 32c8794f..82e4387c 100644 --- a/packages/core/src/models/course/category.ts +++ b/packages/core/src/models/course/category.ts @@ -1,5 +1,5 @@ import { schema as s } from '@nawadi/db' -import { asc, desc, eq, isNull } from 'drizzle-orm' +import { asc, count, desc, eq, isNotNull, isNull } from 'drizzle-orm' import { alias } from 'drizzle-orm/pg-core' import { z } from 'zod' import { useQuery, withTransaction } from '../../contexts/index.js' @@ -45,6 +45,40 @@ export const create = withZod( }), ) +export const update = withZod( + insertSchema + .pick({ + id: true, + title: true, + handle: true, + description: true, + parentCategoryId: true, + weight: true, + }) + .required({ id: true }), + successfulCreateResponse, + async (item) => { + const query = useQuery() + + const row = await query + .update(s.category) + .set({ + title: item.title, + handle: item.handle, + description: item.description, + parentCategoryId: item.parentCategoryId, + weight: item.weight, + }) + .where(eq(s.category.id, item.id)) + .returning({ + id: s.category.id, + }) + .then(singleRow) + + return row + }, +) + export const list = withZod(z.void(), outputSchema.array(), async () => { const query = useQuery() @@ -61,18 +95,52 @@ export const list = withZod(z.void(), outputSchema.array(), async () => { export const listParentCategories = withZod( z.void(), - outputSchema.omit({ parent: true }).array(), + outputSchema + .omit({ parent: true }) + .extend({ + hasActiveChildren: z.boolean(), + }) + .array(), async () => { const query = useQuery() + const categoryCountPerParent = query + .select({ + parentCategoryId: s.category.parentCategoryId, + count: count().as('count'), + }) + .from(s.category) + .where(isNotNull(s.category.parentCategoryId)) + .groupBy(s.category.parentCategoryId) + .as('categoryCountPerParent') + const rows = await query - .select() + .select({ + id: s.category.id, + title: s.category.title, + handle: s.category.handle, + description: s.category.description, + weight: s.category.weight, + + createdAt: s.category.createdAt, + updatedAt: s.category.updatedAt, + deletedAt: s.category.deletedAt, + + parentCategoryId: s.category.parentCategoryId, + + activeChildrenCount: categoryCountPerParent.count, + }) .from(s.category) + .leftJoin( + categoryCountPerParent, + eq(s.category.id, categoryCountPerParent.parentCategoryId), + ) .where(isNull(s.category.parentCategoryId)) .orderBy(asc(s.category.weight)) - return rows.map(({ parentCategoryId, ...row }) => ({ + return rows.map(({ parentCategoryId, activeChildrenCount, ...row }) => ({ ...row, + hasActiveChildren: activeChildrenCount > 0, parent: rows.find(({ id }) => id === parentCategoryId) ?? null, })) }, diff --git a/packages/core/src/models/course/discipline.test.ts b/packages/core/src/models/course/discipline.test.ts index 27145c80..a32b3afa 100644 --- a/packages/core/src/models/course/discipline.test.ts +++ b/packages/core/src/models/course/discipline.test.ts @@ -11,18 +11,54 @@ test('discipline crud', () => handle: 'handle-1', }) + // From handle + const disciplineFromHandle = await Discipline.fromHandle('handle-1') + + const output = { + id, + title: 'title-1', + handle: 'handle-1', + createdAt: disciplineFromHandle!.createdAt, + updatedAt: disciplineFromHandle!.updatedAt, + deletedAt: null, + weight: 1, + } satisfies Output + + assert.deepStrictEqual(disciplineFromHandle, output) + + // From id + const disciplineFromId = await Discipline.fromId(id) + assert.deepStrictEqual(disciplineFromId, output) + + // List const list = await Discipline.list() assert.equal(list.length, 1) const [item] = list - assert.deepStrictEqual(item, { + assert.deepStrictEqual(item, output) + + // Update + const updated = await Discipline.update({ id, - title: 'title-1', - handle: 'handle-1', - createdAt: item!.createdAt, - updatedAt: item!.updatedAt, + title: 'title-2', + handle: 'handle-2', + weight: 2, + }) + + assert.strictEqual(updated.id, id) + + const disciplineFromHandleAfterUpdate = + await Discipline.fromHandle('handle-2') + const outputAfterUpdate = { + id, + title: 'title-2', + handle: 'handle-2', + createdAt: disciplineFromHandle!.createdAt, + updatedAt: disciplineFromHandleAfterUpdate!.updatedAt, deletedAt: null, - weight: 1, - } satisfies Output) + weight: 2, + } satisfies Output + + assert.deepStrictEqual(disciplineFromHandleAfterUpdate, outputAfterUpdate) })) diff --git a/packages/core/src/models/course/discipline.ts b/packages/core/src/models/course/discipline.ts index 10aa6c48..ad44bebf 100644 --- a/packages/core/src/models/course/discipline.ts +++ b/packages/core/src/models/course/discipline.ts @@ -41,6 +41,34 @@ export const create = withZod( }), ) +export const update = withZod( + insertSchema + .pick({ + id: true, + title: true, + handle: true, + weight: true, + }) + .required({ id: true }), + successfulCreateResponse, + async (item) => { + const query = useQuery() + + const row = await query + .update(s.discipline) + .set({ + title: item.title, + handle: item.handle, + weight: item.weight, + }) + .where(eq(s.discipline.id, item.id)) + .returning({ id: s.discipline.id }) + .then(singleRow) + + return row + }, +) + export const list = withZod(z.void(), selectSchema.array(), async () => { const query = useQuery()