diff --git a/apps/backend/prisma/migrations/20260907120000_add_application_status_log/migration.sql b/apps/backend/prisma/migrations/20260907120000_add_application_status_log/migration.sql new file mode 100644 index 0000000..5df6d50 --- /dev/null +++ b/apps/backend/prisma/migrations/20260907120000_add_application_status_log/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "ApplicationStatusLog" ( + "id" SERIAL NOT NULL, + "applicationId" INTEGER NOT NULL, + "changedById" TEXT, + "previousStatus" "ApplicationStatus", + "newStatus" "ApplicationStatus" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ApplicationStatusLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ApplicationStatusLog_applicationId_idx" ON "ApplicationStatusLog"("applicationId"); + +-- CreateIndex +CREATE INDEX "ApplicationStatusLog_changedById_idx" ON "ApplicationStatusLog"("changedById"); + +-- AddForeignKey +ALTER TABLE "ApplicationStatusLog" ADD CONSTRAINT "ApplicationStatusLog_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "Application"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ApplicationStatusLog" ADD CONSTRAINT "ApplicationStatusLog_changedById_fkey" FOREIGN KEY ("changedById") REFERENCES "User"("authSchId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 3c58fd7..0927533 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -34,26 +34,27 @@ enum ApplicationStatus { } model User { - authSchId String @id - fullName String - nickName String - role Role @default(USER) - neptun String? @unique - email String? - isSchResident Boolean - isActiveVikStudent Boolean - roomNumber Int? - canHelpNoobs Boolean @default(false) - publicDesc String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - vikStatusUpdatedAt DateTime @default(now()) - profilePicture ProfilePicture? - applicationPeriods ApplicationPeriod[] - applications Application[] - posts Post[] - upvotes Upvote[] - idNumber String? + authSchId String @id + fullName String + nickName String + role Role @default(USER) + neptun String? @unique + email String? + isSchResident Boolean + isActiveVikStudent Boolean + roomNumber Int? + canHelpNoobs Boolean @default(false) + publicDesc String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + vikStatusUpdatedAt DateTime @default(now()) + profilePicture ProfilePicture? + applicationPeriods ApplicationPeriod[] + applications Application[] + posts Post[] + upvotes Upvote[] + idNumber String? + applicationStatusChanges ApplicationStatusLog[] } model ProfilePicture { @@ -100,18 +101,35 @@ model ApplicationPeriod { } model Application { - id Int @id @default(autoincrement()) - user User @relation(fields: [userId], references: [authSchId]) + id Int @id @default(autoincrement()) + user User @relation(fields: [userId], references: [authSchId]) userId String - applicationPeriod ApplicationPeriod @relation(fields: [applicationPeriodId], references: [id], onDelete: Cascade) + applicationPeriod ApplicationPeriod @relation(fields: [applicationPeriodId], references: [id], onDelete: Cascade) applicationPeriodId Int - status ApplicationStatus @default(SUBMITTED) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + status ApplicationStatus @default(SUBMITTED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + statusLogs ApplicationStatusLog[] @@unique([userId, applicationPeriodId]) } +/// Audit log: one row per status change of an Application. +/// Records who made the change, when, and the previous/new status. +model ApplicationStatusLog { + id Int @id @default(autoincrement()) + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + applicationId Int + changedBy User? @relation(fields: [changedById], references: [authSchId], onDelete: SetNull) + changedById String? + previousStatus ApplicationStatus? + newStatus ApplicationStatus + createdAt DateTime @default(now()) + + @@index([applicationId]) + @@index([changedById]) +} + model Post { id Int @id @default(autoincrement()) title String diff --git a/apps/backend/src/application/application.controller.ts b/apps/backend/src/application/application.controller.ts index b455828..2c4b520 100644 --- a/apps/backend/src/application/application.controller.ts +++ b/apps/backend/src/application/application.controller.ts @@ -51,6 +51,14 @@ export class ApplicationController { return this.applicationService.getLastUserApplication(user); } + @UseGuards(AuthGuard('jwt'), RolesGuard) + @ApiBearerAuth() + @Roles(Role.BODY_ADMIN, Role.BODY_MEMBER) + @Get('status-logs') + findAllStatusLogs() { + return this.applicationService.findAllStatusLogs(); + } + @UseGuards(AuthGuard('jwt'), RolesGuard) @ApiBearerAuth() @Roles(Role.BODY_ADMIN, Role.BODY_MEMBER) @@ -63,8 +71,16 @@ export class ApplicationController { @ApiBearerAuth() @Roles(Role.BODY_ADMIN, Role.BODY_MEMBER) @Patch('bulk') - bulkUpdate(@Body() bulkUpdateApplicationDto: BulkUpdateApplicationDto) { - return this.applicationService.bulkUpdate(bulkUpdateApplicationDto); + bulkUpdate(@Body() bulkUpdateApplicationDto: BulkUpdateApplicationDto, @CurrentUser() user: User) { + return this.applicationService.bulkUpdate(bulkUpdateApplicationDto, user); + } + + @UseGuards(AuthGuard('jwt'), RolesGuard) + @ApiBearerAuth() + @Roles(Role.BODY_ADMIN, Role.BODY_MEMBER) + @Get(':id/status-logs') + getStatusLogs(@Param('id', ParseIntPipe) id: number) { + return this.applicationService.getStatusLogs(id); } @UseGuards(AuthGuard('jwt'), RolesGuard) @@ -73,9 +89,10 @@ export class ApplicationController { @Patch(':id') update( @Param('id', ParseIntPipe) id: number, - @Body() updateApplicationDto: UpdateApplicationDto + @Body() updateApplicationDto: UpdateApplicationDto, + @CurrentUser() user: User ): Promise { - return this.applicationService.update(id, updateApplicationDto); + return this.applicationService.update(id, updateApplicationDto, user); } @UseGuards(AuthGuard('jwt'), RolesGuard) diff --git a/apps/backend/src/application/application.service.ts b/apps/backend/src/application/application.service.ts index ceb7cf1..6159617 100644 --- a/apps/backend/src/application/application.service.ts +++ b/apps/backend/src/application/application.service.ts @@ -16,6 +16,15 @@ import { CreateApplicationDto } from './dto/create-application.dto'; import { UpdateApplicationDto } from './dto/update-application.dto'; import { DefaultArgs } from '@prisma/client/runtime/library'; +/** + * A Prisma client scoped to an interactive transaction (the value passed to the + * `$transaction(async (tx) => ...)` callback). + */ +export type PrismaTransactionClient = Omit< + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +>; + @Injectable() export class ApplicationService { constructor( @@ -171,37 +180,137 @@ export class ApplicationService { } } - async update(id: number, updateApplicationDto: UpdateApplicationDto): Promise { - try { - return await this.prisma.application.update({ - where: { - id, - }, + /** + * Changes an application's status, refreshes its `updatedAt` timestamp and + * writes an audit-log entry (`ApplicationStatusLog`) recording who made the + * change and the previous/new status. A log entry is only written when the + * status actually changes. + * + * @param client - a Prisma client or an interactive transaction client + * @param application - the current application (must include `id` and `status`) + * @param newStatus - the status to set + * @param changedById - `authSchId` of the acting user, or `null` for system-initiated changes + */ + private async applyStatusChange( + client: PrismaService | PrismaTransactionClient, + application: Pick, + newStatus: ApplicationStatus, + changedById: string | null + ): Promise { + const updated = await client.application.update({ + where: { id: application.id }, + data: { + status: newStatus, + updatedAt: new Date(), + }, + }); + if (application.status !== newStatus) { + await client.applicationStatusLog.create({ data: { - status: updateApplicationDto.applicationStatus, + applicationId: application.id, + previousStatus: application.status, + newStatus, + changedById, }, }); + } + return updated; + } + + async update(id: number, updateApplicationDto: UpdateApplicationDto, user: User): Promise { + try { + return await this.prisma.$transaction(async (tx) => { + const application = await tx.application.findUniqueOrThrow({ where: { id } }); + return this.applyStatusChange(tx, application, updateApplicationDto.applicationStatus, user.authSchId); + }); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { if (e.code === 'P2025') { throw new NotFoundException('A keresett jelentkezés nem található'); } } + throw e; } } - async bulkUpdate(bulkUpdateApplicationDto: BulkUpdateApplicationDto): Promise { - const { ids, applicationStatus } = bulkUpdateApplicationDto; - return this.prisma.application.updateMany({ - where: { - id: { in: ids }, + /** + * Returns the whole status-change audit log (every application), newest first, + * including the affected application's applicant and who made each change. + * Used by the admin audit-log page, which does its own filtering/pagination. + */ + async findAllStatusLogs() { + return this.prisma.applicationStatusLog.findMany({ + orderBy: { createdAt: 'desc' }, + include: { + changedBy: { select: { authSchId: true, fullName: true, nickName: true } }, + application: { + select: { + id: true, + applicationPeriodId: true, + user: { select: { authSchId: true, fullName: true, nickName: true } }, + }, + }, }, - data: { - status: applicationStatus, + }); + } + + /** + * Returns the audit log of every status change of the given application, + * newest first, including who made each change. + */ + async getStatusLogs(id: number) { + try { + await this.prisma.application.findUniqueOrThrow({ where: { id } }); + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2025') { + throw new NotFoundException('A keresett jelentkezés nem található'); + } + throw e; + } + return this.prisma.applicationStatusLog.findMany({ + where: { applicationId: id }, + orderBy: { createdAt: 'desc' }, + include: { + changedBy: { select: { authSchId: true, fullName: true, nickName: true } }, }, }); } + /** + * Bulk status change: refreshes `updatedAt` and writes an audit-log entry for + * every application whose status actually changes. Runs in three statements + * (read current statuses, `updateMany`, `createMany` the log rows) regardless + * of how many applications are targeted. + */ + async bulkUpdate(bulkUpdateApplicationDto: BulkUpdateApplicationDto, user: User): Promise { + const { ids, applicationStatus } = bulkUpdateApplicationDto; + return this.prisma.$transaction(async (tx) => { + const applications = await tx.application.findMany({ + where: { id: { in: ids } }, + select: { id: true, status: true }, + }); + + const result = await tx.application.updateMany({ + where: { id: { in: ids } }, + data: { status: applicationStatus, updatedAt: new Date() }, + }); + + const logs = applications + .filter((application) => application.status !== applicationStatus) + .map((application) => ({ + applicationId: application.id, + previousStatus: application.status, + newStatus: applicationStatus, + changedById: user.authSchId, + })); + if (logs.length > 0) { + await tx.applicationStatusLog.createMany({ data: logs }); + } + + return result; + }); + } + async remove(id: number, user: User): Promise { const application = await this.prisma.application.findFirst({ where: { @@ -248,20 +357,13 @@ export class ApplicationService { async setActiveApplicationsStatus( userId: string, status: ApplicationStatus, - tx: Omit< - PrismaClient, - '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' - > + tx: PrismaTransactionClient, + changedById: string | null ) { try { const activeApplications = await this.getActiveApplications(userId); await Promise.all( - activeApplications.map((application) => - tx.application.update({ - where: { id: application.id }, - data: { status: status }, - }) - ) + activeApplications.map((application) => this.applyStatusChange(tx, application, status, changedById)) ); } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError) { diff --git a/apps/backend/src/user/user.controller.ts b/apps/backend/src/user/user.controller.ts index bb34a54..916e85a 100644 --- a/apps/backend/src/user/user.controller.ts +++ b/apps/backend/src/user/user.controller.ts @@ -57,14 +57,14 @@ export class UserController { @UploadedFile(ImageParserPipe) file: Express.Multer.File ) { - await this.userService.saveProfilePicture(user.authSchId, file.buffer, file.mimetype); + await this.userService.saveProfilePicture(user.authSchId, file.buffer, file.mimetype, user.authSchId); } @Delete('me/profile-picture') @UseGuards(AuthGuard('jwt')) @ApiBearerAuth() async deleteProfilePicture(@CurrentUser() user: User) { - await this.userService.deleteProfilePicture(user.authSchId); + await this.userService.deleteProfilePicture(user.authSchId, user.authSchId); } @Patch('me') @@ -131,8 +131,12 @@ export class UserController { @UseGuards(AuthGuard('jwt'), RolesGuard) @ApiBearerAuth() @Roles(Role.BODY_ADMIN) - async setProfilePictureStatus(@Param('id') id: string, @Param('status') status: ProfilePictureStatus) { - return this.userService.setProfilePictureStatus(id, status); + async setProfilePictureStatus( + @Param('id') id: string, + @Param('status') status: ProfilePictureStatus, + @CurrentUser() user: User + ) { + return this.userService.setProfilePictureStatus(id, status, user.authSchId); } @Post(':id/profile-picture') @@ -143,8 +147,9 @@ export class UserController { async updateProfilePictureAdmin( @Param('id') id: string, @UploadedFile(ImageParserPipe) - file: Express.Multer.File + file: Express.Multer.File, + @CurrentUser() user: User ) { - await this.userService.saveProfilePicture(id, file.buffer, file.mimetype); + await this.userService.saveProfilePicture(id, file.buffer, file.mimetype, user.authSchId); } } diff --git a/apps/backend/src/user/user.service.ts b/apps/backend/src/user/user.service.ts index 96f8933..0aa96e7 100644 --- a/apps/backend/src/user/user.service.ts +++ b/apps/backend/src/user/user.service.ts @@ -59,11 +59,11 @@ export class UserService { return this.prisma.user.update({ where: { authSchId: id }, data: updateUserDto }); } - async setProfilePictureStatus(id: string, status: any) { + async setProfilePictureStatus(id: string, status: any, changedById: string | null) { try { const transactionResult = await this.prisma.$transaction(async (tx) => { if (status !== ProfilePictureStatus.PENDING) { - await this.applicationService.setActiveApplicationsStatus(id, status, tx); + await this.applicationService.setActiveApplicationsStatus(id, status, tx, changedById); } return tx.profilePicture.update({ where: { userId: id }, @@ -211,21 +211,21 @@ export class UserService { } } - async saveProfilePicture(authSchId: string, buffer: Buffer, mimetype: string) { + async saveProfilePicture(authSchId: string, buffer: Buffer, mimetype: string, changedById: string | null) { if (mimetype !== 'image/png' && mimetype !== 'image/jpeg') { throw new BadRequestException('Invalid image format'); } try { - return this.createOrUpdateProfilePicture(authSchId, buffer); + return this.createOrUpdateProfilePicture(authSchId, buffer, changedById); } catch (_error) { throw new NotFoundException(`User with id ${authSchId} not found`); } } - async deleteProfilePicture(authSchId: string) { + async deleteProfilePicture(authSchId: string, changedById: string | null) { try { const transactionResult = await this.prisma.$transaction(async (tx) => { - await this.applicationService.setActiveApplicationsStatus(authSchId, 'REJECTED', tx); + await this.applicationService.setActiveApplicationsStatus(authSchId, 'REJECTED', tx, changedById); return tx.profilePicture.delete({ where: { userId: authSchId }, }); @@ -285,14 +285,14 @@ export class UserService { } } - private async createOrUpdateProfilePicture(userId: string, profileImage: Buffer) { + private async createOrUpdateProfilePicture(userId: string, profileImage: Buffer, changedById: string | null) { const { image, mimeType } = await optimizeImage(profileImage, true); const imageBytes = new Uint8Array( image.buffer.slice(image.byteOffset, image.byteOffset + image.byteLength) ) as Uint8Array; try { await this.prisma.$transaction(async (tx) => { - await this.applicationService.setActiveApplicationsStatus(userId, 'SUBMITTED', tx); + await this.applicationService.setActiveApplicationsStatus(userId, 'SUBMITTED', tx, changedById); return tx.profilePicture.update({ where: { userId: userId }, data: { diff --git a/apps/frontend/src/app/admin/page.tsx b/apps/frontend/src/app/admin/page.tsx index a6e334e..620fd57 100644 --- a/apps/frontend/src/app/admin/page.tsx +++ b/apps/frontend/src/app/admin/page.tsx @@ -4,7 +4,7 @@ import React from 'react'; import Th1 from '@/components/typography/typography'; import { Button } from '@/components/ui/button'; -import { LuClipboardList, LuShield, LuUserCheck } from 'react-icons/lu'; +import { LuClipboardList, LuScrollText, LuShield, LuUserCheck } from 'react-icons/lu'; export default function Page() { const router = useRouter(); @@ -24,6 +24,10 @@ export default function Page() { Időszakok kezelése + ); diff --git a/apps/frontend/src/app/application-status-logs/columns.tsx b/apps/frontend/src/app/application-status-logs/columns.tsx new file mode 100644 index 0000000..8011c9c --- /dev/null +++ b/apps/frontend/src/app/application-status-logs/columns.tsx @@ -0,0 +1,83 @@ +'use client'; +import { Column, ColumnDef, FilterFn } from '@tanstack/react-table'; + +import StatusBadge from '@/components/ui/StatusBadge'; +import { DateSortableFilterableHeader } from '@/components/ui/table-headers/DateSortableFilterableHeader'; +import { SortableFilterableHeader } from '@/components/ui/table-headers/SortableFilterableHeader'; +import { filterByDateRange } from '@/lib/customFilters'; +import { ApplicationEntity, ApplicationStatus } from '@/types/application-entity'; +import { ApplicationStatusLogEntity } from '@/types/application-status-log-entity'; + +// The shared table headers are typed against ApplicationEntity, but only use the +// generic Column API – reuse them here with a cast. +const textHeader = (column: Column) => + SortableFilterableHeader(column as unknown as Column); +const dateHeader = (column: Column) => + DateSortableFilterableHeader(column as unknown as Column); + +function formatDate(value: string | Date) { + return new Date(value).toLocaleDateString('hu-HU', { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: 'numeric', + minute: 'numeric', + }); +} + +export const columns: ColumnDef[] = [ + { + id: 'Jelentkezés', + accessorFn: (row) => `#${row.application?.id ?? row.applicationId}`, + size: 90, + enableResizing: false, + header: ({ column }) => textHeader(column), + cell: ({ row }) => #{row.original.application?.id ?? row.original.applicationId}, + }, + { + id: 'Jelentkező', + accessorFn: (row) => row.application?.user.fullName ?? '—', + header: ({ column }) => textHeader(column), + cell: ({ row }) => {row.original.application?.user.fullName ?? '—'}, + }, + { + id: 'Módosította', + accessorFn: (row) => row.changedBy?.fullName ?? 'Rendszer', + header: ({ column }) => textHeader(column), + cell: ({ row }) => ( + + {row.original.changedBy?.fullName ?? 'Rendszer'} + + ), + }, + { + id: 'Régi állapot', + accessorFn: (row) => row.previousStatus ?? '', + size: 120, + enableResizing: false, + header: ({ column }) => textHeader(column), + cell: ({ row }) => + row.original.previousStatus ? ( + + ) : ( + + ), + }, + { + id: 'Új állapot', + accessorKey: 'newStatus', + size: 120, + enableResizing: false, + header: ({ column }) => textHeader(column), + cell: ({ row }) => , + }, + { + id: 'Időpont', + accessorKey: 'createdAt', + size: 160, + enableResizing: false, + filterFn: filterByDateRange as unknown as FilterFn, + header: ({ column }) => dateHeader(column), + cell: ({ row }) => {formatDate(row.original.createdAt)}, + }, +]; diff --git a/apps/frontend/src/app/application-status-logs/data-table.tsx b/apps/frontend/src/app/application-status-logs/data-table.tsx new file mode 100644 index 0000000..648abd8 --- /dev/null +++ b/apps/frontend/src/app/application-status-logs/data-table.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from '@tanstack/react-table'; +import React from 'react'; + +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Menubar, + MenubarCheckboxItem, + MenubarContent, + MenubarMenu, + MenubarTrigger, +} from '@/components/ui/menubar'; +import { + Pagination, + PaginationContent, + PaginationFirst, + PaginationItem, + PaginationLast, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; +} + +export function DataTable({ columns, data }: DataTableProps) { + const [sorting, setSorting] = React.useState([{ id: 'Időpont', desc: true }]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [columnVisibility, setColumnVisibility] = React.useState({}); + const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 30 }); + + const table = useReactTable({ + data, + columns, + state: { sorting, columnFilters, columnVisibility, pagination }, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: setPagination, + }); + + return ( +
+
+ + + Nézet + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => ( + column.toggleVisibility(Boolean(value))} + > + {column.id} + + ))} + + + +
+ + table.getColumn('Jelentkező')?.setFilterValue(event.target.value)} + className='max-w-sm' + /> +
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +

