diff --git a/apps/api/src/routes/exports/archives/photos.ts b/apps/api/src/routes/exports/archives/photos.ts index be34566c..d119cc76 100644 --- a/apps/api/src/routes/exports/archives/photos.ts +++ b/apps/api/src/routes/exports/archives/photos.ts @@ -5,6 +5,11 @@ import { randomUUID } from 'node:crypto' import prisma from '../../../prisma.js' import { openFileStream } from '../../../services/file/helpers/getFileUrl.js' import { sheetAuthorize } from '../sheets/sheets.schema.js' +import { z } from 'zod' + +const querySchema = z.object({ + mode: z.enum(['group', 'flat']), +}) export async function veranstaltungPhotoArchive(ctx: Context) { const authorization = await sheetAuthorize(ctx) @@ -13,6 +18,7 @@ export async function veranstaltungPhotoArchive(ctx: Context) { } const { query, gliederung } = authorization + const { mode } = querySchema.parse(ctx.query) const anmeldungen = await prisma.anmeldung.findMany({ where: { @@ -54,6 +60,7 @@ export async function veranstaltungPhotoArchive(ctx: Context) { }, person: { select: { + id: true, firstname: true, lastname: true, photo: true, @@ -90,9 +97,11 @@ export async function veranstaltungPhotoArchive(ctx: Context) { const stream = await openFileStream(person.photo) const directory = `Fotos Teilnehmende ${unterveranstaltung.veranstaltung.name}/${unterveranstaltung.gliederung.name}` - const name = `${person.firstname} ${person.lastname}.${mime.getExtension(person.photo.mimetype ?? 'text/plain')}` + const basename = mode === 'group' ? `${person.firstname} ${person.lastname}` : person.id + const extension = mime.getExtension(person.photo.mimetype ?? 'text/plain') + zip.append(stream, { - name: `${directory}/${name}`, + name: mode === 'group' ? `${directory}/${basename}.${extension}` : `${person.photo.id}.${extension}`, date: person.photo.createdAt, }) } diff --git a/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts b/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts index cc57e593..6f7311db 100644 --- a/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts +++ b/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts @@ -115,7 +115,7 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) { Status: AnmeldungStatusMapping[anmeldung.status].human, Anmeldedatum: anmeldung.createdAt, - Foto: anmeldung.person.photoId ? 'Ja' : 'Nein', + 'Foto ID': anmeldung.person.photoId ?? '', Geschlecht: anmeldung.person.gender ? GenderMapping[anmeldung.person.gender].human : '', Vorname: anmeldung.person.firstname, diff --git a/apps/api/src/services/anmeldung/anmeldungGet.ts b/apps/api/src/services/anmeldung/anmeldungGet.ts index d7866b4f..d5d3d140 100644 --- a/apps/api/src/services/anmeldung/anmeldungGet.ts +++ b/apps/api/src/services/anmeldung/anmeldungGet.ts @@ -46,6 +46,14 @@ const select = { unterveranstaltung: { select: { id: true, + beschreibung: true, + gliederung: { + select: { + id: true, + name: true, + edv: true, + }, + }, veranstaltung: { select: { id: true, diff --git a/apps/api/src/services/anmeldungLink/anmeldeLink.list.ts b/apps/api/src/services/anmeldungLink/anmeldeLink.list.ts index 7f21b4c1..3a4e6f07 100644 --- a/apps/api/src/services/anmeldungLink/anmeldeLink.list.ts +++ b/apps/api/src/services/anmeldungLink/anmeldeLink.list.ts @@ -1,17 +1,53 @@ import { z } from 'zod' -import { definePublicQueryProcedure } from '../../types/defineProcedure.js' +import { defineOrderBy } from '../../types/defineOrderBy.js' +import { defineProtectedQueryProcedure } from '../../types/defineProcedure.js' import client from '../../prisma.js' +import { Role, Prisma } from '@prisma/client' +import { ZPaginationSchema } from '../../types/defineQuery.js' +import type { Context } from '../../context.js' -export const anmeldungLinkListProcedure = definePublicQueryProcedure({ +const filterSchema = z.discriminatedUnion('type', [ + z.strictObject({ + type: z.literal('veranstaltung'), + veranstaltungId: z.number(), + }), + z.strictObject({ + type: z.literal('unterveranstaltung'), + unterveranstaltungId: z.number(), + }), +]) + +type FilterSchema = z.infer + +function getWhere(ctx: Context, filter: FilterSchema): Prisma.AnmeldungLinkWhereInput { + if (filter.type === 'veranstaltung') { + return { + unterveranstaltung: { + veranstaltungId: filter.veranstaltungId, + }, + } + } else if (filter.type === 'unterveranstaltung') { + return { + unterveranstaltungId: filter.unterveranstaltungId, + } + } + throw new Error('Invalid filter type') +} + +export const anmeldungLinkListProcedure = defineProtectedQueryProcedure({ key: 'list', + roleIds: [Role.ADMIN, Role.GLIEDERUNG_ADMIN, Role.USER], inputSchema: z.strictObject({ - unterveranstaltungId: z.number().int(), + pagination: ZPaginationSchema, + filter: filterSchema, + orderBy: defineOrderBy(['usedAt', 'createdBy', 'comment']), }), - handler: async ({ input: { unterveranstaltungId } }) => { + async handler({ ctx, input }) { + const { skip, take } = input.pagination const result = await client.anmeldungLink.findMany({ - where: { - unterveranstaltungId, - }, + skip, + take, + where: getWhere(ctx, input.filter), orderBy: { createdAt: 'desc', }, @@ -20,6 +56,7 @@ export const anmeldungLinkListProcedure = definePublicQueryProcedure({ createdAt: true, usedAt: true, comment: true, + accessToken: true, createdBy: { select: { person: { @@ -32,6 +69,8 @@ export const anmeldungLinkListProcedure = definePublicQueryProcedure({ }, anmeldung: { select: { + id: true, + createdAt: true, person: { select: { firstname: true, @@ -47,15 +86,15 @@ export const anmeldungLinkListProcedure = definePublicQueryProcedure({ }, }) -export const anmeldungLinkCountProcedure = definePublicQueryProcedure({ +export const anmeldungLinkCountProcedure = defineProtectedQueryProcedure({ key: 'count', + roleIds: [Role.ADMIN, Role.GLIEDERUNG_ADMIN, Role.USER], inputSchema: z.strictObject({ - unterveranstaltungId: z.number().int(), + filter: filterSchema, }), - handler: ({ input: { unterveranstaltungId } }) => - client.anmeldungLink.count({ - where: { - unterveranstaltungId, - }, - }), + handler: ({ ctx, input }) => { + return client.anmeldungLink.count({ + where: getWhere(ctx, input.filter), + }) + }, }) diff --git a/apps/frontend/src/components/UIComponents/TwoRowText.vue b/apps/frontend/src/components/UIComponents/TwoRowText.vue deleted file mode 100644 index 229372e2..00000000 --- a/apps/frontend/src/components/UIComponents/TwoRowText.vue +++ /dev/null @@ -1,17 +0,0 @@ - - - diff --git a/apps/frontend/src/components/UnterveranstaltungenTable.vue b/apps/frontend/src/components/UnterveranstaltungenTable.vue index 2fda23cf..c6eed84e 100644 --- a/apps/frontend/src/components/UnterveranstaltungenTable.vue +++ b/apps/frontend/src/components/UnterveranstaltungenTable.vue @@ -9,7 +9,7 @@ import { apiClient } from '@/api' import BasicInput from '@/components/BasicInputs/BasicInput.vue' import { UnterveranstaltungTypeMapping, type RouterInput, type RouterOutput } from '@codeanker/api' import { type TGridColumn } from '@codeanker/datagrid' -import TwoRowText from './UIComponents/TwoRowText.vue' +import DataGridDoubleLineCell from './DataGridDoubleLineCell.vue' const props = defineProps<{ veranstaltungId?: number @@ -33,11 +33,13 @@ const columns = computed ({ - title: content.veranstaltung.name, - subtitle: content.beschreibung.split(' ').slice(0, 5).join(' '), - }), + cell: DataGridDoubleLineCell, + cellProps: (formattedValue, row) => { + return { + title: row.content.veranstaltung.name, + message: row.content.beschreibung.split(' ').slice(0, 5).join(' '), + } + }, }, { field: 'gliederung.name', diff --git a/apps/frontend/src/components/data/AnmeldeLinkTable.vue b/apps/frontend/src/components/data/AnmeldeLinkTable.vue index 02386906..22d20c62 100644 --- a/apps/frontend/src/components/data/AnmeldeLinkTable.vue +++ b/apps/frontend/src/components/data/AnmeldeLinkTable.vue @@ -1,85 +1,148 @@ diff --git a/apps/frontend/src/components/data/AnmeldungenTable.vue b/apps/frontend/src/components/data/AnmeldungenTable.vue index 653399b6..840a9055 100644 --- a/apps/frontend/src/components/data/AnmeldungenTable.vue +++ b/apps/frontend/src/components/data/AnmeldungenTable.vue @@ -25,6 +25,7 @@ import { import { useDataGridFilter, useDataGridOrderBy, useGrid, type TGridColumn } from '@codeanker/datagrid' import { dayjs } from '@codeanker/helpers' import UserLogo from '../UIComponents/UserLogo.vue' +import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/24/solid' type Props = { filter: RouterInput['anmeldung']['list']['filter'] @@ -232,7 +233,6 @@ if (loggedInAccount.value?.role === 'ADMIN') { } /// Filter via Route laden - const defaultOrderBy = { createdAt: 'desc', } as const @@ -372,6 +372,25 @@ onMounted(() => { {{ currentAnmeldung?.unterveranstaltung.veranstaltung.name }} +
+
Unterveranstaltung
+
+
+ {{ currentAnmeldung?.unterveranstaltung.beschreibung }} + + + + ({{ currentAnmeldung?.unterveranstaltung.gliederung.name }}) +
+
+
Status
diff --git a/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue b/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue index 716c3487..13155a77 100644 --- a/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue +++ b/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue @@ -20,10 +20,12 @@ import { useRoute } from 'vue-router' import { apiClient } from '@/api' import Abbr from '@/components/Abbr.vue' import CustomFieldsTable from '@/components/CustomFields/CustomFieldsTable.vue' +import AnmeldeLinkTable from '@/components/data/AnmeldeLinkTable.vue' import AnmeldungenTable from '@/components/data/AnmeldungenTable.vue' import FilesExport, { type ExportedFileType } from '@/components/FilesExport.vue' import FilesListAndUpload from '@/components/FilesListAndUpload.vue' import FormUnterveranstaltungLandingSettings from '@/components/forms/unterveranstaltung/FormUnterveranstaltungLandingSettings.vue' +import AnmeldeLinkCreateModal from '@/components/UIComponents/AnmeldeLinkCreateModal.vue' import Badge from '@/components/UIComponents/Badge.vue' import Button from '@/components/UIComponents/Button.vue' import Tab from '@/components/UIComponents/components/Tab.vue' @@ -33,9 +35,6 @@ import { loggedInAccount } from '@/composables/useAuthentication' import { useRouteTitle } from '@/composables/useRouteTitle' import { formatDateWith } from '@codeanker/helpers' import FAQList from '../FAQs/FAQList.vue' -import { PlusIcon } from '@heroicons/vue/24/solid' -import AnmeldeLinkTable from '@/components/data/AnmeldeLinkTable.vue' -import AnmeldeLinkCreateModal from '@/components/UIComponents/AnmeldeLinkCreateModal.vue' const route = useRoute() const { setTitle } = useRouteTitle() @@ -152,12 +151,20 @@ const files: ExportedFileType[] = [ hoverColor: 'hover:text-primary-700', }, { - name: 'Fotos', - description: 'Alle Fotos von bestätigten Teilnehmenden', + name: 'Fotos (Gruppiert)', + description: 'Alle Fotos von bestätigten Teilnehmenden, gruppiert nach Veranstaltung und Ausschreibung', icon: CameraIcon, bgColor: 'bg-orange-600', hoverColor: 'hover:text-orange-700', - href: `/api/export/archive/photos?${exportParams}`, + href: `/api/export/archive/photos?${exportParams}&mode=group`, + }, + { + name: 'Fotos (Für automatisierte Verarbeitung)', + description: 'Alle Fotos von bestätigten Teilnehmenden, optimiert für automatisierte Verarbeitung', + icon: CameraIcon, + bgColor: 'bg-orange-600', + hoverColor: 'hover:text-orange-700', + href: `/api/export/archive/photos?${exportParams}&mode=flat`, }, ] @@ -295,23 +302,26 @@ const anmeldeLinkCreateModal = useTemplateRef('anmeldeLinkCreateModal') key="anmeldelinks" >
-
+
Anmeldelinks

Mit einem Anmeldelink können Personen sich auch dann anmelden, wenn der Meldeschluss erreicht oder die - maximale Teilnehmendenzahl erreicht ist. + maximale Teilnehmendenzahl erreicht ist. Eine Bestätigung der Anmeldung ist dennoch erforderlich.

-

Eine Bestätigung der Anmeldung ist trotzdem erforderlich.

- +
+ +
import { + CameraIcon, ClipboardDocumentListIcon, DocumentIcon, MegaphoneIcon, - UsersIcon, SquaresPlusIcon, + UsersIcon, WalletIcon, - CameraIcon, } from '@heroicons/vue/24/outline' import { useAsyncState } from '@vueuse/core' import { computed } from 'vue' @@ -19,11 +19,11 @@ import Badge from '@/components/UIComponents/Badge.vue' import Tab from '@/components/UIComponents/components/Tab.vue' import InfoList from '@/components/UIComponents/InfoList.vue' import Tabs from '@/components/UIComponents/Tabs.vue' +import VeranstaltungCard from '@/components/UIComponents/VeranstaltungCard.vue' import UnterveranstaltungenTable from '@/components/UnterveranstaltungenTable.vue' import { useRouteTitle } from '@/composables/useRouteTitle' import { formatDateWith } from '@codeanker/helpers' import { PlusIcon } from '@heroicons/vue/24/solid' -import VeranstaltungCard from '@/components/UIComponents/VeranstaltungCard.vue' const { setTitle } = useRouteTitle() @@ -104,12 +104,20 @@ const files: ExportedFileType[] = [ hoverColor: 'hover:text-primary-700', }, { - name: 'Fotos', - description: 'Alle Fotos von bestätigten Teilnehmenden', + name: 'Fotos (Gruppiert)', + description: 'Alle Fotos von bestätigten Teilnehmenden, gruppiert nach Veranstaltung und Ausschreibung', + icon: CameraIcon, + bgColor: 'bg-orange-600', + hoverColor: 'hover:text-orange-700', + href: `/api/export/archive/photos?${exportParams}&mode=group`, + }, + { + name: 'Fotos (Für automatisierte Verarbeitung)', + description: 'Alle Fotos von bestätigten Teilnehmenden, optimiert für automatisierte Verarbeitung', icon: CameraIcon, bgColor: 'bg-orange-600', hoverColor: 'hover:text-orange-700', - href: `/api/export/archive/photos?${exportParams}`, + href: `/api/export/archive/photos?${exportParams}&mode=flat`, }, ]