diff --git a/apps/api/prisma/migrations/20260304182124_custom_field_order/migration.sql b/apps/api/prisma/migrations/20260304182124_custom_field_order/migration.sql new file mode 100644 index 00000000..79c1d2b8 --- /dev/null +++ b/apps/api/prisma/migrations/20260304182124_custom_field_order/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "CustomField" ADD COLUMN "order" INTEGER; diff --git a/apps/api/prisma/schema/CustomField.prisma b/apps/api/prisma/schema/CustomField.prisma index 0a1415df..ac05f0a7 100644 --- a/apps/api/prisma/schema/CustomField.prisma +++ b/apps/api/prisma/schema/CustomField.prisma @@ -19,6 +19,7 @@ model CustomField { id String @id @default(uuid(7)) name String description String? + order Int? type CustomFieldType required Boolean @default(false) options String[] diff --git a/apps/api/src/services/customFields/customFields.router.ts b/apps/api/src/services/customFields/customFields.router.ts index 3e38d8cc..5f861fa8 100644 --- a/apps/api/src/services/customFields/customFields.router.ts +++ b/apps/api/src/services/customFields/customFields.router.ts @@ -9,6 +9,8 @@ import { customFieldsVeranstaltungCreate } from './customFieldsVeranstaltungCrea import { customFieldsVeranstaltungDelete, customFieldsUnterveranstaltungDelete } from './customFieldsDelete.js' import { customFieldsUpdate } from './customFieldsUpdate.js' import { customFieldValuesUpdate } from './customFieldValuesUpdate.js' +import { customFieldUnterveranstaltungOrder } from './schema/customFieldsUnterveranstaltungOrder.js' +import { customFieldVeranstaltungOrder } from './schema/customFieldsVeranstaltungOrder.js' // Import Routes here - do not delete this line export const customFieldsRouter = mergeRouters( @@ -21,6 +23,8 @@ export const customFieldsRouter = mergeRouters( customFieldsUnterveranstaltungDelete, customFieldsUnterveranstaltungCreate, customFieldValuesUpdate, - customFieldsTemplates + customFieldsTemplates, + customFieldVeranstaltungOrder, + customFieldUnterveranstaltungOrder // Add Routes here - do not delete this line ) diff --git a/apps/api/src/services/customFields/customFieldsList.ts b/apps/api/src/services/customFields/customFieldsList.ts index f7d9a09c..35fbd0f9 100644 --- a/apps/api/src/services/customFields/customFieldsList.ts +++ b/apps/api/src/services/customFields/customFieldsList.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { CustomFieldPosition, CustomFieldType, Prisma } from '@prisma/client' import prisma from '../../prisma.js' -import { definePublicQueryProcedure } from '../../types/defineProcedure.js' +import { defineProtectedQueryProcedure, definePublicQueryProcedure } from '../../types/defineProcedure.js' import { calculatePagination, defineEmptyQueryResponse, @@ -10,6 +10,7 @@ import { defineTableInput, } from '../../types/defineTableProcedure.js' import { boolish } from '../../util/zod.js' +import { getGliederungRequireAdmin } from '../../util/getGliederungRequireAdmin.js' const baseFilter = z.strictObject({ entity: z.enum(['veranstaltung', 'unterveranstaltung']), @@ -23,6 +24,7 @@ export const customFieldsTable = definePublicQueryProcedure({ async handler({ input: { entity, entityId, position } }) { if (entity === 'veranstaltung') { return await prisma.customField.findMany({ + orderBy: [{ order: { sort: 'asc', nulls: 'last' } }], where: { veranstaltungId: entityId, positions: @@ -35,6 +37,14 @@ export const customFieldsTable = definePublicQueryProcedure({ }) } else if (entity === 'unterveranstaltung') { return await prisma.customField.findMany({ + orderBy: [ + { + unterveranstaltungId: { sort: 'asc', nulls: 'first' }, + }, + { + order: { sort: 'asc', nulls: 'last' }, + }, + ], where: { positions: position === undefined @@ -64,8 +74,9 @@ export const customFieldsTable = definePublicQueryProcedure({ }, }) -export const customFieldsList = definePublicQueryProcedure({ +export const customFieldsList = defineProtectedQueryProcedure({ key: 'table', + roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], inputSchema: baseFilter.extend({ table: defineTableInput({ filter: { @@ -74,10 +85,14 @@ export const customFieldsList = definePublicQueryProcedure({ required: boolish, position: z.nativeEnum(CustomFieldPosition), }, - orderBy: ['name'], + orderBy: ['name', 'order'], }), }), - async handler({ input }) { + async handler({ ctx, input }) { + if (ctx.account.role === 'GLIEDERUNG_ADMIN') { + await getGliederungRequireAdmin(ctx.accountId) + } + const where: Prisma.CustomFieldWhereInput = { name: { contains: input.table?.filter?.name, @@ -93,6 +108,25 @@ export const customFieldsList = definePublicQueryProcedure({ }, } + if (input.entity === 'veranstaltung') { + where.veranstaltungId = input.entityId + } else if (input.entity === 'unterveranstaltung') { + where.OR = [ + { + unterveranstaltungId: input.entityId, + }, + { + veranstaltung: { + unterveranstaltungen: { + some: { + id: input.entityId, + }, + }, + }, + }, + ] + } + const total = await prisma.customField.count({ where }) const { pageIndex, pageSize, pages } = calculatePagination(total, input.table?.pagination) @@ -100,15 +134,13 @@ export const customFieldsList = definePublicQueryProcedure({ const customFields = await prisma.customField.findMany({ take: pageSize, skip: pageSize * pageIndex, - where: { - ...where, - veranstaltungId: input.entityId, - }, + where, orderBy: input.table?.orderBy, select: { id: true, name: true, description: true, + order: true, type: true, positions: true, required: true, @@ -122,27 +154,18 @@ export const customFieldsList = definePublicQueryProcedure({ const customFields = await prisma.customField.findMany({ take: pageSize, skip: pageSize * pageIndex, - where: { - ...where, - OR: [ - { - unterveranstaltungId: input.entityId, - }, - { - veranstaltung: { - unterveranstaltungen: { - some: { - id: input.entityId, - }, - }, - }, - }, - ], - }, + orderBy: [ + { + unterveranstaltungId: { sort: 'asc', nulls: 'first' }, + }, + ...(input.table?.orderBy ?? []), + ], + where, select: { id: true, name: true, description: true, + order: true, type: true, positions: true, required: true, diff --git a/apps/api/src/services/customFields/schema/customField.schema.ts b/apps/api/src/services/customFields/schema/customField.schema.ts index 8172e451..af4585c1 100644 --- a/apps/api/src/services/customFields/schema/customField.schema.ts +++ b/apps/api/src/services/customFields/schema/customField.schema.ts @@ -1,11 +1,21 @@ import { CustomFieldPosition, CustomFieldType } from '@prisma/client' import { z } from 'zod' -export const customFieldSchema = z.strictObject({ - name: z.string().min(1), - description: z.string().nullable(), - type: z.nativeEnum(CustomFieldType), - required: z.boolean(), - options: z.array(z.string()), - positions: z.nativeEnum(CustomFieldPosition).array(), -}) +export const customFieldSchema = z + .strictObject({ + name: z.string().min(1), + description: z.string().nullable(), + type: z.nativeEnum(CustomFieldType), + required: z.boolean(), + options: z.array(z.string()), + positions: z.nativeEnum(CustomFieldPosition).array().min(1), + }) + .superRefine((values, ctx) => { + const optionTypes = ['BASIC_DROPDOWN', 'BASIC_RADIO', 'BASIC_SWITCH'] as CustomFieldType[] + if (optionTypes.includes(values.type) && values.options.length === 0) { + ctx.addIssue({ + code: 'custom', + message: 'Es muss mindestens eine Auswahlmöglichkeit angegeben werden', + }) + } + }) diff --git a/apps/api/src/services/customFields/schema/customFieldsUnterveranstaltungOrder.ts b/apps/api/src/services/customFields/schema/customFieldsUnterveranstaltungOrder.ts new file mode 100644 index 00000000..2b0f974a --- /dev/null +++ b/apps/api/src/services/customFields/schema/customFieldsUnterveranstaltungOrder.ts @@ -0,0 +1,54 @@ +import z from 'zod' +import { defineProtectedMutateProcedure } from '../../../types/defineProcedure.js' +import { getGliederungRequireAdmin } from '../../../util/getGliederungRequireAdmin.js' +import { TRPCError } from '@trpc/server' +import prisma from '../../../prisma.js' + +export const customFieldUnterveranstaltungOrder = defineProtectedMutateProcedure({ + key: 'unterveranstaltungOrder', + roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'], + inputSchema: z.strictObject({ + unterveranstaltungId: z.string().uuid(), + fields: z.array(z.string().uuid()), + }), + handler: async ({ ctx, input }) => { + if (ctx.account.role === 'GLIEDERUNG_ADMIN') { + const gliederung = await getGliederungRequireAdmin(ctx.accountId) + if (gliederung.id !== input.unterveranstaltungId) { + throw new TRPCError({ + code: 'FORBIDDEN', + }) + } + } + + const fields = await prisma.customField.findMany({ + where: { + unterveranstaltungId: input.unterveranstaltungId, + id: { + in: input.fields, + }, + }, + }) + + if (fields.length !== input.fields.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Some supplied fields do not belong to the given unterveranstaltung!', + }) + } + + await prisma.$transaction( + input.fields.map((field, index) => + prisma.customField.update({ + where: { + unterveranstaltungId: input.unterveranstaltungId, + id: field, + }, + data: { + order: index + 1, + }, + }) + ) + ) + }, +}) diff --git a/apps/api/src/services/customFields/schema/customFieldsVeranstaltungOrder.ts b/apps/api/src/services/customFields/schema/customFieldsVeranstaltungOrder.ts new file mode 100644 index 00000000..3cc63732 --- /dev/null +++ b/apps/api/src/services/customFields/schema/customFieldsVeranstaltungOrder.ts @@ -0,0 +1,44 @@ +import { TRPCError } from '@trpc/server' +import z from 'zod' +import prisma from '../../../prisma.js' +import { defineProtectedMutateProcedure } from '../../../types/defineProcedure.js' + +export const customFieldVeranstaltungOrder = defineProtectedMutateProcedure({ + key: 'veranstaltungOrder', + roleIds: ['ADMIN'], + inputSchema: z.strictObject({ + veranstaltungId: z.string().uuid(), + fields: z.array(z.string().uuid()), + }), + handler: async ({ input }) => { + const fields = await prisma.customField.findMany({ + where: { + veranstaltungId: input.veranstaltungId, + id: { + in: input.fields, + }, + }, + }) + + if (fields.length !== input.fields.length) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Some supplied fields do not belong to the given veranstaltung!', + }) + } + + await prisma.$transaction( + input.fields.map((field, index) => + prisma.customField.update({ + where: { + veranstaltungId: input.veranstaltungId, + id: field, + }, + data: { + order: index + 1, + }, + }) + ) + ) + }, +}) diff --git a/apps/frontend/src/components/CustomFields/CustomFieldsTable.vue b/apps/frontend/src/components/CustomFields/CustomFieldsTable.vue index f9bcf945..2ec38a34 100644 --- a/apps/frontend/src/components/CustomFields/CustomFieldsTable.vue +++ b/apps/frontend/src/components/CustomFields/CustomFieldsTable.vue @@ -32,6 +32,18 @@ const router = useRouter() const column = createColumnHelper() const columns = [ + column.accessor('order', { + header: 'Reihenfolge', + enableSorting: true, + cell({ getValue }) { + const order = getValue() + if (!order) { + return '-' + } + + return order + }, + }), column.accessor('name', { header: 'Name', enableColumnFilter: true, @@ -174,6 +186,7 @@ function onClick(field: CustomField) { diff --git a/apps/frontend/src/views/Unterveranstaltung/CustomFields/CustomFieldUnterveranstaltungOrder.vue b/apps/frontend/src/views/Unterveranstaltung/CustomFields/CustomFieldUnterveranstaltungOrder.vue new file mode 100644 index 00000000..8cb874a9 --- /dev/null +++ b/apps/frontend/src/views/Unterveranstaltung/CustomFields/CustomFieldUnterveranstaltungOrder.vue @@ -0,0 +1,203 @@ + + + diff --git a/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue b/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue index 8c8f7e8a..83b18296 100644 --- a/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue +++ b/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue @@ -2,12 +2,14 @@ import { CameraIcon, ChatBubbleLeftRightIcon, + ChevronUpDownIcon, CodeBracketIcon, DocumentDuplicateIcon, DocumentIcon, HandRaisedIcon, LinkIcon, MegaphoneIcon, + PlusIcon, RocketLaunchIcon, SquaresPlusIcon, UserGroupIcon, @@ -371,15 +373,28 @@ const anmeldeLinkCreateModal = useTemplateRef('anmeldeLinkCreateModal') Hier können benutzerdefinierte Felder erstellt werden, welche für alle Unterveranstaltungen gelten.

+
- Neues Feld + + Neues Feld + + + Reihenfolge ändern + +
import('./CustomFields/CustomFieldUnterveranstaltungOrder.vue'), + meta: { + breadcrumbs: [ + detailCrumb, + { + text: 'Benutzerdefinierte Felder sortieren', + }, + ], + }, + }, { name: 'Unterveranstaltung Custom Field bearbeiten', path: ':unterveranstaltungId/fields/:fieldId', diff --git a/apps/frontend/src/views/Verwaltung/Veranstaltungen/CustomFields/CustomFieldVeranstaltungOrder.vue b/apps/frontend/src/views/Verwaltung/Veranstaltungen/CustomFields/CustomFieldVeranstaltungOrder.vue new file mode 100644 index 00000000..ddcef8b9 --- /dev/null +++ b/apps/frontend/src/views/Verwaltung/Veranstaltungen/CustomFields/CustomFieldVeranstaltungOrder.vue @@ -0,0 +1,159 @@ + + + diff --git a/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungDetail.vue b/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungDetail.vue index 7730e943..74d2d789 100644 --- a/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungDetail.vue +++ b/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungDetail.vue @@ -10,6 +10,7 @@ import { RocketLaunchIcon, DocumentDuplicateIcon, LinkIcon, + ChevronUpDownIcon, } from '@heroicons/vue/24/outline' import { useAsyncState } from '@vueuse/core' import { computed } from 'vue' @@ -352,12 +353,25 @@ function copyProgramLink() { Hier können benutzerdefinierte Felder erstellt werden, welche für alle Unterveranstaltungen gelten.

- - Neues Feld - +
+ + + Neues Feld + + + + Reihenfolge ändern + +
import('./CustomFields/CustomFieldVeranstaltungOrder.vue'), + meta: { + breadcrumbs: [ + detailCrumb, + { + text: 'Benutzerdefinierte Felder sortieren', + }, + ], + }, + }, { name: 'Verwaltung Custom Field bearbeiten', path: ':veranstaltungId/fields/:fieldId',