diff --git a/backend/src/asset-disposals/asset-disposals.controller.ts b/backend/src/asset-disposals/asset-disposals.controller.ts new file mode 100644 index 0000000..22eea13 --- /dev/null +++ b/backend/src/asset-disposals/asset-disposals.controller.ts @@ -0,0 +1,24 @@ +import { Controller, Post, Body, Get, Param, UsePipes, ValidationPipe } from '@nestjs/common'; +import { AssetDisposalsService } from './asset-disposals.service'; +import { CreateAssetDisposalDto } from './dto/create-asset-disposal.dto'; + +@Controller('asset-disposals') +export class AssetDisposalsController { + constructor(private readonly disposalsService: AssetDisposalsService) {} + + @Post() + @UsePipes(new ValidationPipe({ whitelist: true })) + markDisposed(@Body() dto: CreateAssetDisposalDto) { + return this.disposalsService.markDisposed(dto); + } + + @Get() + findAll() { + return this.disposalsService.findAll(); + } + + @Get('asset/:assetId') + findByAsset(@Param('assetId') assetId: string) { + return this.disposalsService.findByAsset(assetId); + } +} \ No newline at end of file diff --git a/backend/src/asset-disposals/asset-disposals.module.ts b/backend/src/asset-disposals/asset-disposals.module.ts new file mode 100644 index 0000000..3caf92c --- /dev/null +++ b/backend/src/asset-disposals/asset-disposals.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetDisposalsService } from './asset-disposals.service'; +import { AssetDisposalsController } from './asset-disposals.controller'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { InventoryItem } from '../inventory/entities/inventory-item.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetDisposal, InventoryItem])], + controllers: [AssetDisposalsController], + providers: [AssetDisposalsService], + exports: [AssetDisposalsService], +}) +export class AssetDisposalsModule {} \ No newline at end of file diff --git a/backend/src/asset-disposals/asset-disposals.service.ts b/backend/src/asset-disposals/asset-disposals.service.ts new file mode 100644 index 0000000..15b273e --- /dev/null +++ b/backend/src/asset-disposals/asset-disposals.service.ts @@ -0,0 +1,54 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetDisposal } from './entities/asset-disposal.entity'; +import { CreateAssetDisposalDto } from './dto/create-asset-disposal.dto'; +import { InventoryItem, InventoryStatus } from '../inventory/entities/inventory-item.entity'; + +@Injectable() +export class AssetDisposalsService { + constructor( + @InjectRepository(AssetDisposal) + private readonly disposalRepository: Repository, + @InjectRepository(InventoryItem) + private readonly inventoryRepository: Repository, + ) {} + + async markDisposed(dto: CreateAssetDisposalDto) { + const asset = await this.inventoryRepository.findOne({ where: { id: dto.assetId } }); + if (!asset) { + throw new NotFoundException(`Asset with ID ${dto.assetId} not found`); + } + + if (asset.status === InventoryStatus.DISPOSED) { + throw new BadRequestException('Asset is already disposed'); + } + + return await this.inventoryRepository.manager.transaction(async (manager) => { + const itemRepo = manager.getRepository(InventoryItem); + const disposalRepo = manager.getRepository(AssetDisposal); + + asset.status = InventoryStatus.DISPOSED; + await itemRepo.save(asset); + + const disposal = disposalRepo.create({ + assetId: dto.assetId, + disposalDate: new Date(dto.disposalDate), + method: dto.method, + reason: dto.reason, + approvedBy: dto.approvedBy, + }); + const saved = await disposalRepo.save(disposal); + + return { message: 'Asset marked as disposed', disposal: saved }; + }); + } + + async findAll() { + return this.disposalRepository.find({ order: { disposalDate: 'DESC' } }); + } + + async findByAsset(assetId: string) { + return this.disposalRepository.find({ where: { assetId }, order: { disposalDate: 'DESC' } }); + } +} \ No newline at end of file diff --git a/backend/src/asset-disposals/dto/create-asset-disposal.dto.ts b/backend/src/asset-disposals/dto/create-asset-disposal.dto.ts new file mode 100644 index 0000000..b343f33 --- /dev/null +++ b/backend/src/asset-disposals/dto/create-asset-disposal.dto.ts @@ -0,0 +1,21 @@ +import { IsUUID, IsDateString, IsEnum, IsString, IsNotEmpty, IsOptional } from 'class-validator'; +import { DisposalMethod } from '../entities/asset-disposal.entity'; + +export class CreateAssetDisposalDto { + @IsUUID() + assetId: string; + + @IsDateString() + disposalDate: string; + + @IsEnum(DisposalMethod) + method: DisposalMethod; + + @IsString() + @IsOptional() + reason?: string; + + @IsString() + @IsNotEmpty() + approvedBy: string; +} \ No newline at end of file diff --git a/backend/src/asset-disposals/entities/asset-disposal.entity.ts b/backend/src/asset-disposals/entities/asset-disposal.entity.ts new file mode 100644 index 0000000..f4e7ab5 --- /dev/null +++ b/backend/src/asset-disposals/entities/asset-disposal.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +export enum DisposalMethod { + SALE = 'sale', + DONATION = 'donation', + SCRAP = 'scrap', + OTHER = 'other', +} + +@Entity('asset_disposals') +@Index(['assetId', 'disposalDate']) +export class AssetDisposal { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + assetId: string; + + @Column({ type: 'timestamp' }) + disposalDate: Date; + + @Column({ type: 'enum', enum: DisposalMethod }) + method: DisposalMethod; + + @Column({ type: 'text', nullable: true }) + reason: string; + + @Column() + approvedBy: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/asset-maintenance.controller.ts b/backend/src/asset-maintenance/asset-maintenance.controller.ts new file mode 100644 index 0000000..38376d8 --- /dev/null +++ b/backend/src/asset-maintenance/asset-maintenance.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Post, Body, Get, Param, Patch, UsePipes, ValidationPipe } from '@nestjs/common'; +import { AssetMaintenanceService } from './asset-maintenance.service'; +import { ScheduleMaintenanceDto } from './dto/schedule-maintenance.dto'; +import { CompleteMaintenanceDto } from './dto/complete-maintenance.dto'; + +@Controller('asset-maintenance') +export class AssetMaintenanceController { + constructor(private readonly maintenanceService: AssetMaintenanceService) {} + + @Post('schedule') + @UsePipes(new ValidationPipe({ whitelist: true })) + schedule(@Body() dto: ScheduleMaintenanceDto) { + return this.maintenanceService.schedule(dto); + } + + @Patch(':id/complete') + @UsePipes(new ValidationPipe({ whitelist: true })) + complete(@Param('id') id: string, @Body() dto: CompleteMaintenanceDto) { + return this.maintenanceService.complete(id, dto); + } + + @Get() + findAll() { + return this.maintenanceService.findAll(); + } + + @Get('asset/:assetId') + findByAsset(@Param('assetId') assetId: string) { + return this.maintenanceService.findByAsset(assetId); + } +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/asset-maintenance.module.ts b/backend/src/asset-maintenance/asset-maintenance.module.ts new file mode 100644 index 0000000..16e67cb --- /dev/null +++ b/backend/src/asset-maintenance/asset-maintenance.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetMaintenanceService } from './asset-maintenance.service'; +import { AssetMaintenanceController } from './asset-maintenance.controller'; +import { AssetMaintenance } from './entities/asset-maintenance.entity'; +import { InventoryItem } from '../inventory/entities/inventory-item.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetMaintenance, InventoryItem])], + controllers: [AssetMaintenanceController], + providers: [AssetMaintenanceService], + exports: [AssetMaintenanceService], +}) +export class AssetMaintenanceModule {} \ No newline at end of file diff --git a/backend/src/asset-maintenance/asset-maintenance.service.ts b/backend/src/asset-maintenance/asset-maintenance.service.ts new file mode 100644 index 0000000..4d0a8d7 --- /dev/null +++ b/backend/src/asset-maintenance/asset-maintenance.service.ts @@ -0,0 +1,63 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AssetMaintenance } from './entities/asset-maintenance.entity'; +import { ScheduleMaintenanceDto } from './dto/schedule-maintenance.dto'; +import { CompleteMaintenanceDto } from './dto/complete-maintenance.dto'; +import { InventoryItem, InventoryStatus } from '../inventory/entities/inventory-item.entity'; + +@Injectable() +export class AssetMaintenanceService { + constructor( + @InjectRepository(AssetMaintenance) + private readonly maintenanceRepository: Repository, + @InjectRepository(InventoryItem) + private readonly inventoryRepository: Repository, + ) {} + + async schedule(dto: ScheduleMaintenanceDto) { + const asset = await this.inventoryRepository.findOne({ where: { id: dto.assetId } }); + if (!asset) { + throw new NotFoundException(`Asset with ID ${dto.assetId} not found`); + } + + // Prevent scheduling maintenance for disposed assets + if (asset.status === InventoryStatus.DISPOSED) { + throw new BadRequestException('Cannot schedule maintenance for disposed asset'); + } + + const record = this.maintenanceRepository.create({ + assetId: dto.assetId, + scheduledDate: new Date(dto.scheduledDate), + completedDate: null, + maintenanceType: dto.maintenanceType, + notes: dto.notes, + }); + + const saved = await this.maintenanceRepository.save(record); + return { message: 'Maintenance scheduled', maintenance: saved }; + } + + async complete(id: string, dto: CompleteMaintenanceDto) { + const record = await this.maintenanceRepository.findOne({ where: { id } }); + if (!record) { + throw new NotFoundException(`Maintenance record with ID ${id} not found`); + } + + record.completedDate = new Date(dto.completedDate); + if (dto.notes) { + record.notes = dto.notes; + } + + const saved = await this.maintenanceRepository.save(record); + return { message: 'Maintenance completed', maintenance: saved }; + } + + async findAll() { + return this.maintenanceRepository.find({ order: { scheduledDate: 'DESC' } }); + } + + async findByAsset(assetId: string) { + return this.maintenanceRepository.find({ where: { assetId }, order: { scheduledDate: 'DESC' } }); + } +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/dto/complete-maintenance.dto.ts b/backend/src/asset-maintenance/dto/complete-maintenance.dto.ts new file mode 100644 index 0000000..ce9e9db --- /dev/null +++ b/backend/src/asset-maintenance/dto/complete-maintenance.dto.ts @@ -0,0 +1,10 @@ +import { IsDateString, IsString, IsOptional } from 'class-validator'; + +export class CompleteMaintenanceDto { + @IsDateString() + completedDate: string; + + @IsString() + @IsOptional() + notes?: string; +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts b/backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts new file mode 100644 index 0000000..9e7a613 --- /dev/null +++ b/backend/src/asset-maintenance/dto/schedule-maintenance.dto.ts @@ -0,0 +1,17 @@ +import { IsUUID, IsDateString, IsEnum, IsString, IsOptional } from 'class-validator'; +import { MaintenanceType } from '../entities/asset-maintenance.entity'; + +export class ScheduleMaintenanceDto { + @IsUUID() + assetId: string; + + @IsDateString() + scheduledDate: string; + + @IsEnum(MaintenanceType) + maintenanceType: MaintenanceType; + + @IsString() + @IsOptional() + notes?: string; +} \ No newline at end of file diff --git a/backend/src/asset-maintenance/entities/asset-maintenance.entity.ts b/backend/src/asset-maintenance/entities/asset-maintenance.entity.ts new file mode 100644 index 0000000..6d9f3e5 --- /dev/null +++ b/backend/src/asset-maintenance/entities/asset-maintenance.entity.ts @@ -0,0 +1,39 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm'; + +export enum MaintenanceType { + PREVENTIVE = 'preventive', + CORRECTIVE = 'corrective', + INSPECTION = 'inspection', + REPLACEMENT = 'replacement', + SERVICE = 'service', + OTHER = 'other', +} + +@Entity('asset_maintenance') +@Index(['assetId', 'scheduledDate']) +export class AssetMaintenance { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid') + @Index() + assetId: string; + + @Column({ type: 'timestamp' }) + scheduledDate: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedDate: Date | null; + + @Column({ type: 'enum', enum: MaintenanceType }) + maintenanceType: MaintenanceType; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/inventory/entities/inventory-item.entity.ts b/backend/src/inventory/entities/inventory-item.entity.ts index 79a4587..3355625 100644 --- a/backend/src/inventory/entities/inventory-item.entity.ts +++ b/backend/src/inventory/entities/inventory-item.entity.ts @@ -1,5 +1,10 @@ import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +export enum InventoryStatus { + ACTIVE = 'active', + DISPOSED = 'disposed', +} + @Entity() export class InventoryItem { @PrimaryGeneratedColumn('uuid') @@ -14,7 +19,10 @@ export class InventoryItem { @Column({ type: 'int' }) quantity: number; - // --- ADD THIS NEW FIELD --- - @Column({ type: 'int', default: 10 }) // Default threshold of 10 units + // Default threshold of 10 units + @Column({ type: 'int', default: 10 }) threshold: number; + + @Column({ type: 'enum', enum: InventoryStatus, default: InventoryStatus.ACTIVE }) + status: InventoryStatus; } \ No newline at end of file diff --git a/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts b/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts index fef049f..1c50640 100644 --- a/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts +++ b/backend/src/inventory/inventory-alerts/inventory-alerts.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { InventoryItem } from '../inventory/entities/inventory-item.entity'; +import { InventoryItem } from '../entities/inventory-item.entity'; import { InventoryAlertsService } from './inventory-alerts.service'; import { InventoryAlertsController } from './inventory-alerts.controller'; import { InventoryEventListener } from './listeners/inventory.listener'; diff --git a/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts b/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts index 8daee2c..6e955d5 100644 --- a/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts +++ b/backend/src/inventory/inventory-alerts/inventory-alerts.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { InventoryItem } from '../inventory/entities/inventory-item.entity'; +import { InventoryItem, InventoryStatus } from '../entities/inventory-item.entity'; export interface LowStockAlert { itemId: string; @@ -38,6 +38,15 @@ export class InventoryAlertsService { return; } + // Ignore disposed items + if (item.status === InventoryStatus.DISPOSED) { + if (this.activeAlerts.has(item.id)) { + this.activeAlerts.delete(item.id); + } + this.logger.log(`Skipping threshold check for disposed item ${item.name} (SKU: ${item.sku}).`); + return; + } + if (item.quantity <= item.threshold) { // Stock is low or has reached the threshold, create/update an alert. if (!this.activeAlerts.has(item.id)) { @@ -51,8 +60,6 @@ export class InventoryAlertsService { }; this.activeAlerts.set(item.id, newAlert); this.logger.log(`New low-stock alert generated for ${item.name} (SKU: ${item.sku})`); - // Here you could also emit an event to trigger notifications (email, etc.) - // this.eventEmitter.emit('alert.low_stock', newAlert); } } else { // Stock is sufficient, remove any existing alert.