Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion .idea/jsLinters/eslint.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@nestjs/swagger": "^8.1.1",
"@prisma/client": "^6.5.0",
"@radix-ui/react-dialog": "^1.1.4",
"archiver": "^7.0.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"nestjs-prisma": "^0.24.0",
Expand All @@ -43,6 +44,7 @@
"@faker-js/faker": "^9.3.0",
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/archiver": "^7.0.0",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/node": "^20.17.12",
Expand Down
11 changes: 11 additions & 0 deletions apps/backend/src/user/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
Controller,
Delete,
Get,
Header,
Param,
ParseIntPipe,
Patch,
Expand Down Expand Up @@ -109,6 +110,16 @@ export class UserController {
return this.userService.findPendingProfilePictures();
}

@Post('profile-pictures/export')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
@Roles(Role.BODY_ADMIN)
@Header('Content-Type', 'application/zip')
@Header('Content-Disposition', 'attachment; filename="profile-pictures.zip"')
async exportProfilePictures(@Body('userIds') userIds?: string[]): Promise<StreamableFile> {
return new StreamableFile(await this.userService.exportProfilePictures(userIds));
}

@Patch(':id')
@UseGuards(AuthGuard('jwt'), RolesGuard)
@ApiBearerAuth()
Expand Down
32 changes: 32 additions & 0 deletions apps/backend/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
UnauthorizedException,
} from '@nestjs/common';
import { Prisma, ProfilePictureStatus, User } from '@prisma/client';
import archiver from 'archiver';
import { PrismaService } from 'nestjs-prisma';
import { optimizeImage } from 'src/util';

Expand Down Expand Up @@ -242,6 +243,37 @@ export class UserService {
}
}

async exportProfilePictures(userIds?: string[]): Promise<Buffer> {
const where: Prisma.ProfilePictureWhereInput = { status: ProfilePictureStatus.ACCEPTED };
if (userIds && userIds.length > 0) {
where.userId = { in: userIds };
}
const pictures = await this.prisma.profilePicture.findMany({
where,
select: {
profileImage: true,
user: { select: { fullName: true, authSchId: true } },
},
});

return new Promise((resolve, reject) => {
const archive = archiver('zip', { zlib: { level: 6 } });
const chunks: Buffer[] = [];
archive.on('data', (chunk: Buffer) => chunks.push(chunk));
archive.on('end', () => resolve(Buffer.concat(chunks)));
archive.on('error', (err: Error) => reject(err));

for (const picture of pictures) {
const imageBuffer = Buffer.from(picture.profileImage.buffer);
const safeName =
picture.user.fullName.replace(/[^\w\s\-áéíóöőúüűÁÉÍÓÖŐÚÜŰ]/g, '_').trim() || picture.user.authSchId;
archive.append(imageBuffer, { name: `${safeName}-${picture.user.authSchId}.jpg` });
}

archive.finalize();
});
}

async findProfilePicture(authSchId: string): Promise<Buffer> {
try {
const profilePic = await this.prisma.profilePicture.findUniqueOrThrow({ where: { userId: authSchId } });
Expand Down
31 changes: 10 additions & 21 deletions apps/frontend/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,30 +1,25 @@
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import nextPlugin from '@next/eslint-plugin-next';
import typescriptEslintPlugin from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});

