diff --git a/backend/src/abdulrcrtw.spec.ts b/backend/src/abdulrcrtw.spec.ts new file mode 100644 index 000000000..dd2d10e15 --- /dev/null +++ b/backend/src/abdulrcrtw.spec.ts @@ -0,0 +1,39 @@ +import { NotesDocsController } from './assets/notes-docs.controller'; +import { MaintenanceService } from './maintenance/maintenance.service'; +import { TransfersService } from './transfers/transfers.service'; + +describe('abdulrcrtw Modules (BE-92, BE-91, BE-90, BE-89)', () => { + it('NotesDocsController adds and lists asset notes and documents', () => { + const controller = new NotesDocsController(); + const note = controller.addNote('ast-1', 'Need battery replacement', { user: { id: 'u-1' } } as any); + expect(note.content).toBe('Need battery replacement'); + expect(controller.getNotes('ast-1').length).toBe(1); + + const doc = controller.addDocument('ast-1', { title: 'Warranty.pdf', fileUrl: 'http://example.com/w.pdf' }, { user: { id: 'u-1' } } as any); + expect(doc.title).toBe('Warranty.pdf'); + expect(controller.getDocuments('ast-1').length).toBe(1); + }); + + it('MaintenanceService creates maintenance records', async () => { + const mockRepo = { + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((dto) => Promise.resolve({ id: 'm-1', ...dto })), + }; + const service = new MaintenanceService(mockRepo as any); + const rec = await service.create({ assetId: 'ast-1', title: 'Screen repair', cost: 15000 }); + expect(rec.cost).toBe(15000); + }); + + it('TransfersService manages approval workflow', async () => { + const tr = { id: 'tr-1', status: 'PENDING' }; + const mockRepo = { + findOne: jest.fn().mockResolvedValue(tr), + create: jest.fn().mockImplementation((dto) => dto), + save: jest.fn().mockImplementation((dto) => Promise.resolve(dto)), + }; + const service = new TransfersService(mockRepo as any); + const approved = await service.approve('tr-1', 'manager-1'); + expect(approved.status).toBe('APPROVED'); + expect(approved.approvedByUserId).toBe('manager-1'); + }); +}); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2dcad4bf0..50bb1c6fc 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -4,10 +4,35 @@ 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'; +import { NotesDocsController } from './assets/notes-docs.controller'; +import { MaintenanceModule } from './maintenance/maintenance.module'; +import { TransfersModule } from './transfers/transfers.module'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), + CommonModule, + UsersModule, + HealthModule, + AuthModule, + BranchesModule, + DepartmentsModule, + LocationsModule, + CategoriesModule, + AssetsLifecycleModule, + AssetsModule, + MaintenanceModule, + TransfersModule, TypeOrmModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ @@ -18,12 +43,12 @@ 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], }), ], - controllers: [AppController], + controllers: [AppController, NotesDocsController], providers: [AppService], }) export class AppModule {} diff --git a/backend/src/assets/entities/asset-document.entity.ts b/backend/src/assets/entities/asset-document.entity.ts new file mode 100644 index 000000000..270258205 --- /dev/null +++ b/backend/src/assets/entities/asset-document.entity.ts @@ -0,0 +1,28 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; + +@Entity('asset_documents') +export class AssetDocument { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @Column() + title: string; + + @Column() + fileUrl: string; + + @Column({ nullable: true }) + fileType?: string; + + @Column({ type: 'integer', default: 0 }) + fileSizeBytes: number; + + @Column() + uploadedByUserId: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/entities/asset-note.entity.ts b/backend/src/assets/entities/asset-note.entity.ts new file mode 100644 index 000000000..d09156a9c --- /dev/null +++ b/backend/src/assets/entities/asset-note.entity.ts @@ -0,0 +1,19 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn } from 'typeorm'; + +@Entity('asset_notes') +export class AssetNote { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @Column() + authorUserId: string; + + @Column({ type: 'text' }) + content: string; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/notes-docs.controller.ts b/backend/src/assets/notes-docs.controller.ts new file mode 100644 index 000000000..3f03313a9 --- /dev/null +++ b/backend/src/assets/notes-docs.controller.ts @@ -0,0 +1,57 @@ +import { Controller, Get, Post, Delete, Param, Body, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; + +@ApiTags('assets') +@Controller('assets/:id') +export class NotesDocsController { + private notes = new Map(); + private docs = new Map(); + + @Get('notes') + @ApiOperation({ summary: 'Get asset notes' }) + getNotes(@Param('id') assetId: string) { + return this.notes.get(assetId) || []; + } + + @Post('notes') + @ApiOperation({ summary: 'Add an asset note' }) + addNote(@Param('id') assetId: string, @Body('content') content: string, @Req() req: any) { + const list = this.notes.get(assetId) || []; + const note = { + id: `n_${Date.now()}`, + assetId, + content, + authorUserId: req.user?.id || 'usr-1', + createdAt: new Date(), + }; + list.unshift(note); + this.notes.set(assetId, list); + return note; + } + + @Get('documents') + @ApiOperation({ summary: 'Get asset document attachments' }) + getDocuments(@Param('id') assetId: string) { + return this.docs.get(assetId) || []; + } + + @Post('documents') + @ApiOperation({ summary: 'Attach a document to an asset' }) + addDocument( + @Param('id') assetId: string, + @Body() body: { title: string; fileUrl: string; fileType?: string; fileSizeBytes?: number }, + @Req() req: any, + ) { + const list = this.docs.get(assetId) || []; + const doc = { + id: `d_${Date.now()}`, + assetId, + ...body, + uploadedByUserId: req.user?.id || 'usr-1', + createdAt: new Date(), + }; + list.unshift(doc); + this.docs.set(assetId, list); + return doc; + } +} diff --git a/backend/src/maintenance/entities/maintenance-record.entity.ts b/backend/src/maintenance/entities/maintenance-record.entity.ts new file mode 100644 index 000000000..9bafdfd57 --- /dev/null +++ b/backend/src/maintenance/entities/maintenance-record.entity.ts @@ -0,0 +1,47 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +@Entity('maintenance_records') +export class MaintenanceRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @Column() + title: string; + + @Column({ nullable: true }) + description?: string; + + @Column({ type: 'enum', enum: MaintenanceStatus, default: MaintenanceStatus.SCHEDULED }) + status: MaintenanceStatus; + + @Column({ nullable: true }) + vendorId?: string; + + @Column({ type: 'integer', default: 0 }) + cost: number; + + @Column({ default: 'USD' }) + currency: string; + + @Column({ nullable: true }) + scheduledDate?: Date; + + @Column({ nullable: true }) + completedDate?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/maintenance/maintenance.controller.ts b/backend/src/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..6c8865a17 --- /dev/null +++ b/backend/src/maintenance/maintenance.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; + +@ApiTags('maintenance') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Get() + @ApiOperation({ summary: 'List all maintenance records' }) + findAll() { + return this.maintenanceService.findAll(); + } + + @Post() + @ApiOperation({ summary: 'Create a maintenance record' }) + create(@Body() dto: any) { + return this.maintenanceService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get maintenance record details' }) + findOne(@Param('id') id: string) { + return this.maintenanceService.findById(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a maintenance record' }) + update(@Param('id') id: string, @Body() dto: any) { + return this.maintenanceService.update(id, dto); + } +} diff --git a/backend/src/maintenance/maintenance.module.ts b/backend/src/maintenance/maintenance.module.ts new file mode 100644 index 000000000..7d4439644 --- /dev/null +++ b/backend/src/maintenance/maintenance.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceRecord } from './entities/maintenance-record.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceRecord])], + providers: [MaintenanceService], + controllers: [MaintenanceController], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/backend/src/maintenance/maintenance.service.ts b/backend/src/maintenance/maintenance.service.ts new file mode 100644 index 000000000..44ed1b913 --- /dev/null +++ b/backend/src/maintenance/maintenance.service.ts @@ -0,0 +1,33 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRecord } from './entities/maintenance-record.entity'; + +@Injectable() +export class MaintenanceService { + constructor( + @InjectRepository(MaintenanceRecord) + private readonly maintenanceRepo: Repository, + ) {} + + async findAll() { + return this.maintenanceRepo.find(); + } + + async findById(id: string) { + const record = await this.maintenanceRepo.findOne({ where: { id } }); + if (!record) throw new NotFoundException(`Maintenance record ${id} not found`); + return record; + } + + async create(dto: Partial) { + const record = this.maintenanceRepo.create(dto); + return this.maintenanceRepo.save(record); + } + + async update(id: string, dto: Partial) { + const record = await this.findById(id); + Object.assign(record, dto); + return this.maintenanceRepo.save(record); + } +} diff --git a/backend/src/transfers/entities/asset-transfer.entity.ts b/backend/src/transfers/entities/asset-transfer.entity.ts new file mode 100644 index 000000000..f20999a6a --- /dev/null +++ b/backend/src/transfers/entities/asset-transfer.entity.ts @@ -0,0 +1,42 @@ +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +export enum TransferStatus { + PENDING = 'PENDING', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', +} + +@Entity('asset_transfers') +export class AssetTransfer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @Column() + fromDepartmentId: string; + + @Column() + toDepartmentId: string; + + @Column() + requestedByUserId: string; + + @Column({ nullable: true }) + approvedByUserId?: string; + + @Column({ type: 'enum', enum: TransferStatus, default: TransferStatus.PENDING }) + status: TransferStatus; + + @Column({ nullable: true }) + rejectionReason?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/transfers/transfers.controller.ts b/backend/src/transfers/transfers.controller.ts new file mode 100644 index 000000000..927fed139 --- /dev/null +++ b/backend/src/transfers/transfers.controller.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Post, Param, Body, Req } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { TransfersService } from './transfers.service'; + +@ApiTags('transfers') +@Controller('transfers') +export class TransfersController { + constructor(private readonly transfersService: TransfersService) {} + + @Get() + @ApiOperation({ summary: 'List all asset transfers' }) + findAll() { + return this.transfersService.findAll(); + } + + @Post() + @ApiOperation({ summary: 'Request an asset transfer' }) + create(@Body() dto: any) { + return this.transfersService.create(dto); + } + + @Get(':id') + @ApiOperation({ summary: 'Get transfer request details' }) + findOne(@Param('id') id: string) { + return this.transfersService.findById(id); + } + + @Post(':id/approve') + @ApiOperation({ summary: 'Approve an asset transfer' }) + approve(@Param('id') id: string, @Req() req: any) { + const approverId = req.user?.id || 'usr-1'; + return this.transfersService.approve(id, approverId); + } + + @Post(':id/reject') + @ApiOperation({ summary: 'Reject an asset transfer' }) + reject(@Param('id') id: string, @Body('reason') reason: string) { + return this.transfersService.reject(id, reason || 'Rejected by manager'); + } +} diff --git a/backend/src/transfers/transfers.module.ts b/backend/src/transfers/transfers.module.ts new file mode 100644 index 000000000..76ff2c3ae --- /dev/null +++ b/backend/src/transfers/transfers.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetTransfer } from './entities/asset-transfer.entity'; +import { TransfersService } from './transfers.service'; +import { TransfersController } from './transfers.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetTransfer])], + providers: [TransfersService], + controllers: [TransfersController], + exports: [TransfersService], +}) +export class TransfersModule {} diff --git a/backend/src/transfers/transfers.service.ts b/backend/src/transfers/transfers.service.ts new file mode 100644 index 000000000..48b0e9fc4 --- /dev/null +++ b/backend/src/transfers/transfers.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetTransfer, TransferStatus } from './entities/asset-transfer.entity'; + +@Injectable() +export class TransfersService { + constructor( + @InjectRepository(AssetTransfer) + private readonly transferRepo: Repository, + ) {} + + async findAll() { + return this.transferRepo.find(); + } + + async findById(id: string) { + const tr = await this.transferRepo.findOne({ where: { id } }); + if (!tr) throw new NotFoundException(`Transfer ${id} not found`); + return tr; + } + + async create(dto: Partial) { + const tr = this.transferRepo.create({ ...dto, status: TransferStatus.PENDING }); + return this.transferRepo.save(tr); + } + + async approve(id: string, approverUserId: string) { + const tr = await this.findById(id); + if (tr.status !== TransferStatus.PENDING) { + throw new BadRequestException('Only pending transfers can be approved'); + } + tr.status = TransferStatus.APPROVED; + tr.approvedByUserId = approverUserId; + return this.transferRepo.save(tr); + } + + async reject(id: string, reason: string) { + const tr = await this.findById(id); + if (tr.status !== TransferStatus.PENDING) { + throw new BadRequestException('Only pending transfers can be rejected'); + } + tr.status = TransferStatus.REJECTED; + tr.rejectionReason = reason; + return this.transferRepo.save(tr); + } +}