+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +

+
+ ))} +
+ ))} +
+ + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + )) + ) : ( + + + Nincs találat + + + )} + + + + +
+ + {table.getFilteredRowModel().rows.length} / {data.length} naplóbejegyzés + +
+
+
+ + + + +
+ table.firstPage()}> + + + table.previousPage()}> + + +
+ + + {pagination.pageIndex + 1} + + +
+ table.nextPage()}> + + + table.lastPage()}> + + +
+
+
+
+
+
+
+
+
+ ); +} diff --git a/apps/frontend/src/app/application-status-logs/page.tsx b/apps/frontend/src/app/application-status-logs/page.tsx new file mode 100644 index 0000000..72240a2 --- /dev/null +++ b/apps/frontend/src/app/application-status-logs/page.tsx @@ -0,0 +1,22 @@ +'use client'; +import React from 'react'; + +import { columns } from '@/app/application-status-logs/columns'; +import { DataTable } from '@/app/application-status-logs/data-table'; +import Th1 from '@/components/typography/typography'; +import LoadingCard from '@/components/ui/LoadingCard'; +import { useApplicationStatusLogs } from '@/hooks/useApplicationStatusLogs'; + +export default function Page() { + const { data, isLoading, error } = useApplicationStatusLogs(); + + if (error) return
Hiba történt: {error.message}
; + + return ( +
+ Jelentkezési státusznapló + {isLoading && !data && } + {data && } +
+ ); +} diff --git a/apps/frontend/src/hooks/useApplicationStatusLogs.ts b/apps/frontend/src/hooks/useApplicationStatusLogs.ts new file mode 100644 index 0000000..f32db45 --- /dev/null +++ b/apps/frontend/src/hooks/useApplicationStatusLogs.ts @@ -0,0 +1,10 @@ +import useSWR from 'swr'; + +import { axiosGetFetcher } from '@/lib/fetchers'; +import { ApplicationStatusLogEntity } from '@/types/application-status-log-entity'; + +export function useApplicationStatusLogs() { + return useSWR('/application/status-logs', axiosGetFetcher, { + refreshInterval: 180000, + }); +} diff --git a/apps/frontend/src/types/application-status-log-entity.ts b/apps/frontend/src/types/application-status-log-entity.ts new file mode 100644 index 0000000..f4e30bb --- /dev/null +++ b/apps/frontend/src/types/application-status-log-entity.ts @@ -0,0 +1,27 @@ +import { ApplicationStatus } from '@/types/application-entity'; + +export type StatusLogUserRef = { + authSchId: string; + fullName: string; + nickName: string; +}; + +/** + * One entry of the application status-change audit log. + * `previousStatus` / `newStatus` are the raw enum keys (e.g. `REJECTED`). + * `changedBy` is `null` for system-initiated changes. + */ +export type ApplicationStatusLogEntity = { + id: number; + applicationId: number; + changedById: string | null; + changedBy: StatusLogUserRef | null; + previousStatus: keyof typeof ApplicationStatus | null; + newStatus: keyof typeof ApplicationStatus; + createdAt: string; + application: { + id: number; + applicationPeriodId: number; + user: StatusLogUserRef; + } | null; +};