export default [
{
ignores: ['**/.eslintrc.js', 'eslint.config.mjs'],
},
...compat.config({
extends: [
'next/core-web-vitals',
// next/typescript already has @typescript-eslint/eslint-plugin bundled, but it probably has
// a different version than the one we're using, so we're including it manually in the plugins
// 'next/typescript'
],
}),
{
plugins: {
'@next/next': nextPlugin,
},
rules: {
...nextPlugin.configs.recommended.rules,
...nextPlugin.configs['core-web-vitals'].rules,
},
},
{
plugins: {
'@typescript-eslint': typescriptEslintPlugin,
Expand All @@ -41,12 +36,6 @@ export default [
},
},

// settings: {
// react: {
// version: 'detect',
// },
// },

rules: {
'no-html-link-for-pages': 'off',
'react/react-in-jsx-scope': 'off',
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"@types/node": "^20.17.12",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4",
"eslint-config-next": "15.2.2",
"eslint-config-next": "15.4.8",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
Expand Down
100 changes: 100 additions & 0 deletions apps/frontend/src/app/admin/profile-picture-export/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
'use client';
import { useRouter } from 'next/navigation';
import React from 'react';
import useSWR from 'swr';

import Th1 from '@/components/typography/typography';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import LoadingCard from '@/components/ui/LoadingCard';
import { axiosGetFetcher } from '@/lib/fetchers';
import { exportProfilePictures } from '@/lib/profile-pictures';
import { ProfilePictureStatus, UserEntityPagination } from '@/types/user-entity';
import { LuDownload } from 'react-icons/lu';

export default function Page() {
const router = useRouter();
const { data, isLoading } = useSWR<UserEntityPagination>('users?page=-1&pageSize=-1', axiosGetFetcher);
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(new Set());

const usersWithPictures = React.useMemo(
() => data?.users.filter((u) => u.profilePicture?.status === ProfilePictureStatus.ACCEPTED) ?? [],
[data]
);

const allSelected = usersWithPictures.length > 0 && selectedIds.size === usersWithPictures.length;

const toggleSelectAll = () => {
if (allSelected) {
setSelectedIds(new Set());
} else {
setSelectedIds(new Set(usersWithPictures.map((u) => u.authSchId)));
}
};

const toggleUser = (authSchId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(authSchId)) {
next.delete(authSchId);
} else {
next.add(authSchId);
}
return next;
});
};

const handleExport = () => exportProfilePictures(selectedIds.size > 0 ? [...selectedIds] : undefined);

const exportLabel = selectedIds.size === 0 ? 'mind' : `${selectedIds.size} db`;

return (
<>
<div className='flex justify-between items-center flex-wrap gap-4'>
<Th1>Profilképek exportálása</Th1>
<div className='flex gap-2'>
<Button variant='outline' onClick={toggleSelectAll} disabled={usersWithPictures.length === 0}>
{allSelected ? 'Kijelölés törlése' : 'Összes kijelölése'}
</Button>
<Button onClick={handleExport}>
<LuDownload />
Exportálás ({exportLabel})
</Button>
</div>
</div>

{isLoading && <LoadingCard />}

{!isLoading && usersWithPictures.length === 0 && (
<p className='text-muted-foreground'>Nincs jóváhagyott profilkép.</p>
)}

<div className='grid max-lg:grid-cols-1 lg:grid-cols-2 gap-2'>
{usersWithPictures.map((user) => (
<Card
key={user.authSchId}
className='flex items-center gap-4 p-4 cursor-pointer select-none'
onClick={() => toggleUser(user.authSchId)}
>
<Checkbox checked={selectedIds.has(user.authSchId)} onCheckedChange={() => toggleUser(user.authSchId)} />
<img
src={`${process.env.NEXT_PUBLIC_API_URL}/users/${user.authSchId}/profile-picture`}
alt={user.fullName}
loading='lazy'
className='w-12 h-16 object-cover rounded'
/>
<div>
<p className='font-medium'>{user.fullName}</p>
<p className='text-sm text-muted-foreground'>{user.nickName}</p>
</div>
</Card>
))}
</div>

<Button variant='secondary' onClick={() => router.push('/admin')}>
Vissza
</Button>
</>
);
}
9 changes: 9 additions & 0 deletions apps/frontend/src/app/periods/[id]/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ interface DataTableProps<TData, TValue> {
onExportApplicationsClicked: (data: TData[]) => void;
onSetToManufactured: (data: TData[]) => void;
onExportToExcelClicked: (data: TData[]) => void;
onExportProfilePicturesClicked: (data: TData[]) => void;
}

