diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6d702176..1864f8e4 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { AppController } from './app.controller'; import { AppService } from './app.service'; import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; +import { AssetsExtendedModule } from './assets/assets-extended.module'; import { AssetsOpsModule } from './assets/assets-ops.module'; import { CheckinModule } from './checkin/checkin.module'; import { AssetsModule } from './assets/assets.module'; @@ -64,6 +65,7 @@ import { CacheService } from './cache/cache.service'; }, }), + AssetsExtendedModule, AssetsOpsModule, CheckinModule, AssetsModule, diff --git a/backend/src/assets/assets-extended.controller.ts b/backend/src/assets/assets-extended.controller.ts new file mode 100644 index 00000000..e0ca0a47 --- /dev/null +++ b/backend/src/assets/assets-extended.controller.ts @@ -0,0 +1,56 @@ +import { Controller, Post, Get, Patch, Delete, Param, Body, Query, Req, UseGuards, UseInterceptors, UploadedFile } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { AssetsExtendedService } from './assets-extended.service'; +import { TransferAssetDto } from './dtos/transfer-asset.dto'; +import { CreateMaintenanceDto } from './dtos/create-maintenance.dto'; +import { UpdateMaintenanceDto } from './dtos/update-maintenance.dto'; +import { HistoryQueryDto } from './dtos/history-query.dto'; + +@Controller('assets') +@UseGuards(AuthGuard('jwt')) +export class AssetsExtendedController { + constructor(private readonly assetsExtendedService: AssetsExtendedService) {} + + @Post(':id/transfer') + async transfer(@Param('id') id: string, @Body() dto: TransferAssetDto, @Req() req: any) { + return this.assetsExtendedService.transfer(id, dto, req.user?.id); + } + + @Get(':id/history') + async getHistory(@Param('id') id: string, @Query() query: HistoryQueryDto) { + return this.assetsExtendedService.getHistory(id, query); + } + + @Post(':id/documents') + @UseInterceptors(FileInterceptor('file')) + async uploadDocument(@Param('id') id: string, @UploadedFile() file: Express.Multer.File, @Req() req: any) { + return this.assetsExtendedService.addDocument(id, file, req.user?.id); + } + + @Get(':id/documents') + async listDocuments(@Param('id') id: string) { + return this.assetsExtendedService.listDocuments(id); + } + + @Delete(':id/documents/:documentId') + async deleteDocument(@Param('id') id: string, @Param('documentId') documentId: string) { + await this.assetsExtendedService.deleteDocument(id, documentId); + return { message: 'Document deleted' }; + } + + @Post(':id/maintenance') + async createMaintenance(@Param('id') id: string, @Body() dto: CreateMaintenanceDto, @Req() req: any) { + return this.assetsExtendedService.createMaintenance(id, dto, req.user?.id); + } + + @Get(':id/maintenance') + async getMaintenanceRecords(@Param('id') id: string) { + return this.assetsExtendedService.getMaintenanceRecords(id); + } + + @Patch(':id/maintenance/:recordId') + async updateMaintenance(@Param('id') id: string, @Param('recordId') recordId: string, @Body() dto: UpdateMaintenanceDto) { + return this.assetsExtendedService.updateMaintenance(id, recordId, dto); + } +} diff --git a/backend/src/assets/assets-extended.module.ts b/backend/src/assets/assets-extended.module.ts new file mode 100644 index 00000000..a96f3fe4 --- /dev/null +++ b/backend/src/assets/assets-extended.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Asset } from './entities/asset.entity'; +import { AssetHistory } from './entities/asset-history.entity'; +import { AssetDocument } from './entities/asset-document.entity'; +import { MaintenanceRecord } from './entities/maintenance-record.entity'; +import { AssetsExtendedService } from './assets-extended.service'; +import { AssetsExtendedController } from './assets-extended.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([Asset, AssetHistory, AssetDocument, MaintenanceRecord])], + controllers: [AssetsExtendedController], + providers: [AssetsExtendedService], + exports: [AssetsExtendedService], +}) +export class AssetsExtendedModule {} diff --git a/backend/src/assets/assets-extended.service.ts b/backend/src/assets/assets-extended.service.ts new file mode 100644 index 00000000..4b5851b2 --- /dev/null +++ b/backend/src/assets/assets-extended.service.ts @@ -0,0 +1,147 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Asset } from './entities/asset.entity'; +import { AssetHistory } from './entities/asset-history.entity'; +import { AssetDocument } from './entities/asset-document.entity'; +import { MaintenanceRecord } from './entities/maintenance-record.entity'; +import { TransferAssetDto } from './dtos/transfer-asset.dto'; +import { CreateMaintenanceDto } from './dtos/create-maintenance.dto'; +import { UpdateMaintenanceDto } from './dtos/update-maintenance.dto'; +import { HistoryQueryDto } from './dtos/history-query.dto'; + +@Injectable() +export class AssetsExtendedService { + constructor( + @InjectRepository(Asset) + private readonly assetRepository: Repository, + @InjectRepository(AssetHistory) + private readonly historyRepository: Repository, + @InjectRepository(AssetDocument) + private readonly documentRepository: Repository, + @InjectRepository(MaintenanceRecord) + private readonly maintenanceRepository: Repository, + ) {} + + async transfer(id: string, dto: TransferAssetDto, userId?: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id }, relations: ['assignedTo'] }); + if (!asset) throw new NotFoundException('Asset not found'); + + const previousValue: Record = { + assignedToId: asset.assignedToId, + departmentId: asset.departmentId, + location: asset.location, + }; + const newValue: Record = { ...dto }; + + Object.assign(asset, dto); + asset.updatedById = userId; + await this.assetRepository.save(asset); + + await this.historyRepository.save( + this.historyRepository.create({ + assetId: id, + action: 'TRANSFERRED', + description: dto.notes || 'Asset transferred', + previousValue: previousValue as Record, + newValue: newValue as Record, + performedById: userId, + }), + ); + + return asset; + } + + async getHistory(assetId: string, query: HistoryQueryDto) { + const qb = this.historyRepository.createQueryBuilder('h') + .leftJoinAndSelect('h.performedBy', 'performedBy') + .where('h.assetId = :assetId', { assetId }) + .orderBy('h.createdAt', 'DESC'); + + if (query.action) qb.andWhere('h.action = :action', { action: query.action }); + if (query.startDate) qb.andWhere('h.createdAt >= :startDate', { startDate: query.startDate }); + if (query.endDate) qb.andWhere('h.createdAt <= :endDate', { endDate: query.endDate }); + if (query.search) qb.andWhere('h.description ILIKE :search', { search: `%${query.search}%` }); + + return qb.getMany(); + } + + async addDocument(assetId: string, file: Express.Multer.File, userId?: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + if (!asset) throw new NotFoundException('Asset not found'); + + const doc = this.documentRepository.create({ + assetId, + name: file.originalname, + type: file.mimetype, + url: '', // Will be set after S3 upload + s3Key: `${assetId}/${Date.now()}-${file.originalname}`, + size: file.size, + uploadedById: userId, + }); + await this.documentRepository.save(doc); + + await this.historyRepository.save( + this.historyRepository.create({ + assetId, + action: 'DOCUMENT_UPLOADED', + description: `Document "${file.originalname}" uploaded`, + performedById: userId, + }), + ); + + return doc; + } + + async listDocuments(assetId: string) { + return this.documentRepository.find({ + where: { assetId }, + relations: ['uploadedBy'], + order: { createdAt: 'DESC' }, + }); + } + + async deleteDocument(assetId: string, documentId: string): Promise { + const doc = await this.documentRepository.findOne({ where: { id: documentId, assetId } }); + if (!doc) throw new NotFoundException('Document not found'); + await this.documentRepository.remove(doc); + } + + async createMaintenance(assetId: string, dto: CreateMaintenanceDto, userId?: string): Promise { + const asset = await this.assetRepository.findOne({ where: { id: assetId } }); + if (!asset) throw new NotFoundException('Asset not found'); + + const record = this.maintenanceRepository.create({ + assetId, + ...dto, + performedById: userId, + }); + await this.maintenanceRepository.save(record); + + await this.historyRepository.save( + this.historyRepository.create({ + assetId, + action: 'MAINTENANCE', + description: `Maintenance scheduled: ${dto.description}`, + performedById: userId, + }), + ); + + return record; + } + + async getMaintenanceRecords(assetId: string) { + return this.maintenanceRepository.find({ + where: { assetId }, + relations: ['performedBy'], + order: { scheduledDate: 'DESC' }, + }); + } + + async updateMaintenance(assetId: string, recordId: string, dto: UpdateMaintenanceDto): Promise { + const record = await this.maintenanceRepository.findOne({ where: { id: recordId, assetId } }); + if (!record) throw new NotFoundException('Maintenance record not found'); + Object.assign(record, dto); + return this.maintenanceRepository.save(record); + } +} diff --git a/backend/src/assets/dtos/create-maintenance.dto.ts b/backend/src/assets/dtos/create-maintenance.dto.ts new file mode 100644 index 00000000..561e2d34 --- /dev/null +++ b/backend/src/assets/dtos/create-maintenance.dto.ts @@ -0,0 +1,16 @@ +import { IsString, IsOptional, IsNumber } from 'class-validator'; + +export class CreateMaintenanceDto { + @IsString() + type: string; + + @IsString() + description: string; + + @IsString() + scheduledDate: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/assets/dtos/history-query.dto.ts b/backend/src/assets/dtos/history-query.dto.ts new file mode 100644 index 00000000..a281e7a7 --- /dev/null +++ b/backend/src/assets/dtos/history-query.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class HistoryQueryDto { + @IsOptional() + @IsString() + action?: string; + + @IsOptional() + @IsString() + startDate?: string; + + @IsOptional() + @IsString() + endDate?: string; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/backend/src/assets/dtos/transfer-asset.dto.ts b/backend/src/assets/dtos/transfer-asset.dto.ts new file mode 100644 index 00000000..0e8b5c3a --- /dev/null +++ b/backend/src/assets/dtos/transfer-asset.dto.ts @@ -0,0 +1,19 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class TransferAssetDto { + @IsOptional() + @IsString() + assignedToId?: string; + + @IsOptional() + @IsString() + departmentId?: string; + + @IsOptional() + @IsString() + location?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/assets/dtos/update-maintenance.dto.ts b/backend/src/assets/dtos/update-maintenance.dto.ts new file mode 100644 index 00000000..72b26d08 --- /dev/null +++ b/backend/src/assets/dtos/update-maintenance.dto.ts @@ -0,0 +1,23 @@ +import { IsOptional, IsString, IsNumber } from 'class-validator'; + +export class UpdateMaintenanceDto { + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsString() + completedDate?: string; + + @IsOptional() + @IsNumber() + cost?: number; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsString() + performedById?: string; +} 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 00000000..bed40bfd --- /dev/null +++ b/backend/src/assets/entities/asset-document.entity.ts @@ -0,0 +1,41 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Asset } from './asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('asset_documents') +export class AssetDocument { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + name: string; + + @Column() + type: string; + + @Column() + url: string; + + @Column({ nullable: true }) + s3Key: string; + + @Column({ type: 'int', nullable: true }) + size: number; + + @Column({ nullable: true }) + uploadedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'uploadedById' }) + uploadedBy: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/entities/asset-history.entity.ts b/backend/src/assets/entities/asset-history.entity.ts new file mode 100644 index 00000000..4b2b53a6 --- /dev/null +++ b/backend/src/assets/entities/asset-history.entity.ts @@ -0,0 +1,38 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Asset } from './asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('asset_history') +export class AssetHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + action: string; + + @Column({ nullable: true, type: 'text' }) + description: string; + + @Column({ nullable: true, type: 'jsonb' }) + previousValue: Record; + + @Column({ nullable: true, type: 'jsonb' }) + newValue: Record; + + @Column({ nullable: true }) + performedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'performedById' }) + performedBy: User; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index e02f4c74..fd47905f 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -21,6 +21,18 @@ export class Asset { @Column({ nullable: true }) serialNumber: string; + @Column({ nullable: true, type: 'date' }) + purchaseDate: string; + + @Column({ nullable: true, type: 'decimal', precision: 12, scale: 2 }) + purchasePrice: number; + + @Column({ nullable: true, type: 'decimal', precision: 12, scale: 2 }) + currentValue: number; + + @Column({ nullable: true, type: 'date' }) + warrantyExpiration: string; + @Column({ default: 'ACTIVE' }) status: string; @@ -36,6 +48,25 @@ export class Asset { @Column({ nullable: true }) assignedToId: string; + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assignedToId' }) + assignedTo: User; + + @Column({ nullable: true, type: 'jsonb' }) + imageUrls: string[]; + + @Column({ nullable: true, type: 'jsonb' }) + customFields: Record; + + @Column({ nullable: true, type: 'jsonb' }) + tags: string[]; + + @Column({ nullable: true }) + manufacturer: string; + + @Column({ nullable: true }) + model: string; + @Column({ nullable: true }) barcode: string; diff --git a/backend/src/assets/entities/maintenance-record.entity.ts b/backend/src/assets/entities/maintenance-record.entity.ts new file mode 100644 index 00000000..1b552393 --- /dev/null +++ b/backend/src/assets/entities/maintenance-record.entity.ts @@ -0,0 +1,50 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Asset } from './asset.entity'; +import { User } from '../../users/entities/user.entity'; + +@Entity('maintenance_records') +export class MaintenanceRecord { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + assetId: string; + + @ManyToOne(() => Asset, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'assetId' }) + asset: Asset; + + @Column() + type: string; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'date' }) + scheduledDate: string; + + @Column({ type: 'date', nullable: true }) + completedDate: string; + + @Column({ type: 'decimal', precision: 12, scale: 2, nullable: true }) + cost: number; + + @Column({ nullable: true }) + performedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'performedById' }) + performedBy: User; + + @Column({ nullable: true, type: 'text' }) + notes: string; + + @Column({ default: 'SCHEDULED' }) + status: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +}