Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand All @@ -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],
}),
Expand Down
47 changes: 47 additions & 0 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions backend/src/assets/assets.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
69 changes: 69 additions & 0 deletions backend/src/assets/assets.service.ts
Original file line number Diff line number Diff line change
@@ -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<Asset>,
) {}

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<Asset>) {
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<Asset>) {
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);
}
}
85 changes: 85 additions & 0 deletions backend/src/assets/entities/asset.entity.ts
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 15 additions & 0 deletions backend/src/prismn.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
72 changes: 72 additions & 0 deletions frontend/app/(dashboard)/transfers/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ padding: '2rem' }}>
<h1>Asset Transfers Inbox</h1>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem' }}>
{['ALL', 'PENDING', 'APPROVED', 'REJECTED'].map((status) => (
<button
key={status}
onClick={() => setFilter(status)}
style={{
padding: '0.5rem 1rem',
borderRadius: '0.25rem',
border: '1px solid #ccc',
backgroundColor: filter === status ? '#0070f3' : '#fff',
color: filter === status ? '#fff' : '#000',
cursor: 'pointer',
}}
>
{status}
</button>
))}
</div>

<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ccc' }}>
<th style={{ padding: '0.5rem' }}>ID</th>
<th style={{ padding: '0.5rem' }}>Asset</th>
<th style={{ padding: '0.5rem' }}>From → To</th>
<th style={{ padding: '0.5rem' }}>Requester</th>
<th style={{ padding: '0.5rem' }}>Date</th>
<th style={{ padding: '0.5rem' }}>Status</th>
<th style={{ padding: '0.5rem' }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredTransfers.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '0.5rem' }}>{item.id}</td>
<td style={{ padding: '0.5rem' }}>{item.assetName}</td>
<td style={{ padding: '0.5rem' }}>{item.from} → {item.to}</td>
<td style={{ padding: '0.5rem' }}>{item.requester}</td>
<td style={{ padding: '0.5rem' }}>{item.date}</td>
<td style={{ padding: '0.5rem' }}><strong>{item.status}</strong></td>
<td style={{ padding: '0.5rem' }}>
{item.status === 'PENDING' && (
<div style={{ display: 'flex', gap: '0.25rem' }}>
<button style={{ backgroundColor: '#22c55e', color: '#fff', border: 'none', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', cursor: 'pointer' }}>Approve</button>
<button style={{ backgroundColor: '#ef4444', color: '#fff', border: 'none', padding: '0.25rem 0.5rem', borderRadius: '0.25rem', cursor: 'pointer' }}>Reject</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
26 changes: 11 additions & 15 deletions frontend/jest.config.js
Original file line number Diff line number Diff line change
@@ -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');

Check failure on line 1 in frontend/jest.config.js

View workflow job for this annotation

GitHub Actions / Frontend (Next.js)

A `require()` style import is forbidden

const createJestConfig = nextJest({
dir: './',
});

const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/$1",
'^@/(.*)$': '<rootDir>/$1',
},
};

module.exports = config;
module.exports = createJestConfig(customJestConfig);
1 change: 1 addition & 0 deletions frontend/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
// jest.setup.js
Loading