diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 017aa2c..0f5d7ef 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,13 +4,13 @@ - + - - - - - + + + + + @@ -18,7 +18,7 @@ - + @@ -28,6 +28,9 @@ + + + { "associatedIndex": 0, "fromUser": false @@ -129,7 +132,7 @@ 1774643312599 - + @@ -179,7 +182,15 @@ 1774708414077 - + + + 1774709613724 + + + + 1774709613724 + + @@ -192,6 +203,7 @@ - + + \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index c805cd9..30c2755 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -39,7 +39,8 @@ "prisma": "^6.4.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 343b905..7f8a80c 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { SpacesModule } from './spaces/spaces.module'; import { AuthModule } from './auth/auth.module'; import { ReservationsModule } from './reservations/reservations.module'; import { EventsModule } from './events/events.module'; +import { ReportsModule } from './reports/reports.module'; import { LoggerMiddleware } from './common/middleware/logger.middleware'; @Module({ @@ -17,6 +18,7 @@ import { LoggerMiddleware } from './common/middleware/logger.middleware'; AuthModule, ReservationsModule, EventsModule, + ReportsModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts new file mode 100644 index 0000000..9407d03 --- /dev/null +++ b/apps/api/src/reports/reports.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, UseGuards, Req, Res, Header } from '@nestjs/common'; +import { ReportsService } from './reports.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { Role } from '@prisma/client'; +import * as express from 'express'; + +@Controller('reports') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} + + @Get('utilization') + getUtilization(@Req() req) { + return this.reportsService.getUtilizationStats(req.user.organizationId); + } + + @Get('employees') + getEmployees(@Req() req) { + return this.reportsService.getEmployeeStats(req.user.organizationId); + } + + @Get('export') + @Header('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + @Header('Content-Disposition', 'attachment; filename="reservations-report.xlsx"') + async export(@Req() req, @Res() res: express.Response) { + const buffer = await this.reportsService.generateXlsxReport(req.user.organizationId); + res.send(buffer); + } +} diff --git a/apps/api/src/reports/reports.module.ts b/apps/api/src/reports/reports.module.ts new file mode 100644 index 0000000..0a846d8 --- /dev/null +++ b/apps/api/src/reports/reports.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ReportsService } from './reports.service'; +import { ReportsController } from './reports.controller'; +import { PrismaModule } from '../prisma/prisma.module'; + +@Module({ + imports: [PrismaModule], + providers: [ReportsService], + controllers: [ReportsController], +}) +export class ReportsModule {} diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts new file mode 100644 index 0000000..f096468 --- /dev/null +++ b/apps/api/src/reports/reports.service.ts @@ -0,0 +1,153 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import * as XLSX from 'xlsx'; +import { SpaceType } from '@prisma/client'; + +@Injectable() +export class ReportsService { + constructor(private prisma: PrismaService) {} + + async getUtilizationStats(organizationId: string) { + const now = new Date(); + + // Get all spaces in the organization + const spaces = await this.prisma.space.findMany({ + where: { + floor: { + building: { + organizationId + } + } + }, + include: { + floor: { + include: { + building: true + } + }, + reservations: { + where: { + startTime: { lte: now }, + endTime: { gte: now } + } + } + } + }); + + const totalSpaces = spaces.length; + const occupiedSpaces = spaces.filter(s => s.reservations.length > 0).length; + + // Stats by building + const buildings = await this.prisma.building.findMany({ + where: { organizationId }, + include: { + floors: { + include: { + _count: { + select: { spaces: true } + } + } + } + } + }); + + const buildingStats = await Promise.all(buildings.map(async (b) => { + const buildingSpaces = spaces.filter(s => s.floor.buildingId === b.id); + const buildingOccupied = buildingSpaces.filter(s => s.reservations.length > 0).length; + + return { + id: b.id, + name: b.name, + totalSpaces: buildingSpaces.length, + occupiedSpaces: buildingOccupied, + utilization: buildingSpaces.length > 0 ? (buildingOccupied / buildingSpaces.length) * 100 : 0 + }; + })); + + // Stats by space type + const typeStats = Object.values(SpaceType).map(type => { + const typeSpaces = spaces.filter(s => s.type === type); + const typeOccupied = typeSpaces.filter(s => s.reservations.length > 0).length; + return { + type, + total: typeSpaces.length, + occupied: typeOccupied, + utilization: typeSpaces.length > 0 ? (typeOccupied / typeSpaces.length) * 100 : 0 + }; + }); + + return { + summary: { + totalSpaces, + occupiedSpaces, + overallUtilization: totalSpaces > 0 ? (occupiedSpaces / totalSpaces) * 100 : 0 + }, + buildingStats, + typeStats + }; + } + + async getEmployeeStats(organizationId: string) { + const users = await this.prisma.user.findMany({ + where: { organizationId }, + include: { + _count: { + select: { reservations: true } + } + } + }); + + const roleDistribution = users.reduce((acc, user) => { + acc[user.role] = (acc[user.role] || 0) + 1; + return acc; + }, {} as Record); + + return { + totalEmployees: users.length, + roleDistribution, + topBookers: users + .sort((a, b) => b._count.reservations - a._count.reservations) + .slice(0, 5) + .map(u => ({ email: u.email, reservationsCount: u._count.reservations })) + }; + } + + async generateXlsxReport(organizationId: string) { + const reservations = await this.prisma.reservation.findMany({ + where: { + user: { organizationId } + }, + include: { + user: { select: { email: true, role: true } }, + space: { + include: { + floor: { + include: { building: true } + } + } + } + }, + orderBy: { startTime: 'desc' } + }); + + const data = reservations.map(r => ({ + 'Reservation ID': r.id, + 'User Email': r.user.email, + 'User Role': r.user.role, + 'Building': r.space.floor.building.name, + 'Floor': r.space.floor.number, + 'Space Name': r.space.name, + 'Space Type': r.space.type, + 'Start Time': r.startTime, + 'End Time': r.endTime, + 'Created At': r.createdAt + })); + + const wb = XLSX.utils.book_new(); + const ws = XLSX.utils.json_to_sheet(data); + XLSX.utils.book_append_sheet(wb, ws, 'Reservations'); + + const buf = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); + return buf; + } +} diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index e8387af..19747f6 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -13,6 +13,9 @@ {#if $token} + {#if $user?.role === 'ADMIN'} + Reports + {/if} Dashboard {$user?.email?.charAt(0).toUpperCase()} diff --git a/apps/web/src/routes/admin/reports/+page.svelte b/apps/web/src/routes/admin/reports/+page.svelte new file mode 100644 index 0000000..679437f --- /dev/null +++ b/apps/web/src/routes/admin/reports/+page.svelte @@ -0,0 +1,186 @@ + + + + + Admin Reporting Dashboard + + + + + Download XLSX Data + + + + {#if loading} + + + + {:else if error} + + + + {error} + + + + {:else} + + + + Overall Utilization + {utilizationStats.summary.overallUtilization.toFixed(1)}% + + + + + + + Total Spaces + {utilizationStats.summary.totalSpaces} + {utilizationStats.summary.occupiedSpaces} currently occupied + + + + Total Employees + {employeeStats.totalEmployees} + Across all roles + + + + + + + + Utilization by Building + + + + {#each utilizationStats.buildingStats as building} + + + {building.name} + {building.occupiedSpaces} / {building.totalSpaces} ({building.utilization.toFixed(1)}%) + + + + + + {/each} + + + + + + + + Utilization by Space Type + + + + {#each utilizationStats.typeStats as typeStat} + + + {typeStat.type.replace('_', ' ')} + {typeStat.occupied} / {typeStat.total} ({typeStat.utilization.toFixed(1)}%) + + + + + + {/each} + + + + + + + + Employee Role Distribution + + + + {#each Object.entries(employeeStats.roleDistribution) as [role, count]} + + {role} + {count} + + {/each} + + + + + + + + Top Bookers (Most Reservations) + + + {#each employeeStats.topBookers as booker} + + {booker.email} + + {booker.reservationsCount} bookings + + + {/each} + {#if employeeStats.topBookers.length === 0} + No bookings found yet. + {/if} + + + + {/if} + diff --git a/package-lock.json b/package-lock.json index 903a1d9..c8601c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,8 @@ "prisma": "^6.4.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "socket.io": "^4.8.3" + "socket.io": "^4.8.3", + "xlsx": "^0.18.5" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -4385,6 +4386,14 @@ "node": ">=0.4.0" } }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5121,6 +5130,18 @@ } ] }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5357,6 +5378,14 @@ "periscopic": "^3.1.0" } }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/collect-v8-coverage": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", @@ -5544,6 +5573,17 @@ } } }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -6814,6 +6854,14 @@ "node": ">= 0.6" } }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -10475,6 +10523,17 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "dev": true }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -11956,6 +12015,22 @@ "node": ">= 8" } }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "engines": { + "node": ">=0.8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -12042,6 +12117,26 @@ } } }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/xmlhttprequest-ssl": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
{error}
{utilizationStats.summary.overallUtilization.toFixed(1)}%
{utilizationStats.summary.totalSpaces}
{utilizationStats.summary.occupiedSpaces} currently occupied
{employeeStats.totalEmployees}
Across all roles
{role}
{count}