From 76fdddec6ae0383e3d8dc1ad5df347b261e05dbd Mon Sep 17 00:00:00 2001 From: oreoluwa <126897231+prismn@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:37:53 +0100 Subject: [PATCH] feat: build assets module, transfers UI page, and Jest harness config Implement modules assigned to prismn. Closes #1125 Closes #1124 Closes #1123 Closes #1122 --- backend/src/app.module.ts | 22 +++++- backend/src/assets/assets.controller.ts | 47 ++++++++++++ backend/src/assets/assets.module.ts | 13 ++++ backend/src/assets/assets.service.ts | 69 +++++++++++++++++ backend/src/assets/entities/asset.entity.ts | 85 +++++++++++++++++++++ backend/src/prismn.spec.ts | 15 ++++ frontend/app/(dashboard)/transfers/page.tsx | 72 +++++++++++++++++ frontend/jest.config.js | 26 +++---- frontend/jest.setup.js | 1 + 9 files changed, 334 insertions(+), 16 deletions(-) create mode 100644 backend/src/assets/assets.controller.ts create mode 100644 backend/src/assets/assets.module.ts create mode 100644 backend/src/assets/assets.service.ts create mode 100644 backend/src/assets/entities/asset.entity.ts create mode 100644 backend/src/prismn.spec.ts create mode 100644 frontend/app/(dashboard)/transfers/page.tsx create mode 100644 frontend/jest.setup.js diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2dcad4bf0..55b121cf8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,10 +4,30 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; +import { CommonModule } from './common/common.module'; +import { UsersModule } from './users/users.module'; +import { HealthModule } from './health/health.module'; +import { AuthModule } from './auth/auth.module'; +import { BranchesModule } from './branches/branches.module'; +import { DepartmentsModule } from './departments/departments.module'; +import { LocationsModule } from './locations/locations.module'; +import { CategoriesModule } from './categories/categories.module'; +import { AssetsLifecycleModule } from './assets/assets-lifecycle.module'; +import { AssetsModule } from './assets/assets.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + CommonModule, + UsersModule, + HealthModule, + AuthModule, + BranchesModule, + DepartmentsModule, + LocationsModule, + CategoriesModule, + AssetsLifecycleModule, + AssetsModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -18,7 +38,7 @@ import { AppService } from './app.service'; password: configService.get('DB_PASSWORD', 'password'), database: configService.get('DB_DATABASE', 'manage_assets'), autoLoadEntities: true, - synchronize: configService.get('NODE_ENV') === 'development', + synchronize: false, }), inject: [ConfigService], }), diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts new file mode 100644 index 000000000..63a70c868 --- /dev/null +++ b/backend/src/assets/assets.controller.ts @@ -0,0 +1,47 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { AssetsService } from './assets.service'; + +@ApiTags('assets') +@Controller('assets') +export class AssetsController { + constructor(private readonly assetsService: AssetsService) {} + + @Get() + @ApiOperation({ summary: 'List all assets (paginated, filterable)' }) + findAll( + @Query('search') search?: string, + @Query('categoryId') categoryId?: string, + @Query('departmentId') departmentId?: string, + @Query('locationId') locationId?: string, + @Query('status') status?: string, + @Query('page') page?: number, + @Query('limit') limit?: number, + ) { + return this.assetsService.findAll({ search, categoryId, departmentId, locationId, status, page, limit }); + } + + @Post() + @ApiOperation({ summary: 'Create an asset' }) + create(@Body() dto: any) { + return this.assetsService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get asset details' }) + findOne(@Param('id') id: string) { + return this.assetsService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an asset' }) + update(@Param('id') id: string, @Body() dto: any) { + return this.assetsService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Soft delete an asset' }) + delete(@Param('id') id: string) { + return this.assetsService.delete(id); + } +} diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts new file mode 100644 index 000000000..4f821ea14 --- /dev/null +++ b/backend/src/assets/assets.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from './entities/asset.entity'; +import { AssetsService } from './assets.service'; +import { AssetsController } from './assets.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset])], + providers: [AssetsService], + controllers: [AssetsController], + exports: [AssetsService], +}) +export class AssetsModule {} diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts new file mode 100644 index 000000000..07ea23e47 --- /dev/null +++ b/backend/src/assets/assets.service.ts @@ -0,0 +1,69 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from './entities/asset.entity'; + +@Injectable() +export class AssetsService { + constructor( + @InjectRepository(Asset) + private readonly assetRepo: Repository, + ) {} + + async findAll(query?: { + search?: string; + categoryId?: string; + departmentId?: string; + locationId?: string; + status?: string; + page?: number; + limit?: number; + }) { + const page = query?.page || 1; + const limit = query?.limit || 20; + const qb = this.assetRepo.createQueryBuilder('asset') + .skip((page - 1) * limit) + .take(limit); + + if (query?.search) { + qb.andWhere('(asset.name ILIKE :s OR asset.assetTag ILIKE :s OR asset.serialNumber ILIKE :s)', { s: `%${query.search}%` }); + } + if (query?.categoryId) qb.andWhere('asset.categoryId = :c', { c: query.categoryId }); + if (query?.departmentId) qb.andWhere('asset.departmentId = :d', { d: query.departmentId }); + if (query?.locationId) qb.andWhere('asset.locationId = :l', { l: query.locationId }); + if (query?.status) qb.andWhere('asset.status = :st', { st: query.status }); + + const [items, total] = await qb.getManyAndCount(); + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + async findById(id: string) { + const asset = await this.assetRepo.findOne({ where: { id } }); + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + return asset; + } + + async create(dto: Partial) { + const count = await this.assetRepo.count(); + const assetTag = dto.assetTag || `AST-${String(count + 1).padStart(5, '0')}`; + const asset = this.assetRepo.create({ ...dto, assetTag }); + return this.assetRepo.save(asset); + } + + async update(id: string, dto: Partial) { + const asset = await this.findById(id); + Object.assign(asset, dto); + return this.assetRepo.save(asset); + } + + async delete(id: string) { + const asset = await this.findById(id); + return this.assetRepo.softRemove(asset); + } +} diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts new file mode 100644 index 000000000..b8d2d7aab --- /dev/null +++ b/backend/src/assets/entities/asset.entity.ts @@ -0,0 +1,85 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + Index, +} from 'typeorm'; + +@Entity('assets') +export class Asset { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index({ unique: true }) + @Column() + assetTag: string; + + @Column() + name: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ nullable: true }) + categoryId?: string; + + @Column({ nullable: true }) + departmentId?: string; + + @Column({ nullable: true }) + locationId?: string; + + @Column({ nullable: true }) + assignedToUserId?: string; + + @Column({ nullable: true }) + branchId?: string; + + @Column({ default: 'AVAILABLE' }) + status: string; + + @Column({ default: 'GOOD' }) + condition: string; + + @Column({ nullable: true }) + serialNumber?: string; + + @Column({ nullable: true }) + model?: string; + + @Column({ nullable: true }) + manufacturer?: string; + + @Column({ nullable: true }) + purchaseDate?: Date; + + @Column({ type: 'integer', default: 0 }) + purchaseCost: number; + + @Column({ default: 'USD' }) + currency: string; + + @Column({ nullable: true }) + warrantyExpiry?: Date; + + @Column({ nullable: true }) + supplierId?: string; + + @Column({ nullable: true }) + imageUrl?: string; + + @Column({ default: false }) + isDigital: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} diff --git a/backend/src/prismn.spec.ts b/backend/src/prismn.spec.ts new file mode 100644 index 000000000..b4c5c9d8e --- /dev/null +++ b/backend/src/prismn.spec.ts @@ -0,0 +1,15 @@ +import { AssetsService } from './assets/assets.service'; + +describe('prismn Modules (BE-86)', () => { + it('AssetsService creates asset with auto-generated assetTag', async () => { + const mockRepo = { + count: jest.fn().mockResolvedValue(5), + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'ast-1', ...dto })), + }; + const service = new AssetsService(mockRepo as any); + const asset = await service.create({ name: 'MacBook Pro', purchaseCost: 200000 }); + expect(asset.assetTag).toBe('AST-00006'); + expect(asset.purchaseCost).toBe(200000); + }); +}); diff --git a/frontend/app/(dashboard)/transfers/page.tsx b/frontend/app/(dashboard)/transfers/page.tsx new file mode 100644 index 000000000..ea4320cc8 --- /dev/null +++ b/frontend/app/(dashboard)/transfers/page.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { useState } from 'react'; + +export default function TransfersPage() { + const [filter, setFilter] = useState('ALL'); + + const mockTransfers = [ + { id: 'tr-1', assetName: 'MacBook Pro M2', from: 'Engineering', to: 'Design', requester: 'Alex Johnson', status: 'PENDING', date: '2026-07-28' }, + { id: 'tr-2', assetName: 'Dell UltraSharp Monitor', from: 'Marketing', to: 'Sales', requester: 'Sam Lee', status: 'APPROVED', date: '2026-07-27' }, + ]; + + const filteredTransfers = filter === 'ALL' ? mockTransfers : mockTransfers.filter(t => t.status === filter); + + return ( +
+

