Skip to content

Commit f2831af

Browse files
Merge pull request DistinctCodes#356 from AbdulmujibOladayo/main
implemented the reporting module
2 parents 328b098 + a3d1e3e commit f2831af

6 files changed

Lines changed: 295 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
2+
import { Asset } from './asset.entity';
3+
import { InventoryItem } from './inventory-item.entity';
4+
5+
@Entity()
6+
export class Department {
7+
@PrimaryGeneratedColumn('uuid')
8+
id: string;
9+
10+
@Column({ unique: true })
11+
name: string;
12+
13+
@Column({ nullable: true })
14+
description?: string;
15+
16+
@OneToMany(() => Asset, (asset) => asset.department)
17+
assets: Asset[];
18+
19+
@OneToMany(() => InventoryItem, (it) => it.department)
20+
inventoryItems: InventoryItem[];
21+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Controller, Get, Query, Res, UsePipes, ValidationPipe, BadRequestException } from '@nestjs/common';
2+
import { Response } from 'express';
3+
import { ReportsService } from './reports.service';
4+
import { ReportQueryDto } from './dto/report-query.dto';
5+
import { jsonToCsvStream } from './utils/csv.helper';
6+
import { recordsToPdfStream } from './utils/pdf.helper';
7+
8+
@Controller('reports')
9+
export class ReportsController {
10+
constructor(private readonly reportsService: ReportsService) {}
11+
12+
// GET /reports/assets?startDate=...&endDate=...&categoryId=...&departmentId=...&format=csv|pdf
13+
@Get('assets')
14+
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
15+
async assets(@Query() q: ReportQueryDto, @Res() res: Response) {
16+
const { format = 'csv', startDate, endDate, categoryId, departmentId } = q;
17+
const data = await this.reportsService.getAssetsReport({ startDate, endDate, categoryId, departmentId });
18+
const filename = `assets-report-${Date.now()}.${format}`;
19+
20+
if (format === 'csv') {
21+
const fields = ['id','name','serialNumber','model','category','department','createdAt','metadata'];
22+
const stream = jsonToCsvStream(data, fields);
23+
res.setHeader('Content-Type', 'text/csv');
24+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
25+
stream.pipe(res);
26+
} else if (format === 'pdf') {
27+
const columns = ['id','name','serialNumber','model','category','department','createdAt'];
28+
const stream = recordsToPdfStream('Assets Report', columns, data);
29+
res.setHeader('Content-Type', 'application/pdf');
30+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
31+
stream.pipe(res);
32+
} else {
33+
throw new BadRequestException('Unsupported format');
34+
}
35+
}
36+
37+
// GET /reports/inventory
38+
@Get('inventory')
39+
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
40+
async inventory(@Query() q: ReportQueryDto, @Res() res: Response) {
41+
const { format = 'csv', startDate, endDate, categoryId, departmentId } = q;
42+
const data = await this.reportsService.getInventoryReport({ startDate, endDate, categoryId, departmentId });
43+
const filename = `inventory-report-${Date.now()}.${format}`;
44+
45+
if (format === 'csv') {
46+
const fields = ['id','name','quantity','category','department','createdAt','metadata'];
47+
const stream = jsonToCsvStream(data, fields);
48+
res.setHeader('Content-Type', 'text/csv');
49+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
50+
stream.pipe(res);
51+
} else if (format === 'pdf') {
52+
const columns = ['id','name','quantity','category','department','createdAt'];
53+
const stream = recordsToPdfStream('Inventory Report', columns, data);
54+
res.setHeader('Content-Type', 'application/pdf');
55+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
56+
stream.pipe(res);
57+
} else {
58+
throw new BadRequestException('Unsupported format');
59+
}
60+
}
61+
62+
// GET /reports/usage
63+
@Get('usage')
64+
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
65+
async usage(@Query() q: ReportQueryDto, @Res() res: Response) {
66+
const { format = 'csv', startDate, endDate, categoryId, departmentId } = q;
67+
const data = await this.reportsService.getUsageReport({ startDate, endDate, categoryId, departmentId });
68+
const filename = `usage-report-${Date.now()}.${format}`;
69+
70+
if (format === 'csv') {
71+
const fields = ['id','action','assetId','assetName','inventoryItemId','inventoryItemName','department','performedBy','performedAt','meta'];
72+
const stream = jsonToCsvStream(data, fields);
73+
res.setHeader('Content-Type', 'text/csv');
74+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
75+
stream.pipe(res);
76+
} else if (format === 'pdf') {
77+
const columns = ['performedAt','action','assetName','inventoryItemName','department','performedBy'];
78+
const stream = recordsToPdfStream('Usage History Report', columns, data);
79+
res.setHeader('Content-Type', 'application/pdf');
80+
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
81+
stream.pipe(res);
82+
} else {
83+
throw new BadRequestException('Unsupported format');
84+
}
85+
}
86+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { IsOptional, IsString, IsIn, IsISO8601 } from 'class-validator';
2+
import { Type } from 'class-transformer';
3+
4+
export class ReportQueryDto {
5+
@IsOptional()
6+
@IsISO8601()
7+
startDate?: string; // inclusive
8+
9+
@IsOptional()
10+
@IsISO8601()
11+
endDate?: string; // inclusive
12+
13+
@IsOptional()
14+
@IsString()
15+
categoryId?: string;
16+
17+
@IsOptional()
18+
@IsString()
19+
departmentId?: string;
20+
21+
@IsOptional()
22+
@IsIn(['csv', 'pdf'])
23+
format?: 'csv' | 'pdf' = 'csv';
24+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
2+
import { Asset } from './asset.entity';
3+
import { InventoryItem } from './inventory-item.entity';
4+
5+
@Entity()
6+
export class Category {
7+
@PrimaryGeneratedColumn('uuid')
8+
id: string;
9+
10+
@Column({ unique: true })
11+
name: string;
12+
13+
@Column({ nullable: true })
14+
description?: string;
15+
16+
@OneToMany(() => Asset, (asset) => asset.category)
17+
assets: Asset[];
18+
19+
@OneToMany(() => InventoryItem, (it) => it.category)
20+
inventoryItems: InventoryItem[];
21+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Module } from '@nestjs/common';
2+
import { TypeOrmModule } from '@nestjs/typeorm';
3+
import { ReportsService } from './reports.service';
4+
import { ReportsController } from './reports.controller';
5+
import { Asset } from '../entities/asset.entity';
6+
import { InventoryItem } from '../entities/inventory-item.entity';
7+
import { UsageHistory } from '../entities/usage-history.entity';
8+
import { Category } from '../entities/category.entity';
9+
import { Department } from '../entities/department.entity';
10+
11+
@Module({
12+
imports: [
13+
TypeOrmModule.forFeature([Asset, InventoryItem, UsageHistory, Category, Department]),
14+
],
15+
providers: [ReportsService],
16+
controllers: [ReportsController],
17+
})
18+
export class ReportsModule {}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { InjectRepository } from '@nestjs/typeorm';
3+
import { Repository } from 'typeorm';
4+
import { Asset } from '../entities/asset.entity';
5+
import { InventoryItem } from '../entities/inventory-item.entity';
6+
import { UsageHistory } from '../entities/usage-history.entity';
7+
import { Category } from '../entities/category.entity';
8+
import { Department } from '../entities/department.entity';
9+
10+
@Injectable()
11+
export class ReportsService {
12+
constructor(
13+
@InjectRepository(Asset) private readonly assetRepo: Repository<Asset>,
14+
@InjectRepository(InventoryItem) private readonly inventoryRepo: Repository<InventoryItem>,
15+
@InjectRepository(UsageHistory) private readonly usageRepo: Repository<UsageHistory>,
16+
@InjectRepository(Category) private readonly categoryRepo: Repository<Category>,
17+
@InjectRepository(Department) private readonly departmentRepo: Repository<Department>,
18+
) {}
19+
20+
/**
21+
* Returns asset records filtered and joined with category & department names.
22+
*/
23+
async getAssetsReport(filters: {
24+
startDate?: string;
25+
endDate?: string;
26+
categoryId?: string;
27+
departmentId?: string;
28+
}) {
29+
const qb = this.assetRepo.createQueryBuilder('asset')
30+
.leftJoinAndSelect('asset.category', 'category')
31+
.leftJoinAndSelect('asset.department', 'department');
32+
33+
if (filters.categoryId) qb.andWhere('category.id = :categoryId', { categoryId: filters.categoryId });
34+
if (filters.departmentId) qb.andWhere('department.id = :departmentId', { departmentId: filters.departmentId });
35+
if (filters.startDate) qb.andWhere('asset.createdAt >= :startDate', { startDate: filters.startDate });
36+
if (filters.endDate) qb.andWhere('asset.createdAt <= :endDate', { endDate: filters.endDate });
37+
38+
const assets = await qb.orderBy('asset.createdAt', 'DESC').getMany();
39+
40+
// Map to flat objects for export
41+
return assets.map(a => ({
42+
id: a.id,
43+
name: a.name,
44+
serialNumber: a.serialNumber ?? '',
45+
model: a.model ?? '',
46+
category: a.category?.name ?? '',
47+
department: a.department?.name ?? '',
48+
createdAt: a.createdAt?.toISOString(),
49+
metadata: JSON.stringify(a.metadata ?? {}),
50+
}));
51+
}
52+
53+
/**
54+
* Returns inventory items with quantities and category/department names
55+
*/
56+
async getInventoryReport(filters: {
57+
startDate?: string;
58+
endDate?: string;
59+
categoryId?: string;
60+
departmentId?: string;
61+
}) {
62+
const qb = this.inventoryRepo.createQueryBuilder('inv')
63+
.leftJoinAndSelect('inv.category', 'category')
64+
.leftJoinAndSelect('inv.department', 'department');
65+
66+
if (filters.categoryId) qb.andWhere('category.id = :categoryId', { categoryId: filters.categoryId });
67+
if (filters.departmentId) qb.andWhere('department.id = :departmentId', { departmentId: filters.departmentId });
68+
if (filters.startDate) qb.andWhere('inv.createdAt >= :startDate', { startDate: filters.startDate });
69+
if (filters.endDate) qb.andWhere('inv.createdAt <= :endDate', { endDate: filters.endDate });
70+
71+
const items = await qb.orderBy('inv.createdAt', 'DESC').getMany();
72+
73+
return items.map(i => ({
74+
id: i.id,
75+
name: i.name,
76+
quantity: i.quantity,
77+
category: i.category?.name ?? '',
78+
department: i.department?.name ?? '',
79+
createdAt: i.createdAt?.toISOString(),
80+
metadata: JSON.stringify(i.metadata ?? {}),
81+
}));
82+
}
83+
84+
/**
85+
* Usage history report; can include asset & inventory references.
86+
*/
87+
async getUsageReport(filters: {
88+
startDate?: string;
89+
endDate?: string;
90+
categoryId?: string; // optional: filter by category of related asset/inventory
91+
departmentId?: string;
92+
}) {
93+
// Basic query that fetches usage history with related asset/inventory and department
94+
const qb = this.usageRepo.createQueryBuilder('u')
95+
.leftJoinAndSelect('u.asset', 'asset')
96+
.leftJoinAndSelect('u.inventoryItem', 'inventoryItem')
97+
.leftJoinAndSelect('u.department', 'department');
98+
99+
if (filters.departmentId) qb.andWhere('department.id = :departmentId', { departmentId: filters.departmentId });
100+
if (filters.startDate) qb.andWhere('u.performedAt >= :startDate', { startDate: filters.startDate });
101+
if (filters.endDate) qb.andWhere('u.performedAt <= :endDate', { endDate: filters.endDate });
102+
103+
// If categoryId is provided, filter either asset.category or inventoryItem.category
104+
if (filters.categoryId) {
105+
qb.leftJoin('asset.category', 'assetCategory')
106+
.leftJoin('inventoryItem.category', 'invCategory')
107+
.andWhere('(assetCategory.id = :catId OR invCategory.id = :catId)', { catId: filters.categoryId });
108+
}
109+
110+
const histories = await qb.orderBy('u.performedAt', 'DESC').getMany();
111+
112+
return histories.map(h => ({
113+
id: h.id,
114+
action: h.action,
115+
assetId: h.asset?.id ?? '',
116+
assetName: h.asset?.name ?? '',
117+
inventoryItemId: h.inventoryItem?.id ?? '',
118+
inventoryItemName: h.inventoryItem?.name ?? '',
119+
department: h.department?.name ?? '',
120+
performedBy: h.performedBy ?? '',
121+
performedAt: h.performedAt?.toISOString(),
122+
meta: JSON.stringify(h.meta ?? {}),
123+
}));
124+
}
125+
}

0 commit comments

Comments
 (0)