export function DataTable<TData, TValue>({
Expand All @@ -62,6 +63,7 @@ export function DataTable<TData, TValue>({
onExportPassesClicked,
onSetToManufactured,
onExportToExcelClicked,
onExportProfilePicturesClicked,
}: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = React.useState<SortingState>([
{
Expand Down Expand Up @@ -216,6 +218,13 @@ export function DataTable<TData, TValue>({
>
Minden kiosztott jelentkezés exportálása Excel file-ba
</MenubarItem>
<Separator />
<MenubarItem
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
onClick={() => onExportProfilePicturesClicked(data.filter((_, i) => rowSelection[i]))}
>
Kijelöltek profilképeinek exportálása
</MenubarItem>
</MenubarContent>
</MenubarMenu>
<MenubarMenu>
Expand Down
5 changes: 5 additions & 0 deletions apps/frontend/src/app/periods/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { toast } from '@/lib/use-toast';
import { ApplicationEntity, ApplicationStatus } from '@/types/application-entity';

import { generateXlsx } from '@/lib/xlsx';
import { exportProfilePictures } from '@/lib/profile-pictures';
import { saveAs } from 'file-saver';
import { ApplicationExport } from './application-export';
import { PassExport } from './pass-export';
Expand Down Expand Up @@ -194,6 +195,9 @@ export default function Page(props: { params: Promise<{ id: number }> }) {
* This function exports the selected applications which have the status {@link ApplicationStatus.DISTRIBUTED}
* to an Excel file.
*/
const onExportProfilePictures = (data: ApplicationEntity[]) =>
exportProfilePictures(data.map((a) => a.user.authSchId));

const onExportToExcel = async (data: ApplicationEntity[]) => {
const distributedApplications = data.filter((a) => a.status === getStatusKey(ApplicationStatus.DISTRIBUTED));

Expand Down Expand Up @@ -278,6 +282,7 @@ export default function Page(props: { params: Promise<{ id: number }> }) {
onExportApplicationsClicked={onApplicationsExport}
onSetToManufactured={onSetToManufactured}
onExportToExcelClicked={onExportToExcel}
onExportProfilePicturesClicked={onExportProfilePictures}
/>
)}
</div>
Expand Down
16 changes: 14 additions & 2 deletions apps/frontend/src/app/roles/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,24 @@ import React from 'react';
import api from '@/components/network/apiSetup';
import Th1 from '@/components/typography/typography';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import LoadingCard from '@/components/ui/LoadingCard';
import NotFoundCard from '@/components/ui/NotFoundCard';
import OwnPagination from '@/components/ui/ownPagination';
import UserCard from '@/components/ui/UserCard';
import { useUsers } from '@/hooks/useUsers';
import { exportProfilePictures } from '@/lib/profile-pictures';
import { toast } from '@/lib/use-toast';
import { Role } from '@/types/user-entity';
import { LuDownload } from 'react-icons/lu';

export default function Page() {
const [search, setSearch] = React.useState('');
const [pageIndex, setPageIndex] = React.useState(0);
const users = useUsers(search, pageIndex);

async function onChange(newRole: Role, userId: string) {
async function onRoleChange(newRole: Role, userId: string) {
try {
await api.patch(`/users/${userId}`, { role: newRole });
await users.mutate();
Expand All @@ -32,11 +35,19 @@ export default function Page() {
}
}

async function mutateUsers() {
await users.mutate();
}

return (
<>
<div className='flex justify-between md:flex-row max-md:flex-col items-center'>
<Th1>Jogosultságok kezelése</Th1>
<div className='flex gap-2'>
<Button onClick={() => exportProfilePictures()}>
<LuDownload />
Minden profilkép exportálása
</Button>
<Input
placeholder='Keresés név alapján...'
value={search}
Expand Down Expand Up @@ -68,7 +79,8 @@ export default function Page() {
<UserCard
key={user.authSchId}
user={user}
onChange={(newRole: Role) => onChange(newRole, user.authSchId)}
onChange={(newRole: Role) => onRoleChange(newRole, user.authSchId)}
mutateUsers={mutateUsers}
/>
))}
</div>
Expand Down
Loading