diff --git a/backend/src/assets/asset-history.module.ts b/backend/src/assets/asset-history.module.ts new file mode 100644 index 00000000..2e3ff655 --- /dev/null +++ b/backend/src/assets/asset-history.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetHistoryEvent } from './entities/asset-history-event.entity'; +import { AssetHistoryService } from './asset-history.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([AssetHistoryEvent])], + providers: [AssetHistoryService], + exports: [AssetHistoryService], +}) +export class AssetHistoryModule {} diff --git a/backend/src/assets/asset-history.service.ts b/backend/src/assets/asset-history.service.ts new file mode 100644 index 00000000..733b0d12 --- /dev/null +++ b/backend/src/assets/asset-history.service.ts @@ -0,0 +1,77 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, EntityManager, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { + AssetHistoryAction, + AssetHistoryEvent, +} from './entities/asset-history-event.entity'; + +export interface RecordHistoryInput { + assetId: string; + action: AssetHistoryAction; + description: string; + previousValue?: Record | null; + newValue?: Record | null; + performedById?: string; +} + +export interface AssetHistoryFilters { + action?: AssetHistoryAction; + startDate?: string; + endDate?: string; + search?: string; +} + +@Injectable() +export class AssetHistoryService { + constructor( + @InjectRepository(AssetHistoryEvent) + private readonly historyRepo: Repository, + ) {} + + /** + * Persist a history event. Pass `manager` to enlist the write in the caller's + * transaction so the event and the asset mutation commit together. + */ + async record( + input: RecordHistoryInput, + manager?: EntityManager, + ): Promise { + const repo = manager ? manager.getRepository(AssetHistoryEvent) : this.historyRepo; + const event = repo.create({ + assetId: input.assetId, + action: input.action, + description: input.description, + previousValue: input.previousValue ?? null, + newValue: input.newValue ?? null, + performedById: input.performedById, + }); + return repo.save(event); + } + + async findByAsset( + assetId: string, + filters?: AssetHistoryFilters, + ): Promise { + const where: Record = { assetId }; + + if (filters?.action) where.action = filters.action; + + const start = filters?.startDate ? new Date(filters.startDate) : undefined; + const end = filters?.endDate ? new Date(filters.endDate) : undefined; + if (start && end) where.createdAt = Between(start, end); + else if (start) where.createdAt = MoreThanOrEqual(start); + else if (end) where.createdAt = LessThanOrEqual(end); + + const events = await this.historyRepo.find({ + where, + relations: ['performedBy'], + order: { createdAt: 'DESC' }, + }); + + if (!filters?.search) return events; + + const needle = filters.search.toLowerCase(); + return events.filter((e) => e.description?.toLowerCase().includes(needle)); + } +} diff --git a/backend/src/assets/asset-lifecycle.service.ts b/backend/src/assets/asset-lifecycle.service.ts index e0bf9f6d..a94a5a2f 100644 --- a/backend/src/assets/asset-lifecycle.service.ts +++ b/backend/src/assets/asset-lifecycle.service.ts @@ -10,6 +10,27 @@ export enum AssetStatus { LOST = 'LOST', } +/** + * Status values accepted on the wire: the canonical statuses plus the aliases + * the frontend AssetStatus enum uses (ACTIVE, MAINTENANCE). + */ +export enum AssetStatusInput { + AVAILABLE = 'AVAILABLE', + ACTIVE = 'ACTIVE', + ASSIGNED = 'ASSIGNED', + IN_MAINTENANCE = 'IN_MAINTENANCE', + MAINTENANCE = 'MAINTENANCE', + IN_TRANSIT = 'IN_TRANSIT', + RETIRED = 'RETIRED', + DISPOSED = 'DISPOSED', + LOST = 'LOST', +} + +const STATUS_ALIASES: Record = { + ACTIVE: AssetStatus.AVAILABLE, + MAINTENANCE: AssetStatus.IN_MAINTENANCE, +}; + const ALLOWED_TRANSITIONS: Record = { [AssetStatus.AVAILABLE]: [ AssetStatus.ASSIGNED, @@ -48,6 +69,24 @@ const ALLOWED_TRANSITIONS: Record = { export class AssetLifecycleService { private history = new Map(); + /** + * Resolve a wire status value to its canonical AssetStatus, rejecting anything + * outside the enum with a 400. + */ + normalizeStatus(status: string): AssetStatus { + if (!status) { + throw new BadRequestException('status is required'); + } + const upper = String(status).toUpperCase(); + const resolved = STATUS_ALIASES[upper] ?? (upper as AssetStatus); + if (!Object.values(AssetStatus).includes(resolved)) { + throw new BadRequestException( + `Invalid asset status "${status}". Allowed values: ${Object.values(AssetStatusInput).join(', ')}`, + ); + } + return resolved; + } + validateTransition(fromStatus: AssetStatus, toStatus: AssetStatus) { if (fromStatus === toStatus) return true; const allowed = ALLOWED_TRANSITIONS[fromStatus] || []; diff --git a/backend/src/assets/asset-status.controller.ts b/backend/src/assets/asset-status.controller.ts deleted file mode 100644 index a5695372..00000000 --- a/backend/src/assets/asset-status.controller.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Controller, Get, Patch, Param, Body, Req } from '@nestjs/common'; -import { - ApiTags, - ApiOperation, - ApiBearerAuth, - ApiResponse, -} from '@nestjs/swagger'; -import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service'; - -@ApiTags('assets') -@ApiBearerAuth('JWT-auth') -@Controller('assets') -export class AssetStatusController { - constructor(private readonly lifecycleService: AssetLifecycleService) {} - - @Patch(':id/status') - @ApiOperation({ summary: 'Update asset status' }) - @ApiResponse({ status: 200, description: 'Status updated' }) - @ApiResponse({ status: 400, description: 'Invalid status transition' }) - updateStatus( - @Param('id') id: string, - @Body() - body: { currentStatus: AssetStatus; newStatus: AssetStatus; note?: string }, - @Req() req: any, - ) { - this.lifecycleService.validateTransition( - body.currentStatus, - body.newStatus, - ); - const actorId = req.user?.id || 'usr-1'; - const entry = this.lifecycleService.recordHistory(id, { - eventType: 'STATUS_CHANGED', - actorUserId: actorId, - note: body.note, - fieldChanges: { - status: { from: body.currentStatus, to: body.newStatus }, - }, - }); - return { assetId: id, status: body.newStatus, historyEntry: entry }; - } - - @Get(':id/history') - @ApiOperation({ summary: 'Get asset history and audit trail' }) - @ApiResponse({ status: 200, description: 'Asset history' }) - getHistory(@Param('id') id: string) { - return this.lifecycleService.getHistory(id); - } -} diff --git a/backend/src/assets/assets-lifecycle.module.ts b/backend/src/assets/assets-lifecycle.module.ts index b7934ff7..70c584e2 100644 --- a/backend/src/assets/assets-lifecycle.module.ts +++ b/backend/src/assets/assets-lifecycle.module.ts @@ -1,10 +1,8 @@ import { Module } from '@nestjs/common'; import { AssetLifecycleService } from './asset-lifecycle.service'; -import { AssetStatusController } from './asset-status.controller'; @Module({ providers: [AssetLifecycleService], - controllers: [AssetStatusController], exports: [AssetLifecycleService], }) export class AssetsLifecycleModule {} diff --git a/backend/src/assets/assets.controller.ts b/backend/src/assets/assets.controller.ts index 97d5d62c..d7e33f06 100644 --- a/backend/src/assets/assets.controller.ts +++ b/backend/src/assets/assets.controller.ts @@ -13,12 +13,17 @@ import { ApiTags, ApiOperation, ApiBearerAuth, + ApiBadRequestResponse, ApiResponse, } from '@nestjs/swagger'; import { AssetsService } from './assets.service'; +import { AssetHistoryService } from './asset-history.service'; +import { AssetHistoryAction } from './entities/asset-history-event.entity'; import { BulkStatusDto } from './dto/bulk-status.dto'; import { BulkAssignDto } from './dto/bulk-assign.dto'; import { BulkDeleteDto } from './dto/bulk-delete.dto'; +import { UpdateAssetStatusDto } from './dto/update-asset-status.dto'; +import { TransferAssetDto } from './dto/transfer-asset.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { Roles } from '../common/decorators/roles.decorator'; @@ -29,7 +34,10 @@ import { User } from '../users/entities/user.entity'; @ApiBearerAuth('JWT-auth') @Controller('assets') export class AssetsController { - constructor(private readonly assetsService: AssetsService) {} + constructor( + private readonly assetsService: AssetsService, + private readonly assetHistoryService: AssetHistoryService, + ) {} @Get() @ApiOperation({ summary: 'List all assets (paginated, filterable)' }) @@ -66,14 +74,15 @@ export class AssetsController { @ApiResponse({ status: 200, description: 'Asset details' }) @ApiResponse({ status: 404, description: 'Asset not found' }) findOne(@Param('id') id: string) { - return this.assetsService.findById(id); + return this.assetsService.findDetail(id); } @Patch(':id') @ApiOperation({ summary: 'Update an asset' }) @ApiResponse({ status: 200, description: 'Asset updated' }) - update(@Param('id') id: string, @Body() dto: any) { - return this.assetsService.update(id, dto); + async update(@Param('id') id: string, @Body() dto: any) { + await this.assetsService.update(id, dto); + return this.assetsService.findDetail(id); } @Delete(':id') @@ -83,6 +92,51 @@ export class AssetsController { return this.assetsService.delete(id); } + @Get(':id/history') + @ApiOperation({ summary: 'Get an asset history and audit trail' }) + getHistory( + @Param('id') id: string, + @Query('action') action?: AssetHistoryAction, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('search') search?: string, + ) { + return this.assetHistoryService.findByAsset(id, { + action, + startDate, + endDate, + search, + }); + } + + @Patch(':id/status') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Change an asset status, with an optional reason' }) + @ApiBadRequestResponse({ description: 'Invalid status value or illegal transition' }) + updateStatus( + @Param('id') id: string, + @Body() dto: UpdateAssetStatusDto, + @GetUser() user: User, + ) { + return this.assetsService.updateStatus(id, dto, user?.id); + } + + @Post(':id/transfer') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Transfer an asset to another department and/or user' }) + @ApiBadRequestResponse({ + description: 'No transfer target given, or department/user does not exist', + }) + transfer( + @Param('id') id: string, + @Body() dto: TransferAssetDto, + @GetUser() user: User, + ) { + return this.assetsService.transfer(id, dto, user?.id); + } + @Patch('bulk/status') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Bulk update asset status' }) diff --git a/backend/src/assets/assets.module.ts b/backend/src/assets/assets.module.ts index bf74d90a..fa52279f 100644 --- a/backend/src/assets/assets.module.ts +++ b/backend/src/assets/assets.module.ts @@ -4,12 +4,16 @@ import { Asset } from './entities/asset.entity'; import { AssetsService } from './assets.service'; import { AssetsController } from './assets.controller'; import { AssetsLifecycleModule } from './assets-lifecycle.module'; +import { AssetHistoryModule } from './asset-history.module'; import { AuditLogsModule } from '../audit-logs/audit-logs.module'; +import { Department } from '../departments/entities/department.entity'; +import { User } from '../users/entities/user.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([Asset]), + TypeOrmModule.forFeature([Asset, Department, User]), AssetsLifecycleModule, + AssetHistoryModule, AuditLogsModule, ], providers: [AssetsService], diff --git a/backend/src/assets/assets.service.spec.ts b/backend/src/assets/assets.service.spec.ts index ecc58ed0..8a54ce30 100644 --- a/backend/src/assets/assets.service.spec.ts +++ b/backend/src/assets/assets.service.spec.ts @@ -7,6 +7,7 @@ import { AssetsService } from './assets.service'; import { Asset } from './entities/asset.entity'; import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service'; import { AuditLogsService } from '../audit-logs/audit-logs.service'; +import { AssetHistoryService } from './asset-history.service'; describe('AssetsService', () => { let service: AssetsService; @@ -35,6 +36,10 @@ describe('AssetsService', () => { provide: AuditLogsService, useValue: createMock(), }, + { + provide: AssetHistoryService, + useValue: createMock(), + }, { provide: DataSource, useValue: createMock(), diff --git a/backend/src/assets/assets.service.ts b/backend/src/assets/assets.service.ts index 7439313a..7b2bf0fa 100644 --- a/backend/src/assets/assets.service.ts +++ b/backend/src/assets/assets.service.ts @@ -6,12 +6,18 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { Asset } from './entities/asset.entity'; +import { Asset, ASSET_DETAIL_RELATIONS } from './entities/asset.entity'; import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service'; import { AuditLogsService } from '../audit-logs/audit-logs.service'; +import { AssetHistoryService } from './asset-history.service'; +import { AssetHistoryAction } from './entities/asset-history-event.entity'; +import { Department } from '../departments/entities/department.entity'; +import { User } from '../users/entities/user.entity'; import { BulkStatusDto } from './dto/bulk-status.dto'; import { BulkAssignDto } from './dto/bulk-assign.dto'; import { BulkDeleteDto } from './dto/bulk-delete.dto'; +import { UpdateAssetStatusDto } from './dto/update-asset-status.dto'; +import { TransferAssetDto } from './dto/transfer-asset.dto'; export interface BulkResult { succeeded: string[]; @@ -26,6 +32,7 @@ export class AssetsService { private readonly assetRepo: Repository, private readonly lifecycle: AssetLifecycleService, private readonly audit: AuditLogsService, + private readonly history: AssetHistoryService, private readonly dataSource: DataSource, private readonly eventEmitter: EventEmitter2, ) {} @@ -95,6 +102,173 @@ export class AssetsService { return this.assetRepo.softRemove(asset); } + /** Load an asset with the relations the frontend detail cache expects. */ + async findDetail(id: string) { + const asset = await this.assetRepo.findOne({ + where: { id }, + relations: ASSET_DETAIL_RELATIONS, + }); + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + return asset; + } + + async updateStatus(id: string, dto: UpdateAssetStatusDto, actorId?: string) { + const newStatus = this.lifecycle.normalizeStatus(dto.status); + + await this.dataSource.transaction(async (manager) => { + const asset = await manager.findOne(Asset, { where: { id } }); + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + + const previousStatus = asset.status; + if (previousStatus === newStatus) return; + + this.lifecycle.validateTransition(previousStatus as AssetStatus, newStatus); + + asset.status = newStatus; + await manager.save(asset); + + await this.history.record( + { + assetId: id, + action: AssetHistoryAction.STATUS_CHANGED, + description: dto.reason + ? `Status changed from ${previousStatus} to ${newStatus}: ${dto.reason}` + : `Status changed from ${previousStatus} to ${newStatus}`, + previousValue: { status: previousStatus }, + newValue: { status: newStatus }, + performedById: actorId, + }, + manager, + ); + + this.eventEmitter.emit('asset.status_changed', { + assetId: id, + departmentId: asset.departmentId, + previousStatus, + newStatus, + }); + this.audit.logAction('ASSET_STATUS_CHANGE', 'Asset', id, actorId, { + previousStatus, + newStatus, + reason: dto.reason, + }); + }); + + return this.findDetail(id); + } + + async transfer(id: string, dto: TransferAssetDto, actorId?: string) { + // `null` is a meaningful assignedToId (unassign), so test for presence not truthiness + const targetsDepartment = dto.departmentId !== undefined; + const targetsAssignee = dto.assignedToId !== undefined; + if (!targetsDepartment && !targetsAssignee) { + throw new BadRequestException( + 'At least one of departmentId or assignedToId is required', + ); + } + + const note = dto.note ?? dto.notes; + + await this.dataSource.transaction(async (manager) => { + const asset = await manager.findOne(Asset, { where: { id } }); + if (!asset) throw new NotFoundException(`Asset ${id} not found`); + + if (dto.departmentId) { + const department = await manager.findOne(Department, { + where: { id: dto.departmentId }, + }); + if (!department) { + throw new BadRequestException(`Department ${dto.departmentId} not found`); + } + } + if (dto.assignedToId) { + const user = await manager.findOne(User, { where: { id: dto.assignedToId } }); + if (!user) { + throw new BadRequestException(`User ${dto.assignedToId} not found`); + } + } + + const previous = { + departmentId: asset.departmentId ?? null, + assignedToId: asset.assignedToUserId ?? null, + status: asset.status, + }; + + if (dto.departmentId !== undefined) asset.departmentId = dto.departmentId; + if (targetsAssignee) { + const assignedToId = dto.assignedToId ?? null; + asset.assignedToUserId = assignedToId; + // Assigning a holder flips the asset to ASSIGNED; clearing it frees the asset. + const derivedStatus = assignedToId + ? AssetStatus.ASSIGNED + : AssetStatus.AVAILABLE; + if ( + previous.assignedToId !== assignedToId && + asset.status !== derivedStatus + ) { + this.lifecycle.validateTransition(asset.status as AssetStatus, derivedStatus); + asset.status = derivedStatus; + } + } + + await manager.save(asset); + + const next = { + departmentId: asset.departmentId ?? null, + assignedToId: asset.assignedToUserId ?? null, + status: asset.status, + }; + + await this.history.record( + { + assetId: id, + action: AssetHistoryAction.TRANSFERRED, + description: this.describeTransfer(previous, next, note), + previousValue: previous, + newValue: next, + performedById: actorId, + }, + manager, + ); + + this.eventEmitter.emit('asset.transferred', { + assetId: id, + previous, + new: next, + actorId, + }); + this.audit.logAction('ASSET_TRANSFER', 'Asset', id, actorId, { + previous, + new: next, + note, + }); + }); + + return this.findDetail(id); + } + + private describeTransfer( + previous: { departmentId: string | null; assignedToId: string | null }, + next: { departmentId: string | null; assignedToId: string | null }, + note?: string, + ) { + const parts: string[] = []; + if (previous.departmentId !== next.departmentId) { + parts.push( + `department ${previous.departmentId ?? 'none'} → ${next.departmentId ?? 'none'}`, + ); + } + if (previous.assignedToId !== next.assignedToId) { + parts.push( + `assignee ${previous.assignedToId ?? 'none'} → ${next.assignedToId ?? 'none'}`, + ); + } + const summary = parts.length + ? `Asset transferred: ${parts.join(', ')}` + : 'Asset transfer recorded with no field changes'; + return note ? `${summary}. Note: ${note}` : summary; + } + async bulkStatus(dto: BulkStatusDto, actorId?: string): Promise { const succeeded: string[] = []; const failed: { id: string; reason: string }[] = []; diff --git a/backend/src/assets/dto/transfer-asset.dto.ts b/backend/src/assets/dto/transfer-asset.dto.ts new file mode 100644 index 00000000..090cc23d --- /dev/null +++ b/backend/src/assets/dto/transfer-asset.dto.ts @@ -0,0 +1,36 @@ +import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class TransferAssetDto { + @ApiPropertyOptional({ description: 'Destination department id' }) + @IsOptional() + @IsString() + @IsNotEmpty({ message: 'departmentId must not be empty' }) + departmentId?: string; + + /** + * A user id assigns the asset (flipping it to ASSIGNED); `null` clears the + * assignment (reverting it to AVAILABLE). + */ + @ApiPropertyOptional({ + description: 'Destination user id, or null to unassign', + nullable: true, + }) + @IsOptional() + @IsString() + @IsNotEmpty({ message: 'assignedToId must not be empty' }) + assignedToId?: string | null; + + @ApiPropertyOptional({ description: 'Transfer note' }) + @IsOptional() + @IsString() + @MaxLength(1000) + note?: string; + + /** Alias accepted for the frontend's TransferAssetInput.notes field. */ + @ApiPropertyOptional({ description: 'Alias of note' }) + @IsOptional() + @IsString() + @MaxLength(1000) + notes?: string; +} diff --git a/backend/src/assets/dto/update-asset-status.dto.ts b/backend/src/assets/dto/update-asset-status.dto.ts new file mode 100644 index 00000000..16a38a06 --- /dev/null +++ b/backend/src/assets/dto/update-asset-status.dto.ts @@ -0,0 +1,17 @@ +import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { AssetStatusInput } from '../asset-lifecycle.service'; + +export class UpdateAssetStatusDto { + @ApiProperty({ enum: AssetStatusInput, description: 'Target asset status' }) + @IsEnum(AssetStatusInput, { + message: `status must be one of: ${Object.values(AssetStatusInput).join(', ')}`, + }) + status: AssetStatusInput; + + @ApiPropertyOptional({ description: 'Why the status changed' }) + @IsOptional() + @IsString() + @MaxLength(1000) + reason?: string; +} diff --git a/backend/src/assets/entities/asset-history-event.entity.ts b/backend/src/assets/entities/asset-history-event.entity.ts new file mode 100644 index 00000000..9529504a --- /dev/null +++ b/backend/src/assets/entities/asset-history-event.entity.ts @@ -0,0 +1,52 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum AssetHistoryAction { + CREATED = 'CREATED', + UPDATED = 'UPDATED', + STATUS_CHANGED = 'STATUS_CHANGED', + TRANSFERRED = 'TRANSFERRED', + MAINTENANCE = 'MAINTENANCE', + NOTE_ADDED = 'NOTE_ADDED', + DOCUMENT_UPLOADED = 'DOCUMENT_UPLOADED', +} + +@Entity('asset_history_events') +export class AssetHistoryEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + assetId: string; + + @Column({ type: 'enum', enum: AssetHistoryAction }) + action: AssetHistoryAction; + + @Column({ type: 'text' }) + description: string; + + @Column({ type: 'jsonb', nullable: true }) + previousValue?: Record | null; + + @Column({ type: 'jsonb', nullable: true }) + newValue?: Record | null; + + @Column({ nullable: true }) + performedById?: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'performedById' }) + performedBy?: User | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/assets/entities/asset.entity.ts b/backend/src/assets/entities/asset.entity.ts index b8d2d7aa..645f034d 100644 --- a/backend/src/assets/entities/asset.entity.ts +++ b/backend/src/assets/entities/asset.entity.ts @@ -6,7 +6,21 @@ import { UpdateDateColumn, DeleteDateColumn, Index, + ManyToOne, + JoinColumn, } from 'typeorm'; +import { Category } from '../../categories/entities/category.entity'; +import { Department } from '../../departments/entities/department.entity'; +import { Location } from '../../locations/entities/location.entity'; +import { User } from '../../users/entities/user.entity'; + +/** Relations loaded whenever a single asset is returned to the frontend. */ +export const ASSET_DETAIL_RELATIONS = [ + 'category', + 'department', + 'location', + 'assignedTo', +]; @Entity('assets') export class Asset { @@ -32,12 +46,28 @@ export class Asset { @Column({ nullable: true }) locationId?: string; - @Column({ nullable: true }) - assignedToUserId?: string; + @Column({ nullable: true, type: 'varchar' }) + assignedToUserId?: string | null; @Column({ nullable: true }) branchId?: string; + @ManyToOne(() => Category, { nullable: true }) + @JoinColumn({ name: 'categoryId' }) + category?: Category | null; + + @ManyToOne(() => Department, { nullable: true }) + @JoinColumn({ name: 'departmentId' }) + department?: Department | null; + + @ManyToOne(() => Location, { nullable: true }) + @JoinColumn({ name: 'locationId' }) + location?: Location | null; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'assignedToUserId' }) + assignedTo?: User | null; + @Column({ default: 'AVAILABLE' }) status: string; diff --git a/frontend/lib/query/types/asset.ts b/frontend/lib/query/types/asset.ts index e67dec61..323fc463 100644 --- a/frontend/lib/query/types/asset.ts +++ b/frontend/lib/query/types/asset.ts @@ -125,11 +125,14 @@ export interface AssetNote { // Input types for mutations export interface UpdateAssetStatusInput { status: AssetStatus; + reason?: string; } +/** At least one of `departmentId` or `assignedToId` must be provided. */ export interface TransferAssetInput { - departmentId: string; - assignedToId?: string; + departmentId?: string; + /** A user id assigns the asset; `null` clears the assignment. */ + assignedToId?: string | null; location?: string; notes?: string; }