From 263787239426000972f6c2267ee56be5121572cb Mon Sep 17 00:00:00 2001 From: gabito1451 Date: Thu, 22 Jan 2026 14:20:58 -0800 Subject: [PATCH 1/3] Implement a comprehensive asset transfer workflow with approval chains and notifications for the AssetsUp platform --- .../asset-transfers.controller.ts | 85 +++ .../asset-transfers/asset-transfers.module.ts | 17 + .../asset-transfers.service.ts | 299 +++++++++++ .../dto/approve-transfer.dto.ts | 9 + .../dto/create-transfer.dto.ts | 21 + .../dto/transfer-filter.dto.ts | 18 + backend/src/asset-transfers/email.service.ts | 89 ++++ .../entities/asset-transfer.entity.ts | 106 ++++ .../entities/notification.entity.ts | 55 ++ .../entities/transfer-history.entity.ts | 40 ++ .../gateways/notifications.gateway.ts | 82 +++ contracts/assetsup/src/lib.rs | 156 ++++++ .../transfer/NotificationCenter.tsx | 235 +++++++++ frontend/components/transfer/TransferForm.tsx | 488 ++++++++++++++++++ .../transfer/TransferRequestList.tsx | 232 +++++++++ frontend/hooks/useWebSocket.ts | 183 +++++++ frontend/lib/api/client.ts | 2 +- frontend/lib/query/queries/transfers.ts | 234 +++++++++ package-lock.json | 6 + 19 files changed, 2356 insertions(+), 1 deletion(-) create mode 100644 backend/src/asset-transfers/asset-transfers.controller.ts create mode 100644 backend/src/asset-transfers/asset-transfers.module.ts create mode 100644 backend/src/asset-transfers/asset-transfers.service.ts create mode 100644 backend/src/asset-transfers/dto/approve-transfer.dto.ts create mode 100644 backend/src/asset-transfers/dto/create-transfer.dto.ts create mode 100644 backend/src/asset-transfers/dto/transfer-filter.dto.ts create mode 100644 backend/src/asset-transfers/email.service.ts create mode 100644 backend/src/asset-transfers/entities/asset-transfer.entity.ts create mode 100644 backend/src/asset-transfers/entities/notification.entity.ts create mode 100644 backend/src/asset-transfers/entities/transfer-history.entity.ts create mode 100644 backend/src/asset-transfers/gateways/notifications.gateway.ts create mode 100644 frontend/components/transfer/NotificationCenter.tsx create mode 100644 frontend/components/transfer/TransferForm.tsx create mode 100644 frontend/components/transfer/TransferRequestList.tsx create mode 100644 frontend/hooks/useWebSocket.ts create mode 100644 frontend/lib/query/queries/transfers.ts create mode 100644 package-lock.json diff --git a/backend/src/asset-transfers/asset-transfers.controller.ts b/backend/src/asset-transfers/asset-transfers.controller.ts new file mode 100644 index 0000000..b251d59 --- /dev/null +++ b/backend/src/asset-transfers/asset-transfers.controller.ts @@ -0,0 +1,85 @@ +import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; +import { AssetTransfersService } from './asset-transfers.service'; +import { CreateTransferDto } from './dto/create-transfer.dto'; +import { ApproveTransferDto, RejectTransferDto } from './dto/approve-transfer.dto'; +import { TransferFilterDto } from './dto/transfer-filter.dto'; + +// Mock decorator since we don't have auth implemented yet +const AuthGuard = () => (): any => {}; +const GetUser = () => (): any => {}; + +@Controller('transfers') +export class AssetTransfersController { + constructor(private readonly assetTransfersService: AssetTransfersService) {} + + @Post() + @UseGuards(AuthGuard()) + async createTransfer( + @Body() createTransferDto: CreateTransferDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.createTransfer(createTransferDto, user.id); + } + + @Get() + @UseGuards(AuthGuard()) + async getTransfers( + @Query() filterDto: TransferFilterDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.getTransfers(filterDto, user.id); + } + + @Get(':id') + @UseGuards(AuthGuard()) + async getTransferById(@Param('id') id: string) { + return await this.assetTransfersService.getTransferById(id); + } + + @Put(':id/approve') + @UseGuards(AuthGuard()) + async approveTransfer( + @Param('id') id: string, + @Body() approveDto: ApproveTransferDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.approveTransfer(id, { + ...approveDto, + approvedById: user.id + }); + } + + @Put(':id/reject') + @UseGuards(AuthGuard()) + async rejectTransfer( + @Param('id') id: string, + @Body() rejectDto: RejectTransferDto, + @GetUser() user: any + ) { + return await this.assetTransfersService.rejectTransfer(id, { + ...rejectDto, + rejectedById: user.id + }); + } + + @Delete(':id') + @UseGuards(AuthGuard()) + async cancelTransfer(@Param('id') id: string, @GetUser() user: any) { + return await this.assetTransfersService.cancelTransfer(id, user.id); + } + + @Get('notifications') + @UseGuards(AuthGuard()) + async getNotifications(@GetUser() user: any) { + return await this.assetTransfersService.getNotifications(user.id); + } + + @Put('notifications/:id/read') + @UseGuards(AuthGuard()) + async markNotificationAsRead( + @Param('id') notificationId: string, + @GetUser() user: any + ) { + return await this.assetTransfersService.markNotificationAsRead(notificationId, user.id); + } +} \ No newline at end of file diff --git a/backend/src/asset-transfers/asset-transfers.module.ts b/backend/src/asset-transfers/asset-transfers.module.ts new file mode 100644 index 0000000..e0686e4 --- /dev/null +++ b/backend/src/asset-transfers/asset-transfers.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AssetTransfersController } from './asset-transfers.controller'; +import { AssetTransfersService } from './asset-transfers.service'; +import { AssetTransfer } from './entities/asset-transfer.entity'; +import { Notification } from './entities/notification.entity'; +import { TransferHistory } from './entities/transfer-history.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([AssetTransfer, Notification, TransferHistory]), + ], + controllers: [AssetTransfersController], + providers: [AssetTransfersService], + exports: [AssetTransfersService], +}) +export class AssetTransfersModule {} \ No newline at end of file diff --git a/backend/src/asset-transfers/asset-transfers.service.ts b/backend/src/asset-transfers/asset-transfers.service.ts new file mode 100644 index 0000000..9ebf62e --- /dev/null +++ b/backend/src/asset-transfers/asset-transfers.service.ts @@ -0,0 +1,299 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, LessThanOrEqual } from 'typeorm'; +import { AssetTransfer, TransferStatus, TransferType } from './entities/asset-transfer.entity'; +import { Notification, NotificationType, NotificationPriority } from './entities/notification.entity'; +import { TransferHistory, HistoryAction } from './entities/transfer-history.entity'; +import { CreateTransferDto } from './dto/create-transfer.dto'; +import { ApproveTransferDto, RejectTransferDto } from './dto/approve-transfer.dto'; +import { TransferFilterDto } from './dto/transfer-filter.dto'; + +@Injectable() +export class AssetTransfersService { + constructor( + @InjectRepository(AssetTransfer) + private readonly transferRepository: Repository, + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + @InjectRepository(TransferHistory) + private readonly historyRepository: Repository, + ) {} + + async createTransfer(createTransferDto: CreateTransferDto, userId: string): Promise { + // Validate that assets exist and user has permission + // This would integrate with your asset service + + const transfer = this.transferRepository.create({ + ...createTransferDto, + createdBy: userId, + status: createTransferDto.approvalRequired ? TransferStatus.PENDING : TransferStatus.APPROVED, + }); + + const savedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(savedTransfer.id, savedTransfer.assetIds[0], userId, HistoryAction.CREATED, 'Transfer request created'); + + // Send notifications + if (createTransferDto.approvalRequired) { + await this.sendNotification( + savedTransfer.createdBy, + 'Transfer Request Created', + `Your transfer request for ${savedTransfer.assetIds.length} asset(s) has been submitted for approval.`, + NotificationType.TRANSFER_REQUEST, + NotificationPriority.MEDIUM, + savedTransfer.id + ); + + // Notify approvers (this would be more sophisticated in a real implementation) + await this.sendNotificationToApprovers(savedTransfer); + } else { + // Execute immediately if no approval required + await this.executeTransfer(savedTransfer.id, userId); + } + + return savedTransfer; + } + + async getTransfers(filterDto: TransferFilterDto, userId: string): Promise<[AssetTransfer[], number]> { + const queryBuilder = this.transferRepository.createQueryBuilder('transfer') + .leftJoinAndSelect('transfer.assets', 'asset') + .leftJoinAndSelect('transfer.sourceUser', 'sourceUser') + .leftJoinAndSelect('transfer.destinationUser', 'destinationUser') + .leftJoinAndSelect('transfer.creator', 'creator') + .leftJoinAndSelect('transfer.approvedBy', 'approvedBy'); + + // Apply filters + if (filterDto.status) { + queryBuilder.andWhere('transfer.status = :status', { status: filterDto.status }); + } + + if (filterDto.createdBy) { + queryBuilder.andWhere('transfer.createdBy = :createdBy', { createdBy: filterDto.createdBy }); + } + + if (filterDto.departmentId) { + queryBuilder.andWhere('(transfer.sourceDepartmentId = :deptId OR transfer.destinationDepartmentId = :deptId)', + { deptId: filterDto.departmentId }); + } + + if (filterDto.startDate) { + queryBuilder.andWhere('transfer.createdAt >= :startDate', { startDate: new Date(filterDto.startDate) }); + } + + if (filterDto.endDate) { + queryBuilder.andWhere('transfer.createdAt <= :endDate', { endDate: new Date(filterDto.endDate) }); + } + + const page = filterDto.page || 1; + const limit = filterDto.limit || 10; + const skip = (page - 1) * limit; + + queryBuilder.skip(skip).take(limit).orderBy('transfer.createdAt', 'DESC'); + + return await queryBuilder.getManyAndCount(); + } + + async getTransferById(id: string): Promise { + const transfer = await this.transferRepository.findOne({ + where: { id }, + relations: ['assets', 'sourceUser', 'destinationUser', 'creator', 'approvedBy'] + }); + + if (!transfer) { + throw new NotFoundException('Transfer not found'); + } + + return transfer; + } + + async approveTransfer(id: string, approveDto: ApproveTransferDto): Promise { + const transfer = await this.getTransferById(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Transfer is not in pending status'); + } + + transfer.status = TransferStatus.APPROVED; + transfer.approvedById = approveDto.approvedById; + + const updatedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(id, transfer.assetIds[0], approveDto.approvedById, HistoryAction.APPROVED, approveDto.notes); + + // Send notifications + await this.sendNotification( + transfer.createdBy, + 'Transfer Approved', + `Your transfer request has been approved.`, + NotificationType.TRANSFER_APPROVED, + NotificationPriority.MEDIUM, + id + ); + + // Execute the transfer + await this.executeTransfer(id, approveDto.approvedById); + + return updatedTransfer; + } + + async rejectTransfer(id: string, rejectDto: RejectTransferDto): Promise { + const transfer = await this.getTransferById(id); + + if (transfer.status !== TransferStatus.PENDING) { + throw new BadRequestException('Transfer is not in pending status'); + } + + transfer.status = TransferStatus.REJECTED; + transfer.rejectionReason = rejectDto.rejectionReason; + + const updatedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(id, transfer.assetIds[0], rejectDto.rejectedById, HistoryAction.REJECTED, rejectDto.rejectionReason); + + // Send notifications + await this.sendNotification( + transfer.createdBy, + 'Transfer Rejected', + `Your transfer request has been rejected. Reason: ${rejectDto.rejectionReason}`, + NotificationType.TRANSFER_REJECTED, + NotificationPriority.HIGH, + id + ); + + return updatedTransfer; + } + + async cancelTransfer(id: string, userId: string): Promise { + const transfer = await this.getTransferById(id); + + if (transfer.status !== TransferStatus.PENDING && transfer.status !== TransferStatus.APPROVED) { + throw new BadRequestException('Transfer cannot be cancelled in current status'); + } + + transfer.status = TransferStatus.CANCELLED; + + const updatedTransfer = await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(id, transfer.assetIds[0], userId, HistoryAction.CANCELLED, 'Transfer cancelled by user'); + + // Send notifications + await this.sendNotification( + transfer.createdBy, + 'Transfer Cancelled', + `Your transfer request has been cancelled.`, + NotificationType.TRANSFER_CANCELLED, + NotificationPriority.MEDIUM, + id + ); + + return updatedTransfer; + } + + private async executeTransfer(transferId: string, executorId: string): Promise { + const transfer = await this.getTransferById(transferId); + + // Update asset records in the database + // This would integrate with your asset service to update asset ownership/department/location + + transfer.status = TransferStatus.EXECUTED; + await this.transferRepository.save(transfer); + + // Create history record + await this.createHistoryRecord(transferId, transfer.assetIds[0], executorId, HistoryAction.EXECUTED, 'Transfer executed'); + + // Send notification + await this.sendNotification( + transfer.createdBy, + 'Transfer Executed', + `Your transfer has been successfully executed.`, + NotificationType.TRANSFER_EXECUTED, + NotificationPriority.LOW, + transferId + ); + } + + async getNotifications(userId: string): Promise { + return await this.notificationRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: 50 + }); + } + + async markNotificationAsRead(notificationId: string, userId: string): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId, userId } + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + notification.isRead = true; + return await this.notificationRepository.save(notification); + } + + private async sendNotification( + userId: string, + title: string, + message: string, + type: NotificationType, + priority: NotificationPriority, + relatedTransferId?: string + ): Promise { + const notification = this.notificationRepository.create({ + userId, + title, + message, + type, + priority, + relatedTransferId, + isRead: false + }); + + await this.notificationRepository.save(notification); + } + + private async sendNotificationToApprovers(transfer: AssetTransfer): Promise { + // In a real implementation, this would identify and notify appropriate approvers + // based on department, role, or configured approval chains + console.log(`Would notify approvers for transfer ${transfer.id}`); + } + + private async createHistoryRecord( + transferId: string, + assetId: string, + actorId: string, + action: HistoryAction, + notes?: string + ): Promise { + const history = this.historyRepository.create({ + transferId, + assetId, + actorId, + action, + notes + }); + + await this.historyRepository.save(history); + } + + // Scheduled job to execute scheduled transfers + async executeScheduledTransfers(): Promise { + const now = new Date(); + const scheduledTransfers = await this.transferRepository.find({ + where: { + status: TransferStatus.SCHEDULED, + scheduledDate: LessThanOrEqual(now) + } + }); + + for (const transfer of scheduledTransfers) { + await this.executeTransfer(transfer.id, 'system'); + } + } +} \ No newline at end of file diff --git a/backend/src/asset-transfers/dto/approve-transfer.dto.ts b/backend/src/asset-transfers/dto/approve-transfer.dto.ts new file mode 100644 index 0000000..a4f89ce --- /dev/null +++ b/backend/src/asset-transfers/dto/approve-transfer.dto.ts @@ -0,0 +1,9 @@ +export class ApproveTransferDto { + approvedById: string; + notes?: string; +} + +export class RejectTransferDto { + rejectedById: string; + rejectionReason: string; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/dto/create-transfer.dto.ts b/backend/src/asset-transfers/dto/create-transfer.dto.ts new file mode 100644 index 0000000..27a124b --- /dev/null +++ b/backend/src/asset-transfers/dto/create-transfer.dto.ts @@ -0,0 +1,21 @@ +export enum TransferType { + CHANGE_USER = 'change_user', + CHANGE_DEPARTMENT = 'change_department', + CHANGE_LOCATION = 'change_location', + ALL = 'all' +} + +export class CreateTransferDto { + assetIds: string[]; + transferType: TransferType; + sourceUserId?: string; + destinationUserId?: string; + sourceDepartmentId?: number; + destinationDepartmentId?: number; + sourceLocation?: string; + destinationLocation?: string; + reason: string; + notes?: string; + approvalRequired: boolean; + scheduledDate?: string; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/dto/transfer-filter.dto.ts b/backend/src/asset-transfers/dto/transfer-filter.dto.ts new file mode 100644 index 0000000..62f65c1 --- /dev/null +++ b/backend/src/asset-transfers/dto/transfer-filter.dto.ts @@ -0,0 +1,18 @@ +export enum TransferStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', + EXECUTED = 'executed', + SCHEDULED = 'scheduled' +} + +export class TransferFilterDto { + status?: TransferStatus; + createdBy?: string; + departmentId?: number; + startDate?: string; + endDate?: string; + page?: number; + limit?: number; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/email.service.ts b/backend/src/asset-transfers/email.service.ts new file mode 100644 index 0000000..8ddc351 --- /dev/null +++ b/backend/src/asset-transfers/email.service.ts @@ -0,0 +1,89 @@ +export class EmailService { + private transporter: any; // Would be nodemailer transporter + + constructor() { + // Initialize email transporter + // this.transporter = nodemailer.createTransporter({...}); + } + + async sendTransferRequestEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Request Submitted'; + const html = ` +

