Add admin reporting dashboard with utilization statistics and downloadable reports

- Implement `+page.svelte` for admin reporting dashboard showing utilization and employee stats.
- Add API endpoints for utilization stats, employee stats, and Excel report export (NestJS).
- Create `ReportsService` and `ReportsModule` for backend logic.
- Introduce `xlsx` package for generating Excel reports.
- Update layout to include "Reports" link for admin users.
- Extend Prisma querying for detailed building, space type, and employee role data.
main
Pau Costa Ferrer 2026-03-28 16:06:05 +01:00
parent 89fdbf6617
commit 30d3ab0686
9 changed files with 507 additions and 12 deletions

View File

@ -4,13 +4,13 @@
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`).">
<list default="true" id="6c8b3c54-eabb-40e5-967f-c7b594c750bc" name="Changes" comment="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing.">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/prisma/schema.prisma" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/spaces/spaces.service.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/FloorplanViewer.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/lib/components/admin/FloorplanEditor.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/app.controller.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.controller.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/api/src/app.module.ts" beforeDir="false" afterPath="$PROJECT_DIR$/apps/api/src/app.module.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/apps/web/src/routes/+layout.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package-lock.json" beforeDir="false" afterPath="$PROJECT_DIR$/package-lock.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@ -18,7 +18,7 @@
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="EmbeddingIndexingInfo">
<option name="cachedIndexableFilesCount" value="83" />
<option name="cachedIndexableFilesCount" value="93" />
<option name="fileBasedEmbeddingIndicesEnabled" value="true" />
</component>
<component name="Git.Settings">
@ -28,6 +28,9 @@
<commands />
<urls />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="AISelfReview" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 0,
&quot;fromUser&quot;: false
@ -129,7 +132,7 @@
<updated>1774643312599</updated>
<workItem from="1774643313611" duration="74000" />
<workItem from="1774643422240" duration="5278000" />
<workItem from="1774706361502" duration="2888000" />
<workItem from="1774706361502" duration="3866000" />
</task>
<task id="LOCAL-00001" summary="Initialize Hot Desking app with web and API services &#10;&#10;- Set up SvelteKit for the web interface with Tailwind CSS. &#10;- Build the API using NestJS with Prisma ORM for database interaction. &#10;- Add environment variable management and docker-compose for PostgreSQL and PgAdmin instances. &#10;- Create shared data models for consistent typing across services. &#10;- Establish basic routing and layouts for web and API services.">
<option name="closed" value="true" />
@ -179,7 +182,15 @@
<option name="project" value="LOCAL" />
<updated>1774708414077</updated>
</task>
<option name="localTasksCounter" value="7" />
<task id="LOCAL-00007" summary="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing.">
<option name="closed" value="true" />
<created>1774709613724</created>
<option name="number" value="00007" />
<option name="presentableId" value="LOCAL-00007" />
<option name="project" value="LOCAL" />
<updated>1774709613724</updated>
</task>
<option name="localTasksCounter" value="8" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@ -192,6 +203,7 @@
<MESSAGE value="Add floorplan editor for admin layout management&#10;&#10;- Implement `FloorplanEditor.svelte` component for adding, editing, and positioning spaces.&#10;- Add backend support for floorplan image upload and batch updating space positions.&#10;- Update Prisma schema to include `planImageUrl` for floors.&#10;- Enable API proxy in Vite configuration for straightforward local development.&#10;- Refactor API calls to use a centralized `apiFetch` utility with token handling." />
<MESSAGE value="Add building-level dashboard and interactive floorplan viewer&#10;&#10;- Create `+page.svelte` for building-level dashboard with floor selection and layout preview.&#10;- Add `FloorplanViewer.svelte` component with WebSocket integration for real-time space status updates.&#10;- Extend `SpacesService` and API endpoints for building floors and floor details retrieval.&#10;- Update dashboard UI with cleaner building and floor presentation.&#10;- Improve loading states and error handling across dashboard and floor pages." />
<MESSAGE value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
<option name="LAST_COMMIT_MESSAGE" value="Add booking modal and enhance reservation handling&#10;&#10;- Create `BookingModal.svelte` for booking spaces with custom time slots or full-day durations.&#10;- Add API endpoint to fetch reservations by space ID and date.&#10;- Update `SpacesService` with enhanced validation rules and conflict checks for reservations.&#10;- Extend WebSocket updates to include reservation data for real-time synchronization.&#10;- Enable date-based filtering and status handling in UI components (`SpaceCard.svelte`, `FloorplanViewer.svelte`)." />
<MESSAGE value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
<option name="LAST_COMMIT_MESSAGE" value="Add support for space resizing and enhanced layout management&#10;&#10;- Update `SpacesService` to handle width and height properties for spaces.&#10;- Introduce `UpdateSpaceDto` for improved validation in batch update operations.&#10;- Extend `FloorplanEditor.svelte` and `FloorplanViewer.svelte` to support dynamic container scaling and space resizing.&#10;- Modify Prisma schema to include `width` and `height` fields for spaces.&#10;- Enhance floorplan rendering to maintain proportional layout during editing and viewing." />
</component>
</project>

View File

@ -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",

View File

@ -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],

View File

@ -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);
}
}

View File

@ -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 {}

View File

@ -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<string, number>);
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;
}
}

View File

@ -13,6 +13,9 @@
</div>
<div class="space-x-4 flex items-center">
{#if $token}
{#if $user?.role === 'ADMIN'}
<a href="/admin/reports" class="text-gray-600 hover:text-indigo-600 font-medium">Reports</a>
{/if}
<a href="/dashboard" class="text-gray-600 hover:text-indigo-600 font-medium">Dashboard</a>
<a href="/login" class="bg-indigo-50 text-indigo-700 px-3 py-1.5 rounded-md hover:bg-indigo-100 font-medium flex items-center space-x-2">
<div class="w-5 h-5 bg-indigo-200 rounded-full text-[10px] flex items-center justify-center font-bold">{$user?.email?.charAt(0).toUpperCase()}</div>

View File

@ -0,0 +1,186 @@
<script lang="ts">
import { onMount } from 'svelte';
import { token } from '$lib/stores/auth';
import { apiFetch } from '$lib/api/client';
let utilizationStats = null;
let employeeStats = null;
let loading = true;
let error = null;
async function fetchStats() {
try {
const [utilRes, empRes] = await Promise.all([
apiFetch<any>('/reports/utilization'),
apiFetch<any>('/reports/employees')
]);
utilizationStats = utilRes;
employeeStats = empRes;
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
async function downloadReport() {
try {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000';
const res = await fetch(`${API_BASE_URL}/reports/export`, {
headers: {
'Authorization': `Bearer ${$token}`
}
});
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reservations-report.xlsx';
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
} catch (e) {
alert('Error downloading report: ' + e.message);
}
}
onMount(fetchStats);
</script>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold text-gray-800">Admin Reporting Dashboard</h1>
<button
on:click={downloadReport}
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg font-medium transition-colors flex items-center space-x-2"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<span>Download XLSX Data</span>
</button>
</div>
{#if loading}
<div class="flex justify-center items-center py-20">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
{:else if error}
<div class="bg-red-50 border-l-4 border-red-400 p-4">
<div class="flex">
<div class="ml-3">
<p class="text-sm text-red-700">{error}</p>
</div>
</div>
</div>
{:else}
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Overall Utilization</h3>
<p class="mt-2 text-3xl font-bold text-indigo-600">{utilizationStats.summary.overallUtilization.toFixed(1)}%</p>
<div class="mt-4 w-full bg-gray-200 rounded-full h-2">
<div class="bg-indigo-600 h-2 rounded-full" style="width: {utilizationStats.summary.overallUtilization}%"></div>
</div>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Total Spaces</h3>
<p class="mt-2 text-3xl font-bold text-gray-900">{utilizationStats.summary.totalSpaces}</p>
<p class="text-sm text-gray-500 mt-1">{utilizationStats.summary.occupiedSpaces} currently occupied</p>
</div>
<div class="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider">Total Employees</h3>
<p class="mt-2 text-3xl font-bold text-gray-900">{employeeStats.totalEmployees}</p>
<p class="text-sm text-gray-500 mt-1">Across all roles</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Building Utilization -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">Utilization by Building</h3>
</div>
<div class="p-6">
<div class="space-y-4">
{#each utilizationStats.buildingStats as building}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-700">{building.name}</span>
<span class="text-gray-500">{building.occupiedSpaces} / {building.totalSpaces} ({building.utilization.toFixed(1)}%)</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="bg-indigo-500 h-2 rounded-full" style="width: {building.utilization}%"></div>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Space Type Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">Utilization by Space Type</h3>
</div>
<div class="p-6">
<div class="space-y-4">
{#each utilizationStats.typeStats as typeStat}
<div>
<div class="flex justify-between text-sm mb-1">
<span class="font-medium text-gray-700">{typeStat.type.replace('_', ' ')}</span>
<span class="text-gray-500">{typeStat.occupied} / {typeStat.total} ({typeStat.utilization.toFixed(1)}%)</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-2">
<div class="bg-emerald-500 h-2 rounded-full" style="width: {typeStat.utilization}%"></div>
</div>
</div>
{/each}
</div>
</div>
</div>
<!-- Role Distribution -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">Employee Role Distribution</h3>
</div>
<div class="p-6">
<div class="grid grid-cols-2 gap-4">
{#each Object.entries(employeeStats.roleDistribution) as [role, count]}
<div class="bg-gray-50 p-4 rounded-lg">
<p class="text-sm text-gray-500 uppercase tracking-wide">{role}</p>
<p class="text-2xl font-bold text-gray-900">{count}</p>
</div>
{/each}
</div>
</div>
</div>
<!-- Top Bookers -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100">
<h3 class="font-bold text-gray-800">Top Bookers (Most Reservations)</h3>
</div>
<div class="divide-y divide-gray-100">
{#each employeeStats.topBookers as booker}
<div class="px-6 py-3 flex justify-between items-center hover:bg-gray-50 transition-colors">
<span class="text-sm font-medium text-gray-700">{booker.email}</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-indigo-100 text-indigo-800">
{booker.reservationsCount} bookings
</span>
</div>
{/each}
{#if employeeStats.topBookers.length === 0}
<div class="px-6 py-8 text-center text-gray-500 italic">No bookings found yet.</div>
{/if}
</div>
</div>
</div>
{/if}
</div>

97
package-lock.json generated
View File

@ -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",