diff --git a/apps/api/src/routes/exports/archives/photos.ts b/apps/api/src/routes/exports/archives/photos.ts index d119cc76..4d08ac7a 100644 --- a/apps/api/src/routes/exports/archives/photos.ts +++ b/apps/api/src/routes/exports/archives/photos.ts @@ -1,26 +1,22 @@ +import { dayjs } from '@codeanker/helpers' +import XLSX from '@e965/xlsx' +import type { Gliederung } from '@prisma/client' +import { TRPCError } from '@trpc/server' import archiver from 'archiver' import type { Context } from 'koa' import mime from 'mime' -import { randomUUID } from 'node:crypto' +import { z } from 'zod' import prisma from '../../../prisma.js' import { openFileStream } from '../../../services/file/helpers/getFileUrl.js' -import { sheetAuthorize } from '../sheets/sheets.schema.js' -import { z } from 'zod' +import { getSecurityWorksheet } from '../helpers/getSecurityWorksheet.js' +import { sheetAuthorize, type SheetQuery } from '../sheets/sheets.schema.js' const querySchema = z.object({ mode: z.enum(['group', 'flat']), }) -export async function veranstaltungPhotoArchive(ctx: Context) { - const authorization = await sheetAuthorize(ctx) - if (!authorization) { - return - } - - const { query, gliederung } = authorization - const { mode } = querySchema.parse(ctx.query) - - const anmeldungen = await prisma.anmeldung.findMany({ +function queryAnmeldungen(query: SheetQuery, gliederung?: Gliederung) { + return prisma.anmeldung.findMany({ where: { OR: [ { @@ -46,9 +42,11 @@ export async function veranstaltungPhotoArchive(ctx: Context) { id: true, unterveranstaltung: { select: { + beschreibung: true, veranstaltung: { select: { name: true, + beginn: true, }, }, gliederung: { @@ -64,10 +62,60 @@ export async function veranstaltungPhotoArchive(ctx: Context) { firstname: true, lastname: true, photo: true, + birthday: true, + essgewohnheit: true, }, }, }, }) +} + +function buildSheet( + anmeldungen: Awaited>, + person: { firstname: string; lastname: string } +) { + const rows = anmeldungen.map((anmeldung) => { + const age = dayjs(anmeldung.unterveranstaltung.veranstaltung.beginn).diff(anmeldung.person.birthday, 'years') + const extension = mime.getExtension(anmeldung.person.photo?.mimetype ?? 'text/plain') + return { + Fotomarker: anmeldung.person.photo ? `Fotos/${anmeldung.person.photo.id}.${extension}` : '', + Altersmarker: age < 16 ? 'U16' : age < 18 ? 'U18' : '', + Veggiemarker: anmeldung.person.essgewohnheit === 'OMNIVOR' ? '' : 'brocolli.svg', + Vorname: anmeldung.person.firstname, + Nachname: anmeldung.person.lastname, + Ausschreibung: + anmeldung.unterveranstaltung.beschreibung?.substring(0, 30) || anmeldung.unterveranstaltung.gliederung.name, + } + }) + + const workbook = XLSX.utils.book_new() + const worksheet = XLSX.utils.json_to_sheet(rows) + + XLSX.utils.book_append_sheet(workbook, worksheet, `Teilnehmendenliste`) + + /** add Security Worksheet */ + const { securityWorksheet, securityWorksheetName } = getSecurityWorksheet({ person }, rows.length) + XLSX.utils.book_append_sheet(workbook, securityWorksheet, securityWorksheetName) + + return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer +} + +export async function veranstaltungPhotoArchive(ctx: Context) { + const authorization = await sheetAuthorize(ctx) + if (!authorization) { + return + } + + const { query, gliederung, account } = authorization + const { mode } = querySchema.parse(ctx.query) + + if (mode === 'flat' && account.role !== 'ADMIN') { + throw new TRPCError({ + code: 'FORBIDDEN', + }) + } + + const anmeldungen = await queryAnmeldungen(query, gliederung) const zip = archiver('zip') @@ -86,7 +134,10 @@ export async function veranstaltungPhotoArchive(ctx: Context) { ctx.res.statusCode = 201 ctx.res.setHeader('Content-Type', 'application/zip') - ctx.res.setHeader('Content-Disposition', `attachment; filename="photos-${randomUUID()}.zip"`) + ctx.res.setHeader( + 'Content-Disposition', + `attachment; filename="${mode === 'flat' ? 'FotosForAutomation' : 'Fotos'}.zip"` + ) zip.pipe(ctx.res) for (const { person, unterveranstaltung } of anmeldungen) { @@ -101,11 +152,18 @@ export async function veranstaltungPhotoArchive(ctx: Context) { const extension = mime.getExtension(person.photo.mimetype ?? 'text/plain') zip.append(stream, { - name: mode === 'group' ? `${directory}/${basename}.${extension}` : `${person.photo.id}.${extension}`, + name: mode === 'group' ? `${directory}/${basename}.${extension}` : `Fotos/${person.photo.id}.${extension}`, date: person.photo.createdAt, }) } + if (mode === 'flat') { + const buffer = buildSheet(anmeldungen, account.person) + zip.append(buffer, { + name: 'Datenzusammenführung.xlsx', + }) + } + await zip.finalize() ctx.res.end() diff --git a/apps/api/src/routes/exports/sheets/sheets.schema.ts b/apps/api/src/routes/exports/sheets/sheets.schema.ts index 30d0622d..429c90a0 100644 --- a/apps/api/src/routes/exports/sheets/sheets.schema.ts +++ b/apps/api/src/routes/exports/sheets/sheets.schema.ts @@ -16,6 +16,8 @@ export const sheetQuerySchema = z message: 'Exactly one of veranstaltungId or unterveranstaltungId must be provided', }) +export type SheetQuery = z.infer + export async function sheetAuthorize(ctx: Context) { const [success, query] = await zodSafe(sheetQuerySchema, ctx.query) if (!success) { diff --git a/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts b/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts index 6f7311db..ba42960d 100644 --- a/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts +++ b/apps/api/src/routes/exports/sheets/teilnehmendenliste.ts @@ -29,6 +29,7 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) { unterveranstaltung: { gliederungId: gliederung?.id, }, + status: 'BESTAETIGT', }, select: { id: true, @@ -41,7 +42,12 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) { birthday: true, email: true, telefon: true, - photoId: true, + photo: { + select: { + id: true, + mimetype: true, + }, + }, essgewohnheit: true, address: { select: { @@ -105,6 +111,9 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) { } }) .reduce((acc, cur) => ({ ...acc, ...cur }), {}) + + const age = dayjs(anmeldung.unterveranstaltung.veranstaltung.beginn).diff(anmeldung.person.birthday, 'years') + return { '#': anmeldung.id, @@ -115,16 +124,13 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) { Status: AnmeldungStatusMapping[anmeldung.status].human, Anmeldedatum: anmeldung.createdAt, - 'Foto ID': anmeldung.person.photoId ?? '', + Foto: anmeldung.person.photo ? 'Ja' : 'Nein', Geschlecht: anmeldung.person.gender ? GenderMapping[anmeldung.person.gender].human : '', Vorname: anmeldung.person.firstname, Nachname: anmeldung.person.lastname, Geburtstag: anmeldung.person.birthday, - 'Alter zu Beginn': dayjs(anmeldung.unterveranstaltung.veranstaltung.beginn).diff( - anmeldung.person.birthday, - 'years' - ), + 'Alter zu Beginn': age, Email: anmeldung.person.email, Telefon: anmeldung.person.telefon, Essgewohnheit: anmeldung.person.essgewohnheit, @@ -147,13 +153,14 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) { }) const workbook = XLSX.utils.book_new() const worksheet = XLSX.utils.json_to_sheet(rows) + XLSX.utils.book_append_sheet(workbook, worksheet, `Teilnehmendenliste`) /** add Security Worksheet */ const { securityWorksheet, securityWorksheetName } = getSecurityWorksheet(account, rows.length) XLSX.utils.book_append_sheet(workbook, securityWorksheet, securityWorksheetName) - const filename = `${dayjs().format('YYYYMMDD-hhmm')}-Teilnehmenden.xlsx` + const filename = `${dayjs().format('YYYYMMDD-hhmm')}-Teilnehmendenliste.xlsx` const buf = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer ctx.res.statusCode = 201 diff --git a/apps/api/src/services/unterveranstaltung/unterveranstaltungList.ts b/apps/api/src/services/unterveranstaltung/unterveranstaltungList.ts index fad69c3c..97072df2 100644 --- a/apps/api/src/services/unterveranstaltung/unterveranstaltungList.ts +++ b/apps/api/src/services/unterveranstaltung/unterveranstaltungList.ts @@ -58,7 +58,13 @@ export const unterveranstaltungListProcedure = defineProtectedQueryProcedure({ }, }, _count: { - select: { Anmeldung: true }, + select: { + Anmeldung: { + where: { + status: 'BESTAETIGT', + }, + }, + }, }, meldebeginn: true, meldeschluss: true, diff --git a/apps/api/src/services/veranstaltung/veranstaltungVerwaltungList.ts b/apps/api/src/services/veranstaltung/veranstaltungVerwaltungList.ts index 47f34615..88b4ecc0 100644 --- a/apps/api/src/services/veranstaltung/veranstaltungVerwaltungList.ts +++ b/apps/api/src/services/veranstaltung/veranstaltungVerwaltungList.ts @@ -81,8 +81,18 @@ export const veranstaltungVerwaltungListProcedure = defineProtectedQueryProcedur }, }, }) - - return veranstaltungen + return veranstaltungen.map((veranstaltung) => { + const count = veranstaltung.unterveranstaltungen.reduce((acc, unterveranstaltung) => { + if (unterveranstaltung._count.Anmeldung) { + acc += unterveranstaltung._count.Anmeldung + } + return acc + }, 0) + return { + ...veranstaltung, + anzahlAnmeldungen: count, + } + }) }, }) diff --git a/apps/frontend/src/components/UnterveranstaltungenTable.vue b/apps/frontend/src/components/UnterveranstaltungenTable.vue index c6eed84e..9695e3b4 100644 --- a/apps/frontend/src/components/UnterveranstaltungenTable.vue +++ b/apps/frontend/src/components/UnterveranstaltungenTable.vue @@ -10,6 +10,7 @@ import BasicInput from '@/components/BasicInputs/BasicInput.vue' import { UnterveranstaltungTypeMapping, type RouterInput, type RouterOutput } from '@codeanker/api' import { type TGridColumn } from '@codeanker/datagrid' import DataGridDoubleLineCell from './DataGridDoubleLineCell.vue' +import { formatCurrency } from '@codeanker/helpers' const props = defineProps<{ veranstaltungId?: number @@ -64,7 +65,7 @@ const columns = computed `${value.toFixed(2)} €`, + format: (value) => formatCurrency(value), }, { field: 'maxTeilnehmende', diff --git a/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue b/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue index 13155a77..a356bc79 100644 --- a/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue +++ b/apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue @@ -151,21 +151,13 @@ const files: ExportedFileType[] = [ hoverColor: 'hover:text-primary-700', }, { - name: 'Fotos (Gruppiert)', + name: 'Fotos', 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}&mode=flat`, - }, ] const faqList = useTemplateRef('faqList') diff --git a/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungList.vue b/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungList.vue index fa3f34b4..7cf8e0ab 100644 --- a/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungList.vue +++ b/apps/frontend/src/views/Verwaltung/Veranstaltungen/VeranstaltungList.vue @@ -7,6 +7,7 @@ import { useRouteTitle } from '@/composables/useRouteTitle' import router from '@/router' import { type RouterInput, type RouterOutput } from '@codeanker/api' import { type TGridColumn } from '@codeanker/datagrid' +import { formatCurrency } from '@codeanker/helpers' const { setTitle } = useRouteTitle() setTitle('Veranstaltungen') @@ -51,13 +52,16 @@ const columns: TGridColumn[] = [ }, { field: 'teilnahmegebuehr', - title: 'Teilnahmegebühr', + title: 'Gebühr', + size: '120px', sortable: true, + format: (value) => formatCurrency(value), }, { field: 'maxTeilnehmende', - title: 'TN', - sortable: true, + title: 'Anm. / Max', + size: '150px', + format: (value, row) => `${row.anzahlAnmeldungen} / ${value}`, }, ] diff --git a/package.json b/package.json index 901eeffa..587ac1b9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "codeanker-project", "author": "CODEANKER GmbH", - "version": "2.3.0", + "version": "2.4.0", "description": "", "license": "CC-BY-3.0-DE", "workspaces": [ diff --git a/packages/helpers/src/formatCurrency.ts b/packages/helpers/src/formatCurrency.ts new file mode 100644 index 00000000..3b1f1f2c --- /dev/null +++ b/packages/helpers/src/formatCurrency.ts @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +export function formatCurrency(value, round = false, isCents = false) { + if (typeof value !== 'number') { + const parsedValue = Number.parseFloat(value) + if (Number.isNaN(parsedValue)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return value + } + value = parsedValue + } + if (!round) { + value = toFixed(value, 2) + } + const formatter = new Intl.NumberFormat('de', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + }) + if (isCents) { + return formatter.format(value / 100) + } else { + return formatter.format(value) + } +} + +function toFixed(num, fixed) { + const re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?') + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + const matches = num.toString().match(re) + if (matches) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + return Number.parseFloat(matches[0]) + } +} diff --git a/packages/helpers/src/index.ts b/packages/helpers/src/index.ts index 9f3e260d..38d93d67 100644 --- a/packages/helpers/src/index.ts +++ b/packages/helpers/src/index.ts @@ -3,3 +3,4 @@ export * from './format.js' export * from './password-strength.js' export * from './formatBytes.js' export * from './group-by.js' +export * from './formatCurrency.js'