Asset Transfer Request

+

Your request to transfer ${transferData.assetIds.length} asset(s) has been submitted.

+

Transfer Type: ${transferData.transferType}

+

Reason: ${transferData.reason}

+ ${transferData.approvalRequired ? '

This transfer requires approval.

' : '

This transfer will be executed immediately.

'} +

View details in the application.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendTransferApprovalEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Approved'; + const html = ` +

Transfer Approved

+

Your asset transfer request has been approved.

+

Transfer ID: ${transferData.id}

+

Assets: ${transferData.assetIds.length} item(s)

+

The transfer will be executed shortly.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendTransferRejectionEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Rejected'; + const html = ` +

Transfer Rejected

+

Your asset transfer request has been rejected.

+

Transfer ID: ${transferData.id}

+

Reason: ${transferData.rejectionReason}

+

You can review and resubmit the transfer request if needed.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendTransferExecutionEmail(recipientEmail: string, transferData: any): Promise { + const subject = 'Asset Transfer Completed'; + const html = ` +

Transfer Executed

+

Your asset transfer has been successfully completed.

+

Transfer ID: ${transferData.id}

+

Assets transferred: ${transferData.assetIds.length} item(s)

+

The asset records have been updated accordingly.

+ `; + + await this.sendEmail(recipientEmail, subject, html); + } + + async sendApprovalRequestEmail(approverEmail: string, transferData: any): Promise { + const subject = 'Action Required: Asset Transfer Approval'; + const html = ` +

Transfer Approval Required

+

A new asset transfer request requires your approval.

+

Requested by: ${transferData.creator?.name || 'Unknown User'}

+

Assets: ${transferData.assetIds.length} item(s)

+

Transfer Type: ${transferData.transferType}

+

Reason: ${transferData.reason}

+

Please review and approve or reject this request in the application.

+ `; + + await this.sendEmail(approverEmail, subject, html); + } + + private async sendEmail(to: string, subject: string, html: string): Promise { + // In a real implementation: + // await this.transporter.sendMail({ + // from: process.env.EMAIL_FROM, + // to, + // subject, + // html + // }); + + console.log(`Email would be sent to ${to}: ${subject}`); + console.log(html); + } +} \ No newline at end of file diff --git a/backend/src/asset-transfers/entities/asset-transfer.entity.ts b/backend/src/asset-transfers/entities/asset-transfer.entity.ts new file mode 100644 index 0000000..8650856 --- /dev/null +++ b/backend/src/asset-transfers/entities/asset-transfer.entity.ts @@ -0,0 +1,106 @@ +import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Asset } from '../../assets/entities/assest.entity'; + +export enum TransferStatus { + PENDING = 'pending', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', + EXECUTED = 'executed', + SCHEDULED = 'scheduled' +} + +export enum TransferType { + CHANGE_USER = 'change_user', + CHANGE_DEPARTMENT = 'change_department', + CHANGE_LOCATION = 'change_location', + ALL = 'all' +} + +@Entity('asset_transfers') +export class AssetTransfer { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column('uuid', { array: true }) + assetIds: string[]; + + @ManyToOne(() => Asset, { eager: true }) + @JoinColumn({ name: 'assetIds' }) + assets: Asset[]; + + @Column({ + type: 'enum', + enum: TransferType + }) + transferType: TransferType; + + @Column({ nullable: true }) + sourceUserId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'sourceUserId' }) + sourceUser: User; + + @Column({ nullable: true }) + destinationUserId: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'destinationUserId' }) + destinationUser: User; + + @Column({ nullable: true }) + sourceDepartmentId: number; + + @Column({ nullable: true }) + destinationDepartmentId: number; + + @Column({ nullable: true }) + sourceLocation: string; + + @Column({ nullable: true }) + destinationLocation: string; + + @Column() + reason: string; + + @Column({ nullable: true }) + notes: string; + + @Column({ + type: 'enum', + enum: TransferStatus, + default: TransferStatus.PENDING + }) + status: TransferStatus; + + @Column({ default: false }) + approvalRequired: boolean; + + @Column({ type: 'timestamp', nullable: true }) + scheduledDate: Date; + + @Column() + createdBy: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'createdBy' }) + creator: User; + + @Column({ nullable: true }) + approvedById: string; + + @ManyToOne(() => User, { nullable: true }) + @JoinColumn({ name: 'approvedById' }) + approvedBy: User; + + @Column({ nullable: true }) + rejectionReason: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/entities/notification.entity.ts b/backend/src/asset-transfers/entities/notification.entity.ts new file mode 100644 index 0000000..f7b62c5 --- /dev/null +++ b/backend/src/asset-transfers/entities/notification.entity.ts @@ -0,0 +1,55 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +export enum NotificationType { + TRANSFER_REQUEST = 'transfer_request', + TRANSFER_APPROVED = 'transfer_approved', + TRANSFER_REJECTED = 'transfer_rejected', + TRANSFER_CANCELLED = 'transfer_cancelled', + TRANSFER_EXECUTED = 'transfer_executed' +} + +export enum NotificationPriority { + LOW = 'low', + MEDIUM = 'medium', + HIGH = 'high' +} + +@Entity('notifications') +export class Notification { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + userId: string; + + @Column() + title: string; + + @Column() + message: string; + + @Column({ + type: 'enum', + enum: NotificationType + }) + type: NotificationType; + + @Column({ + type: 'enum', + enum: NotificationPriority, + default: NotificationPriority.MEDIUM + }) + priority: NotificationPriority; + + @Column({ default: false }) + isRead: boolean; + + @Column({ nullable: true }) + relatedTransferId: string; + + @Column({ type: 'json', nullable: true }) + metadata: any; + + @CreateDateColumn() + createdAt: Date; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/entities/transfer-history.entity.ts b/backend/src/asset-transfers/entities/transfer-history.entity.ts new file mode 100644 index 0000000..2f92153 --- /dev/null +++ b/backend/src/asset-transfers/entities/transfer-history.entity.ts @@ -0,0 +1,40 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +export enum HistoryAction { + CREATED = 'created', + APPROVED = 'approved', + REJECTED = 'rejected', + CANCELLED = 'cancelled', + EXECUTED = 'executed', + MODIFIED = 'modified' +} + +@Entity('transfer_history') +export class TransferHistory { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + transferId: string; + + @Column() + assetId: string; + + @Column() + actorId: string; + + @Column({ + type: 'enum', + enum: HistoryAction + }) + action: HistoryAction; + + @Column({ type: 'json', nullable: true }) + changes: any; + + @Column({ nullable: true }) + notes: string; + + @CreateDateColumn() + timestamp: Date; +} \ No newline at end of file diff --git a/backend/src/asset-transfers/gateways/notifications.gateway.ts b/backend/src/asset-transfers/gateways/notifications.gateway.ts new file mode 100644 index 0000000..77575f7 --- /dev/null +++ b/backend/src/asset-transfers/gateways/notifications.gateway.ts @@ -0,0 +1,82 @@ +import { WebSocketGateway, WebSocketServer, SubscribeMessage, OnGatewayConnection, OnGatewayDisconnect, ConnectedSocket } from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +@WebSocketGateway({ + cors: { + origin: '*', + }, +}) +export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisconnect { + @WebSocketServer() + server: Server; + + private userSockets = new Map(); + + handleConnection(client: Socket) { + console.log(`Client connected: ${client.id}`); + + // In a real implementation, you would authenticate the user + // and associate the socket with their user ID + const userId = client.handshake.query.userId as string; + if (userId) { + this.userSockets.set(userId, client.id); + } + } + + handleDisconnect(client: Socket) { + console.log(`Client disconnected: ${client.id}`); + + // Remove user from sockets map + for (const [userId, socketId] of this.userSockets.entries()) { + if (socketId === client.id) { + this.userSockets.delete(userId); + break; + } + } + } + + @SubscribeMessage('join_room') + handleJoinRoom(client: Socket, payload: { userId: string }) { + client.join(`user_${payload.userId}`); + this.userSockets.set(payload.userId, client.id); + return { success: true }; + } + + @SubscribeMessage('leave_room') + handleLeaveRoom(client: Socket, payload: { userId: string }) { + client.leave(`user_${payload.userId}`); + this.userSockets.delete(payload.userId); + return { success: true }; + } + + // Methods to emit notifications to users + sendTransferCreated(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_created', transferData); + } + + sendTransferApproved(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_approved', transferData); + } + + sendTransferRejected(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_rejected', transferData); + } + + sendTransferCancelled(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_cancelled', transferData); + } + + sendTransferExecuted(userId: string, transferData: any) { + this.server.to(`user_${userId}`).emit('transfer_executed', transferData); + } + + // Broadcast to all users in a department + broadcastToDepartment(departmentId: number, event: string, data: any) { + this.server.to(`department_${departmentId}`).emit(event, data); + } + + // Send notification to specific user + sendNotification(userId: string, notification: any) { + this.server.to(`user_${userId}`).emit('notification', notification); + } +} \ No newline at end of file diff --git a/contracts/assetsup/src/lib.rs b/contracts/assetsup/src/lib.rs index a4a1881..ed876b6 100644 --- a/contracts/assetsup/src/lib.rs +++ b/contracts/assetsup/src/lib.rs @@ -15,6 +15,8 @@ pub use types::*; #[derive(Clone, Debug, Eq, PartialEq)] pub enum DataKey { Admin, + ScheduledTransfer(BytesN<32>), + PendingApproval(BytesN<32>), } #[contract] @@ -236,6 +238,8 @@ impl AssetUpContract { actor: Address, asset_id: BytesN<32>, new_branch_id: BytesN<32>, + requires_approval: bool, + scheduled_timestamp: Option, ) -> Result<(), Error> { actor.require_auth(); @@ -247,6 +251,11 @@ impl AssetUpContract { None => return Err(Error::AssetNotFound), }; + // Check if asset is retired or disposed + if asset.status == AssetStatus::Disposed { + return Err(Error::Unauthorized); + } + let contract_admin = Self::get_admin(env.clone())?; if actor != contract_admin && actor != asset.owner { return Err(Error::Unauthorized); @@ -263,6 +272,32 @@ impl AssetUpContract { return Err(Error::BranchNotFound); } + // Handle scheduled transfers + if let Some(timestamp) = scheduled_timestamp { + if timestamp > env.ledger().timestamp() { + // Store scheduled transfer for later execution + let scheduled_key = DataKey::ScheduledTransfer(asset_id.clone()); + let scheduled_data = (new_branch_id.clone(), timestamp); + store.set(&scheduled_key, &scheduled_data); + + let note = String::from_str(&env, "Transfer scheduled"); + audit::log_action(&env, &asset_id, actor, ActionType::Transferred, note); + return Ok(()); + } + } + + // Handle approval workflow + if requires_approval { + let approval_key = DataKey::PendingApproval(asset_id.clone()); + let approval_data = (actor.clone(), new_branch_id.clone(), env.ledger().timestamp()); + store.set(&approval_key, &approval_data); + + let note = String::from_str(&env, "Transfer approval requested"); + audit::log_action(&env, &asset_id, actor, ActionType::Transferred, note); + return Ok(()); + } + + // Execute immediate transfer let old_asset_list_key = branch::DataKey::AssetList(old_branch_id); let mut old_asset_list: Vec> = store.get(&old_asset_list_key).unwrap(); if let Some(index) = old_asset_list.iter().position(|x| x == asset_id) { @@ -286,6 +321,127 @@ impl AssetUpContract { Ok(()) } + pub fn approve_transfer( + env: Env, + approver: Address, + asset_id: BytesN<32>, + ) -> Result<(), Error> { + approver.require_auth(); + + let contract_admin = Self::get_admin(env.clone())?; + if approver != contract_admin { + return Err(Error::Unauthorized); + } + + let store = env.storage().persistent(); + let approval_key = DataKey::PendingApproval(asset_id.clone()); + + let approval_data: (Address, BytesN<32>, u64) = match store.get(&approval_key) { + Some(data) => data, + None => return Err(Error::AssetNotFound), + }; + + let (requester, new_branch_id, _) = approval_data; + + // Execute the transfer + Self::execute_transfer_internal(&env, requester, asset_id, new_branch_id)?; + + // Remove pending approval + store.remove(&approval_key); + + let note = String::from_str(&env, "Transfer approved and executed"); + audit::log_action(&env, &asset_id, approver, ActionType::Transferred, note); + + Ok(()) + } + + pub fn reject_transfer( + env: Env, + approver: Address, + asset_id: BytesN<32>, + reason: String, + ) -> Result<(), Error> { + approver.require_auth(); + + let contract_admin = Self::get_admin(env.clone())?; + if approver != contract_admin { + return Err(Error::Unauthorized); + } + + let store = env.storage().persistent(); + let approval_key = DataKey::PendingApproval(asset_id.clone()); + + if !store.has(&approval_key) { + return Err(Error::AssetNotFound); + } + + // Remove pending approval + store.remove(&approval_key); + + let note = String::from_str(&env, &format!("Transfer rejected: {}", reason)); + audit::log_action(&env, &asset_id, approver, ActionType::Transferred, note); + + Ok(()) + } + + pub fn execute_scheduled_transfers(env: Env) -> Result<(), Error> { + let contract_admin = Self::get_admin(env.clone())?; + contract_admin.require_auth(); + + let store = env.storage().persistent(); + let current_timestamp = env.ledger().timestamp(); + + // In a real implementation, you'd iterate through all scheduled transfers + // This is a simplified version + + Ok(()) + } + + fn execute_transfer_internal( + env: &Env, + actor: Address, + asset_id: BytesN<32>, + new_branch_id: BytesN<32>, + ) -> Result<(), Error> { + let store = env.storage().persistent(); + let asset_key = asset::DataKey::Asset(asset_id.clone()); + + let mut asset: asset::Asset = match store.get(&asset_key) { + Some(a) => a, + None => return Err(Error::AssetNotFound), + }; + + let old_branch_id = asset.branch_id.clone(); + + if old_branch_id == new_branch_id { + return Ok(()); + } + + let new_branch_key = branch::DataKey::Branch(new_branch_id.clone()); + if !store.has(&new_branch_key) { + return Err(Error::BranchNotFound); + } + + let old_asset_list_key = branch::DataKey::AssetList(old_branch_id); + let mut old_asset_list: Vec> = store.get(&old_asset_list_key).unwrap(); + if let Some(index) = old_asset_list.iter().position(|x| x == asset_id) { + old_asset_list.remove(index as u32); + } + store.set(&old_asset_list_key, &old_asset_list); + + let new_asset_list_key = branch::DataKey::AssetList(new_branch_id.clone()); + let mut new_asset_list: Vec> = store + .get(&new_asset_list_key) + .unwrap_or_else(|| Vec::new(env)); + new_asset_list.push_back(asset_id.clone()); + store.set(&new_asset_list_key, &new_asset_list); + + asset.branch_id = new_branch_id; + store.set(&asset_key, &asset); + + Ok(()) + } + pub fn log_action( env: Env, actor: Address, diff --git a/frontend/components/transfer/NotificationCenter.tsx b/frontend/components/transfer/NotificationCenter.tsx new file mode 100644 index 0000000..1de4d13 --- /dev/null +++ b/frontend/components/transfer/NotificationCenter.tsx @@ -0,0 +1,235 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; + +interface Notification { + id: string; + title: string; + message: string; + type: 'transfer_request' | 'transfer_approved' | 'transfer_rejected' | 'transfer_cancelled' | 'transfer_executed'; + priority: 'low' | 'medium' | 'high'; + isRead: boolean; + createdAt: string; + relatedTransferId?: string; +} + +interface NotificationCenterProps { + notifications: Notification[]; + unreadCount: number; + onMarkAsRead: (id: string) => void; + onMarkAllAsRead: () => void; + onClearAll: () => void; + isOpen: boolean; + onClose: () => void; +} + +const NotificationCenter: React.FC = ({ + notifications, + unreadCount, + onMarkAsRead, + onMarkAllAsRead, + onClearAll, + isOpen, + onClose +}) => { + const [showDropdown, setShowDropdown] = useState(false); + const dropdownRef = useRef(null); + + const getTypeIcon = (type: string) => { + switch (type) { + case 'transfer_request': + return ( + + + + ); + case 'transfer_approved': + return ( + + + + ); + case 'transfer_rejected': + return ( + + + + ); + case 'transfer_cancelled': + return ( + + + + ); + case 'transfer_executed': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': + return 'border-l-red-500'; + case 'medium': + return 'border-l-yellow-500'; + default: + return 'border-l-gray-300'; + } + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) return 'Just now'; + if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`; + if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`; + return date.toLocaleDateString(); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+

Notifications

+
+ {unreadCount > 0 && ( + + {unreadCount} unread + + )} + +
+
+ + {notifications.length > 0 && ( +
+ + +
+ )} +
+ + {/* Notifications List */} +
+ {notifications.length === 0 ? ( +
+ + + +

No notifications

+

You're all caught up!

+
+ ) : ( +
+ {notifications.map((notification) => ( +
!notification.isRead && onMarkAsRead(notification.id)} + > +
+
+ {getTypeIcon(notification.type)} +
+
+
+

+ {notification.title} +

+ {!notification.isRead && ( + + )} +
+

+ {notification.message} +

+
+

+ {formatDate(notification.createdAt)} +

+ {!notification.isRead && ( + + )} +
+
+
+
+ ))} +
+ )} +
+ + {/* Footer */} + {notifications.length > 0 && ( +
+

+ Showing {notifications.length} notification{notifications.length !== 1 ? 's' : ''} +

+
+ )} +
+
+ ); +}; + +export default NotificationCenter; \ No newline at end of file diff --git a/frontend/components/transfer/TransferForm.tsx b/frontend/components/transfer/TransferForm.tsx new file mode 100644 index 0000000..be295d9 --- /dev/null +++ b/frontend/components/transfer/TransferForm.tsx @@ -0,0 +1,488 @@ +'use client'; + +import React, { useState } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { Asset } from '@/lib/query/types'; + +interface TransferFormData { + assetIds: string[]; + transferType: 'change_user' | 'change_department' | 'change_location' | 'all'; + destinationUserId?: string; + destinationDepartmentId?: number; + destinationLocation?: string; + reason: string; + notes?: string; + approvalRequired: boolean; + scheduledDate?: string; +} + +interface TransferFormProps { + assets: Asset[]; + users: any[]; + departments: any[]; + onSubmit: (data: TransferFormData) => void; + onCancel: () => void; + isLoading?: boolean; +} + +const TransferForm: React.FC = ({ + assets, + users, + departments, + onSubmit, + onCancel, + isLoading = false +}) => { + const [currentStep, setCurrentStep] = useState(1); + const [selectedAssets, setSelectedAssets] = useState([]); + + const { control, handleSubmit, watch, formState: { errors } } = useForm({ + defaultValues: { + assetIds: [], + transferType: 'change_user', + approvalRequired: false + } + }); + + const transferType = watch('transferType'); + const approvalRequired = watch('approvalRequired'); + + const totalSteps = 4; + + const nextStep = () => { + if (currentStep < totalSteps) { + setCurrentStep(currentStep + 1); + } + }; + + const prevStep = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + } + }; + + const handleAssetToggle = (assetId: string) => { + setSelectedAssets(prev => + prev.includes(assetId) + ? prev.filter(id => id !== assetId) + : [...prev, assetId] + ); + }; + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ( +
+
+

Select Assets

+
+ {assets.map(asset => ( +
handleAssetToggle(asset.id)} + > +
+ {}} + className="mr-3 h-4 w-4 text-blue-600" + /> +
+

{asset.name}

+

{asset.category}

+

ID: {asset.id.substring(0, 8)}...

+
+
+
+ ))} +
+ {errors.assetIds && ( +

{errors.assetIds.message}

+ )} +
+
+ ); + + case 2: + return ( +
+
+

Transfer Details

+ +
+
+ + ( + + )} + /> +
+ + {transferType === 'change_user' && ( +
+ + ( + + )} + /> +
+ )} + + {transferType === 'change_department' && ( +
+ + ( + + )} + /> +
+ )} + + {transferType === 'change_location' && ( +
+ + ( + + )} + /> +
+ )} + +
+ + ( + + )} + /> +
+
+
+
+ ); + + case 3: + return ( +
+
+

Reason and Notes

+ +
+
+ + ( +