Asset Transfers Inbox

+
+ {['ALL', 'PENDING', 'APPROVED', 'REJECTED'].map((status) => ( + + ))} +
+ + + + + + + + + + + + + + + {filteredTransfers.map((item) => ( + + + + + + + + + + ))} + +
IDAssetFrom → ToRequesterDateStatusActions
{item.id}{item.assetName}{item.from} → {item.to}{item.requester}{item.date}{item.status} + {item.status === 'PENDING' && ( +
+ + +
+ )} +
+
+ ); +} diff --git a/frontend/jest.config.js b/frontend/jest.config.js index 80dd39e33..5342bfd68 100644 --- a/frontend/jest.config.js +++ b/frontend/jest.config.js @@ -1,19 +1,15 @@ -/** @type {import('jest').Config} */ -const config = { - testEnvironment: "jsdom", - transform: { - "^.+\\.tsx?$": [ - "ts-jest", - { - tsconfig: { - jsx: "react-jsx", - }, - }, - ], - }, +const nextJest = require('next/jest'); + +const createJestConfig = nextJest({ + dir: './', +}); + +const customJestConfig = { + setupFilesAfterEnv: ['/jest.setup.js'], + testEnvironment: 'jest-environment-jsdom', moduleNameMapper: { - "^@/(.*)$": "/$1", + '^@/(.*)$': '/$1', }, }; -module.exports = config; +module.exports = createJestConfig(customJestConfig); diff --git a/frontend/jest.setup.js b/frontend/jest.setup.js new file mode 100644 index 000000000..a9874b1b9 --- /dev/null +++ b/frontend/jest.setup.js @@ -0,0 +1 @@ +// jest.setup.js