diff --git a/backend/src/abdulrcrtw.spec.ts b/backend/src/abdulrcrtw.spec.ts index bf9f4d945..5cb160653 100644 --- a/backend/src/abdulrcrtw.spec.ts +++ b/backend/src/abdulrcrtw.spec.ts @@ -32,6 +32,7 @@ describe('abdulrcrtw Modules (BE-92, BE-91, BE-90, BE-89)', () => { assetId: 'ast-1', title: 'Screen repair', cost: 15000, + scheduledDate: '2026-01-01', }); expect(rec.cost).toBe(15000); }); diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8b48554fd..092846d4e 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -21,6 +21,7 @@ import { InventoryModule } from './inventory/inventory.module'; import { VendorsModule } from './vendors/vendors.module'; import { PurchaseOrdersModule } from './purchase-orders/purchase-orders.module'; import { LicensesModule } from './licenses/licenses.module'; +import { AuditsModule } from './audits/audits.module'; import { NotificationsModule } from './notifications/notifications.module'; import { GatewayModule } from './gateway/gateway.module'; import { AuditLogsModule } from './audit-logs/audit-logs.module'; @@ -45,6 +46,7 @@ import { AuditLogsModule } from './audit-logs/audit-logs.module'; VendorsModule, PurchaseOrdersModule, LicensesModule, + AuditsModule, NotificationsModule, GatewayModule, AuditLogsModule, diff --git a/backend/src/audits/audits.controller.ts b/backend/src/audits/audits.controller.ts new file mode 100644 index 000000000..c24e054a5 --- /dev/null +++ b/backend/src/audits/audits.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { AuditsService } from './audits.service'; +import { CreateAuditSessionDto } from './dto/create-audit-session.dto'; +import { RecordAuditItemDto } from './dto/record-audit-item.dto'; + +@ApiTags('audits') +@Controller('audits') +export class AuditsController { + constructor(private readonly auditsService: AuditsService) {} + + @Get() + @ApiOperation({ + summary: 'List audit sessions with scope, status and progress', + }) + findAll() { + return this.auditsService.findAll(); + } + + @Post() + @ApiOperation({ + summary: 'Start an audit session (generates the expected-assets checklist)', + }) + create(@Body() dto: CreateAuditSessionDto) { + return this.auditsService.create(dto); + } + + @Get(':id') + @ApiOperation({ + summary: 'Get an audit session with its checklist and discrepancy summary', + }) + findOne(@Param('id') id: string) { + return this.auditsService.findById(id); + } + + @Patch(':id/items/:itemId') + @ApiOperation({ + summary: + 'Record a checklist item result (Found / Missing / Wrong location / Damaged)', + }) + recordItem( + @Param('id') id: string, + @Param('itemId') itemId: string, + @Body() dto: RecordAuditItemDto, + ) { + return this.auditsService.recordItem(id, itemId, dto); + } + + @Post(':id/complete') + @ApiOperation({ summary: 'Complete the audit session (becomes immutable)' }) + complete(@Param('id') id: string) { + return this.auditsService.complete(id); + } +} diff --git a/backend/src/audits/audits.module.ts b/backend/src/audits/audits.module.ts new file mode 100644 index 000000000..d652ebaa7 --- /dev/null +++ b/backend/src/audits/audits.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuditSession } from './entities/audit-session.entity'; +import { AuditItem } from './entities/audit-item.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { AuditsService } from './audits.service'; +import { AuditsController } from './audits.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([AuditSession, AuditItem, Asset])], + providers: [AuditsService], + controllers: [AuditsController], + exports: [AuditsService], +}) +export class AuditsModule {} diff --git a/backend/src/audits/audits.service.ts b/backend/src/audits/audits.service.ts new file mode 100644 index 000000000..0ad919d30 --- /dev/null +++ b/backend/src/audits/audits.service.ts @@ -0,0 +1,153 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { + AuditSession, + AuditSessionStatus, +} from './entities/audit-session.entity'; +import { AuditItem, AuditItemResult } from './entities/audit-item.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { CreateAuditSessionDto } from './dto/create-audit-session.dto'; +import { RecordAuditItemDto } from './dto/record-audit-item.dto'; + +@Injectable() +export class AuditsService { + constructor( + @InjectRepository(AuditSession) + private readonly sessionRepo: Repository, + @InjectRepository(AuditItem) + private readonly itemRepo: Repository, + @InjectRepository(Asset) + private readonly assetRepo: Repository, + ) {} + + private summarize(session: AuditSession, items: AuditItem[]) { + const total = items.length; + const checked = items.filter( + (i) => i.result !== AuditItemResult.PENDING, + ).length; + const discrepancies = items.filter( + (i) => + i.result !== AuditItemResult.PENDING && + i.result !== AuditItemResult.FOUND, + ); + return { + ...session, + totalItems: total, + checkedItems: checked, + progressPercent: total === 0 ? 0 : Math.round((checked / total) * 100), + discrepancyCount: discrepancies.length, + }; + } + + async findAll() { + const sessions = await this.sessionRepo.find({ + order: { createdAt: 'DESC' }, + }); + const results = []; + for (const session of sessions) { + const items = await this.itemRepo.find({ + where: { auditSessionId: session.id }, + }); + results.push(this.summarize(session, items)); + } + return results; + } + + async findById(id: string) { + const session = await this.sessionRepo.findOne({ where: { id } }); + if (!session) throw new NotFoundException(`Audit session ${id} not found`); + const items = await this.itemRepo.find({ + where: { auditSessionId: id }, + order: { createdAt: 'ASC' }, + }); + return { ...this.summarize(session, items), items }; + } + + async create(dto: CreateAuditSessionDto, actorId?: string) { + if (!dto.departmentId && !dto.locationId) { + throw new BadRequestException( + 'An audit session needs a department or location scope', + ); + } + + const qb = this.assetRepo.createQueryBuilder('asset'); + if (dto.departmentId) + qb.andWhere('asset.departmentId = :d', { d: dto.departmentId }); + if (dto.locationId) + qb.andWhere('asset.locationId = :l', { l: dto.locationId }); + const assets = await qb.getMany(); + + if (assets.length === 0) { + throw new BadRequestException('No assets match the selected scope'); + } + + const session = this.sessionRepo.create({ + name: dto.name, + scopeDepartmentId: dto.departmentId, + scopeLocationId: dto.locationId, + createdByUserId: actorId, + status: AuditSessionStatus.DRAFT, + }); + const savedSession = await this.sessionRepo.save(session); + + const items = assets.map((asset) => + this.itemRepo.create({ + auditSessionId: savedSession.id, + assetId: asset.id, + expectedLocationId: asset.locationId, + result: AuditItemResult.PENDING, + }), + ); + await this.itemRepo.save(items); + + return this.findById(savedSession.id); + } + + private async getMutableSession(id: string): Promise { + const session = await this.sessionRepo.findOne({ where: { id } }); + if (!session) throw new NotFoundException(`Audit session ${id} not found`); + if (session.status === AuditSessionStatus.COMPLETED) { + throw new BadRequestException( + 'This audit session is completed and can no longer be edited', + ); + } + return session; + } + + async recordItem(sessionId: string, itemId: string, dto: RecordAuditItemDto) { + const session = await this.getMutableSession(sessionId); + + const item = await this.itemRepo.findOne({ + where: { id: itemId, auditSessionId: sessionId }, + }); + if (!item) + throw new NotFoundException( + `Audit item ${itemId} not found in this session`, + ); + + item.result = dto.result; + item.note = dto.note; + item.checkedAt = new Date(); + await this.itemRepo.save(item); + + if (session.status === AuditSessionStatus.DRAFT) { + session.status = AuditSessionStatus.IN_PROGRESS; + await this.sessionRepo.save(session); + } + + return this.findById(sessionId); + } + + async complete(sessionId: string) { + const session = await this.getMutableSession(sessionId); + session.status = AuditSessionStatus.COMPLETED; + session.completedAt = new Date(); + await this.sessionRepo.save(session); + return this.findById(sessionId); + } +} diff --git a/backend/src/audits/dto/create-audit-session.dto.ts b/backend/src/audits/dto/create-audit-session.dto.ts new file mode 100644 index 000000000..10535216e --- /dev/null +++ b/backend/src/audits/dto/create-audit-session.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateAuditSessionDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + departmentId?: string; + + @IsOptional() + @IsString() + locationId?: string; +} diff --git a/backend/src/audits/dto/record-audit-item.dto.ts b/backend/src/audits/dto/record-audit-item.dto.ts new file mode 100644 index 000000000..678671d03 --- /dev/null +++ b/backend/src/audits/dto/record-audit-item.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { AuditItemResult } from '../entities/audit-item.entity'; + +export class RecordAuditItemDto { + @IsEnum(AuditItemResult) + result: AuditItemResult; + + @IsOptional() + @IsString() + note?: string; +} diff --git a/backend/src/audits/entities/audit-item.entity.ts b/backend/src/audits/entities/audit-item.entity.ts new file mode 100644 index 000000000..c8d278966 --- /dev/null +++ b/backend/src/audits/entities/audit-item.entity.ts @@ -0,0 +1,48 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export enum AuditItemResult { + PENDING = 'PENDING', + FOUND = 'FOUND', + MISSING = 'MISSING', + WRONG_LOCATION = 'WRONG_LOCATION', + DAMAGED = 'DAMAGED', +} + +@Entity('audit_items') +export class AuditItem { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + auditSessionId: string; + + @Column() + assetId: string; + + /** Snapshot of the asset's expected location at the time the checklist was generated. */ + @Column({ nullable: true }) + expectedLocationId?: string; + + @Column({ + type: 'enum', + enum: AuditItemResult, + default: AuditItemResult.PENDING, + }) + result: AuditItemResult; + + @Column({ nullable: true }) + note?: string; + + @Column({ nullable: true }) + checkedAt?: Date; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/audits/entities/audit-session.entity.ts b/backend/src/audits/entities/audit-session.entity.ts new file mode 100644 index 000000000..c053250ff --- /dev/null +++ b/backend/src/audits/entities/audit-session.entity.ts @@ -0,0 +1,47 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, +} from 'typeorm'; + +export enum AuditSessionStatus { + DRAFT = 'DRAFT', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', +} + +@Entity('audit_sessions') +export class AuditSession { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + name: string; + + @Column({ + type: 'enum', + enum: AuditSessionStatus, + default: AuditSessionStatus.DRAFT, + }) + status: AuditSessionStatus; + + @Column({ nullable: true }) + scopeDepartmentId?: string; + + @Column({ nullable: true }) + scopeLocationId?: string; + + @Column({ nullable: true }) + createdByUserId?: string; + + @Column({ nullable: true }) + completedAt?: Date; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/femaleotaku.spec.ts b/backend/src/femaleotaku.spec.ts index 08d7b231c..e6e90e315 100644 --- a/backend/src/femaleotaku.spec.ts +++ b/backend/src/femaleotaku.spec.ts @@ -13,7 +13,10 @@ describe('femaleotaku Modules (BE-88, BE-87, BE-85, BE-84)', () => { .fn() .mockImplementation((dto) => Promise.resolve({ id: 'loc-1', ...dto })), }; - const service = new LocationsService(mockRepo as any); + const mockAssetRepo = { + count: jest.fn().mockResolvedValue(0), + }; + const service = new LocationsService(mockRepo as any, mockAssetRepo as any); const loc = await service.create({ name: 'Building A', code: 'BLD-A' }); expect(loc.name).toBe('Building A'); }); diff --git a/backend/src/ibinola.spec.ts b/backend/src/ibinola.spec.ts index ed1f44b03..e68d9e273 100644 --- a/backend/src/ibinola.spec.ts +++ b/backend/src/ibinola.spec.ts @@ -49,9 +49,12 @@ describe('ibinola Modules (BE-96, BE-95, BE-94, BE-93)', () => { findOne: jest.fn().mockResolvedValue(lic), save: jest.fn().mockImplementation((dto) => Promise.resolve(dto)), }; - const service = new LicensesService(mockRepo as any); + const mockSeatRepo = { + findOne: jest.fn().mockResolvedValue(null), + }; + const service = new LicensesService(mockRepo as any, mockSeatRepo as any); - await expect(service.assign('lic-1', 'u-1')).rejects.toThrow( + await expect(service.assign('lic-1', { userId: 'u-1' })).rejects.toThrow( 'No available seats remaining for this license', ); }); diff --git a/backend/src/licenses/dto/assign-seat.dto.ts b/backend/src/licenses/dto/assign-seat.dto.ts new file mode 100644 index 000000000..3be49b90d --- /dev/null +++ b/backend/src/licenses/dto/assign-seat.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class AssignSeatDto { + @IsString() + userId: string; +} diff --git a/backend/src/licenses/dto/create-license.dto.ts b/backend/src/licenses/dto/create-license.dto.ts new file mode 100644 index 000000000..0d07cc9b2 --- /dev/null +++ b/backend/src/licenses/dto/create-license.dto.ts @@ -0,0 +1,56 @@ +import { + IsEnum, + IsInt, + IsOptional, + IsPositive, + IsString, + IsDateString, +} from 'class-validator'; +import { LicenseType, BillingPeriod } from '../entities/license.entity'; + +export class CreateLicenseDto { + @IsString() + name: string; + + @IsOptional() + @IsString() + vendorId?: string; + + @IsString() + licenseKey: string; + + @IsOptional() + @IsEnum(LicenseType) + type?: LicenseType; + + @IsOptional() + @IsEnum(BillingPeriod) + billingPeriod?: BillingPeriod; + + @IsInt() + @IsPositive() + seatsTotal: number; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + expiryDate?: string; + + @IsOptional() + @IsInt() + cost?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsOptional() + autoRenew?: boolean; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/licenses/dto/update-license.dto.ts b/backend/src/licenses/dto/update-license.dto.ts new file mode 100644 index 000000000..3f500e4a8 --- /dev/null +++ b/backend/src/licenses/dto/update-license.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateLicenseDto } from './create-license.dto'; + +export class UpdateLicenseDto extends PartialType(CreateLicenseDto) {} diff --git a/backend/src/licenses/entities/license-seat-assignment.entity.ts b/backend/src/licenses/entities/license-seat-assignment.entity.ts new file mode 100644 index 000000000..99d4e5d54 --- /dev/null +++ b/backend/src/licenses/entities/license-seat-assignment.entity.ts @@ -0,0 +1,26 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('license_seat_assignments') +export class LicenseSeatAssignment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column() + licenseId: string; + + @Column() + userId: string; + + @CreateDateColumn() + assignedAt: Date; + + @Column({ nullable: true }) + unassignedAt?: Date; +} diff --git a/backend/src/licenses/entities/license.entity.ts b/backend/src/licenses/entities/license.entity.ts index 952b046c8..896016d41 100644 --- a/backend/src/licenses/entities/license.entity.ts +++ b/backend/src/licenses/entities/license.entity.ts @@ -11,6 +11,12 @@ export enum LicenseType { SUBSCRIPTION = 'SUBSCRIPTION', } +export enum BillingPeriod { + MONTHLY = 'MONTHLY', + YEARLY = 'YEARLY', + ONE_TIME = 'ONE_TIME', +} + @Entity('software_licenses') export class License { @PrimaryGeneratedColumn('uuid') @@ -32,6 +38,9 @@ export class License { }) type: LicenseType; + @Column({ type: 'enum', enum: BillingPeriod, default: BillingPeriod.YEARLY }) + billingPeriod: BillingPeriod; + @Column({ type: 'integer', default: 1 }) seatsTotal: number; @@ -53,6 +62,9 @@ export class License { @Column({ default: false }) autoRenew: boolean; + @Column({ nullable: true }) + notes?: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/licenses/licenses.controller.ts b/backend/src/licenses/licenses.controller.ts index 8e0399225..2d8efa925 100644 --- a/backend/src/licenses/licenses.controller.ts +++ b/backend/src/licenses/licenses.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, Post, Param, Body } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, +} from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,6 +14,9 @@ import { ApiResponse, } from '@nestjs/swagger'; import { LicensesService } from './licenses.service'; +import { CreateLicenseDto } from './dto/create-license.dto'; +import { UpdateLicenseDto } from './dto/update-license.dto'; +import { AssignSeatDto } from './dto/assign-seat.dto'; @ApiTags('licenses') @ApiBearerAuth('JWT-auth') @@ -23,7 +34,7 @@ export class LicensesController { @Post() @ApiOperation({ summary: 'Create a software license' }) @ApiResponse({ status: 201, description: 'License created' }) - create(@Body() dto: any) { + create(@Body() dto: CreateLicenseDto) { return this.licensesService.create(dto); } @@ -35,10 +46,46 @@ export class LicensesController { return this.licensesService.findById(id); } + @Patch(':id') + @ApiOperation({ summary: 'Update a license' }) + update(@Param('id') id: string, @Body() dto: UpdateLicenseDto) { + return this.licensesService.update(id, dto); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a license' }) + delete(@Param('id') id: string) { + return this.licensesService.delete(id); + } + + @Post(':id/reveal-key') + @ApiOperation({ + summary: + 'Reveal the plaintext license key (explicit click-to-reveal action)', + }) + revealKey(@Param('id') id: string) { + return this.licensesService.revealKey(id); + } + + @Get(':id/assignments') + @ApiOperation({ summary: 'List active seat assignments for a license' }) + getAssignments(@Param('id') id: string) { + return this.licensesService.getAssignments(id); + } + @Post(':id/assign') - @ApiOperation({ summary: 'Assign a license seat' }) + @ApiOperation({ summary: 'Assign a license seat to a user' }) @ApiResponse({ status: 201, description: 'License seat assigned' }) - assign(@Param('id') id: string, @Body('assigneeId') assigneeId: string) { - return this.licensesService.assign(id, assigneeId); + assign(@Param('id') id: string, @Body() dto: AssignSeatDto) { + return this.licensesService.assign(id, dto); + } + + @Post(':id/assignments/:assignmentId/unassign') + @ApiOperation({ summary: 'Release a seat assignment' }) + unassign( + @Param('id') id: string, + @Param('assignmentId') assignmentId: string, + ) { + return this.licensesService.unassign(id, assignmentId); } } diff --git a/backend/src/licenses/licenses.module.ts b/backend/src/licenses/licenses.module.ts index 294a513a9..f649df503 100644 --- a/backend/src/licenses/licenses.module.ts +++ b/backend/src/licenses/licenses.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { License } from './entities/license.entity'; +import { LicenseSeatAssignment } from './entities/license-seat-assignment.entity'; import { LicensesService } from './licenses.service'; import { LicensesController } from './licenses.controller'; @Module({ - imports: [TypeOrmModule.forFeature([License])], + imports: [TypeOrmModule.forFeature([License, LicenseSeatAssignment])], providers: [LicensesService], controllers: [LicensesController], exports: [LicensesService], diff --git a/backend/src/licenses/licenses.service.ts b/backend/src/licenses/licenses.service.ts index 848d3d777..5a78b2f4a 100644 --- a/backend/src/licenses/licenses.service.ts +++ b/backend/src/licenses/licenses.service.ts @@ -2,42 +2,132 @@ import { Injectable, NotFoundException, ConflictException, + BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { IsNull, Repository } from 'typeorm'; import { License } from './entities/license.entity'; +import { LicenseSeatAssignment } from './entities/license-seat-assignment.entity'; +import { CreateLicenseDto } from './dto/create-license.dto'; +import { UpdateLicenseDto } from './dto/update-license.dto'; + +const RENEWAL_WARNING_DAYS = 30; @Injectable() export class LicensesService { constructor( @InjectRepository(License) private readonly licenseRepo: Repository, + @InjectRepository(LicenseSeatAssignment) + private readonly seatRepo: Repository, ) {} + private withRenewalFlag(license: License) { + let renewsSoon = false; + if (license.expiryDate) { + const days = Math.ceil( + (new Date(license.expiryDate).getTime() - Date.now()) / + (1000 * 60 * 60 * 24), + ); + renewsSoon = days >= 0 && days <= RENEWAL_WARNING_DAYS; + } + return { ...license, renewsSoon }; + } + async findAll() { - return this.licenseRepo.find(); + const licenses = await this.licenseRepo.find(); + return licenses.map((l) => this.withRenewalFlag(l)); } async findById(id: string) { const lic = await this.licenseRepo.findOne({ where: { id } }); if (!lic) throw new NotFoundException(`License ${id} not found`); - return lic; + return this.withRenewalFlag(lic); } - async create(dto: Partial) { + async create(dto: CreateLicenseDto) { const lic = this.licenseRepo.create(dto); - return this.licenseRepo.save(lic); + const saved = await this.licenseRepo.save(lic); + return this.findById(saved.id); + } + + async update(id: string, dto: UpdateLicenseDto) { + const lic = await this.licenseRepo.findOne({ where: { id } }); + if (!lic) throw new NotFoundException(`License ${id} not found`); + if (dto.seatsTotal !== undefined && dto.seatsTotal < lic.seatsUsed) { + throw new BadRequestException( + `Cannot reduce total seats below the ${lic.seatsUsed} seat(s) currently assigned`, + ); + } + Object.assign(lic, dto); + await this.licenseRepo.save(lic); + return this.findById(id); + } + + async delete(id: string) { + const lic = await this.licenseRepo.findOne({ where: { id } }); + if (!lic) throw new NotFoundException(`License ${id} not found`); + return this.licenseRepo.remove(lic); } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async assign(id: string, _userIdOrAssetId: string) { - const lic = await this.findById(id); + /** Returns the plaintext license key — only ever called from the explicit reveal action. */ + async revealKey(id: string): Promise<{ licenseKey: string }> { + const lic = await this.licenseRepo + .createQueryBuilder('license') + .addSelect('license.licenseKey') + .where('license.id = :id', { id }) + .getOne(); + if (!lic) throw new NotFoundException(`License ${id} not found`); + return { licenseKey: lic.licenseKey }; + } + + async getAssignments(licenseId: string) { + await this.findById(licenseId); + return this.seatRepo.find({ + where: { licenseId, unassignedAt: IsNull() }, + order: { assignedAt: 'ASC' }, + }); + } + + async assign(licenseId: string, dto: { userId: string }) { + const lic = await this.licenseRepo.findOne({ where: { id: licenseId } }); + if (!lic) throw new NotFoundException(`License ${licenseId} not found`); if (lic.seatsUsed >= lic.seatsTotal) { throw new ConflictException( 'No available seats remaining for this license', ); } + + const existing = await this.seatRepo.findOne({ + where: { licenseId, userId: dto.userId, unassignedAt: IsNull() }, + }); + if (existing) { + throw new ConflictException( + 'This user already holds a seat for this license', + ); + } + + const assignment = this.seatRepo.create({ licenseId, userId: dto.userId }); + await this.seatRepo.save(assignment); lic.seatsUsed += 1; - return this.licenseRepo.save(lic); + await this.licenseRepo.save(lic); + return this.findById(licenseId); + } + + async unassign(licenseId: string, assignmentId: string) { + const lic = await this.licenseRepo.findOne({ where: { id: licenseId } }); + if (!lic) throw new NotFoundException(`License ${licenseId} not found`); + + const assignment = await this.seatRepo.findOne({ + where: { id: assignmentId, licenseId, unassignedAt: IsNull() }, + }); + if (!assignment) + throw new NotFoundException('Active seat assignment not found'); + + assignment.unassignedAt = new Date(); + await this.seatRepo.save(assignment); + lic.seatsUsed = Math.max(0, lic.seatsUsed - 1); + await this.licenseRepo.save(lic); + return this.findById(licenseId); } } diff --git a/backend/src/locations/dto/create-location.dto.ts b/backend/src/locations/dto/create-location.dto.ts new file mode 100644 index 000000000..7bf8db57b --- /dev/null +++ b/backend/src/locations/dto/create-location.dto.ts @@ -0,0 +1,26 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { LocationType } from '../entities/location.entity'; + +export class CreateLocationDto { + @IsString() + name: string; + + @IsString() + code: string; + + @IsOptional() + @IsEnum(LocationType) + type?: LocationType; + + @IsOptional() + @IsString() + address?: string; + + @IsOptional() + @IsString() + parentLocationId?: string; + + @IsOptional() + @IsString() + branchId?: string; +} diff --git a/backend/src/locations/dto/update-location.dto.ts b/backend/src/locations/dto/update-location.dto.ts new file mode 100644 index 000000000..27875b263 --- /dev/null +++ b/backend/src/locations/dto/update-location.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateLocationDto } from './create-location.dto'; + +export class UpdateLocationDto extends PartialType(CreateLocationDto) {} diff --git a/backend/src/locations/locations.controller.ts b/backend/src/locations/locations.controller.ts index c36655f9d..da8918c50 100644 --- a/backend/src/locations/locations.controller.ts +++ b/backend/src/locations/locations.controller.ts @@ -14,6 +14,8 @@ import { ApiResponse, } from '@nestjs/swagger'; import { LocationsService } from './locations.service'; +import { CreateLocationDto } from './dto/create-location.dto'; +import { UpdateLocationDto } from './dto/update-location.dto'; @ApiTags('locations') @ApiBearerAuth('JWT-auth') @@ -22,7 +24,9 @@ export class LocationsController { constructor(private readonly locationsService: LocationsService) {} @Get() - @ApiOperation({ summary: 'List all locations' }) + @ApiOperation({ + summary: 'List all locations with per-node and rolled-up asset counts', + }) @ApiResponse({ status: 200, description: 'List of locations' }) findAll() { return this.locationsService.findAll(); @@ -31,7 +35,7 @@ export class LocationsController { @Post() @ApiOperation({ summary: 'Create a location' }) @ApiResponse({ status: 201, description: 'Location created' }) - create(@Body() dto: any) { + create(@Body() dto: CreateLocationDto) { return this.locationsService.create(dto); } @@ -44,14 +48,16 @@ export class LocationsController { } @Patch(':id') - @ApiOperation({ summary: 'Update a location' }) + @ApiOperation({ summary: 'Update or move a location' }) @ApiResponse({ status: 200, description: 'Location updated' }) - update(@Param('id') id: string, @Body() dto: any) { + update(@Param('id') id: string, @Body() dto: UpdateLocationDto) { return this.locationsService.update(id, dto); } @Delete(':id') - @ApiOperation({ summary: 'Delete a location' }) + @ApiOperation({ + summary: 'Delete a location (blocked if children or assets exist)', + }) @ApiResponse({ status: 200, description: 'Location deleted' }) delete(@Param('id') id: string) { return this.locationsService.delete(id); diff --git a/backend/src/locations/locations.module.ts b/backend/src/locations/locations.module.ts index 8f2955e3f..aed12f253 100644 --- a/backend/src/locations/locations.module.ts +++ b/backend/src/locations/locations.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Location } from './entities/location.entity'; +import { Asset } from '../assets/entities/asset.entity'; import { LocationsService } from './locations.service'; import { LocationsController } from './locations.controller'; @Module({ - imports: [TypeOrmModule.forFeature([Location])], + imports: [TypeOrmModule.forFeature([Location, Asset])], providers: [LocationsService], controllers: [LocationsController], exports: [LocationsService], diff --git a/backend/src/locations/locations.service.ts b/backend/src/locations/locations.service.ts index 8b03ee248..46ee77fd0 100644 --- a/backend/src/locations/locations.service.ts +++ b/backend/src/locations/locations.service.ts @@ -2,20 +2,65 @@ import { Injectable, NotFoundException, BadRequestException, + ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Location } from './entities/location.entity'; +import { Asset } from '../assets/entities/asset.entity'; +import { CreateLocationDto } from './dto/create-location.dto'; +import { UpdateLocationDto } from './dto/update-location.dto'; + +export interface LocationWithCounts extends Location { + assetCount: number; + totalAssetCount: number; +} @Injectable() export class LocationsService { constructor( @InjectRepository(Location) private readonly locationRepo: Repository, + @InjectRepository(Asset) + private readonly assetRepo: Repository, ) {} - async findAll() { - return this.locationRepo.find(); + async findAll(): Promise { + const locations = await this.locationRepo.find(); + const counts = await this.assetRepo + .createQueryBuilder('asset') + .select('asset.locationId', 'locationId') + .addSelect('COUNT(*)', 'count') + .where('asset.locationId IS NOT NULL') + .groupBy('asset.locationId') + .getRawMany<{ locationId: string; count: string }>(); + + const directCounts = new Map( + counts.map((c) => [c.locationId, Number(c.count)]), + ); + + const childrenByParent = new Map(); + for (const loc of locations) { + const key = loc.parentLocationId ?? ''; + childrenByParent.set(key, [...(childrenByParent.get(key) ?? []), loc.id]); + } + + const totalCache = new Map(); + const computeTotal = (id: string): number => { + if (totalCache.has(id)) return totalCache.get(id) as number; + const own = directCounts.get(id) ?? 0; + const children = childrenByParent.get(id) ?? []; + const total = + own + children.reduce((sum, childId) => sum + computeTotal(childId), 0); + totalCache.set(id, total); + return total; + }; + + return locations.map((loc) => ({ + ...loc, + assetCount: directCounts.get(loc.id) ?? 0, + totalAssetCount: computeTotal(loc.id), + })); } async findById(id: string) { @@ -24,15 +69,21 @@ export class LocationsService { return loc; } - async create(dto: Partial) { + async create(dto: CreateLocationDto) { + if (dto.parentLocationId) { + await this.findById(dto.parentLocationId); + } const loc = this.locationRepo.create(dto); return this.locationRepo.save(loc); } - async update(id: string, dto: Partial) { + async update(id: string, dto: UpdateLocationDto) { const loc = await this.findById(id); - if (dto.parentLocationId && dto.parentLocationId === id) { - throw new BadRequestException('A location cannot be its own parent'); + if (dto.parentLocationId) { + if (dto.parentLocationId === id) { + throw new BadRequestException('A location cannot be its own parent'); + } + await this.assertNotDescendant(id, dto.parentLocationId); } Object.assign(loc, dto); return this.locationRepo.save(loc); @@ -40,6 +91,44 @@ export class LocationsService { async delete(id: string) { const loc = await this.findById(id); + + const childCount = await this.locationRepo.count({ + where: { parentLocationId: id }, + }); + if (childCount > 0) { + throw new ConflictException( + 'This location has child locations. Delete or move them before deleting this location.', + ); + } + + const assetCount = await this.assetRepo.count({ + where: { locationId: id }, + }); + if (assetCount > 0) { + throw new ConflictException( + `This location still has ${assetCount} asset(s) assigned to it. Reassign them before deleting.`, + ); + } + return this.locationRepo.remove(loc); } + + /** Prevent moving a location underneath one of its own descendants. */ + private async assertNotDescendant(id: string, candidateParentId: string) { + let current: string | undefined = candidateParentId; + const visited = new Set(); + while (current) { + if (current === id) { + throw new BadRequestException( + 'Cannot move a location under its own descendant', + ); + } + if (visited.has(current)) break; + visited.add(current); + const parent: Location | null = await this.locationRepo.findOne({ + where: { id: current }, + }); + current = parent?.parentLocationId; + } + } } diff --git a/backend/src/maintenance/asset-maintenance.controller.ts b/backend/src/maintenance/asset-maintenance.controller.ts new file mode 100644 index 000000000..424338faa --- /dev/null +++ b/backend/src/maintenance/asset-maintenance.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceRecordDto } from './dto/create-maintenance-record.dto'; + +@ApiTags('assets') +@Controller('assets/:id/maintenance') +export class AssetMaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Get() + @ApiOperation({ summary: "Get an asset's maintenance history" }) + findForAsset(@Param('id') assetId: string) { + return this.maintenanceService.findAll({ assetId }); + } + + @Post() + @ApiOperation({ summary: 'Schedule maintenance for an asset' }) + create( + @Param('id') assetId: string, + @Body() dto: CreateMaintenanceRecordDto, + ) { + return this.maintenanceService.create(dto, assetId); + } + + @Patch(':maintenanceId') + @ApiOperation({ summary: "Update an asset's maintenance record status" }) + updateStatus( + @Param('maintenanceId') maintenanceId: string, + @Body('status') status: string, + ) { + return this.maintenanceService.updateStatus(maintenanceId, status); + } +} diff --git a/backend/src/maintenance/dto/create-maintenance-record.dto.ts b/backend/src/maintenance/dto/create-maintenance-record.dto.ts new file mode 100644 index 000000000..635acfda6 --- /dev/null +++ b/backend/src/maintenance/dto/create-maintenance-record.dto.ts @@ -0,0 +1,45 @@ +import { + IsDateString, + IsEnum, + IsInt, + IsOptional, + IsString, +} from 'class-validator'; +import { MaintenanceType } from '../entities/maintenance-record.entity'; + +export class CreateMaintenanceRecordDto { + @IsOptional() + @IsString() + assetId?: string; + + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(MaintenanceType) + type?: MaintenanceType; + + @IsOptional() + @IsString() + vendorId?: string; + + @IsOptional() + @IsInt() + cost?: number; + + @IsOptional() + @IsString() + currency?: string; + + @IsDateString() + scheduledDate: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/backend/src/maintenance/dto/update-maintenance-record.dto.ts b/backend/src/maintenance/dto/update-maintenance-record.dto.ts new file mode 100644 index 000000000..30b97a136 --- /dev/null +++ b/backend/src/maintenance/dto/update-maintenance-record.dto.ts @@ -0,0 +1,16 @@ +import { PartialType, OmitType } from '@nestjs/mapped-types'; +import { IsDateString, IsEnum, IsOptional } from 'class-validator'; +import { CreateMaintenanceRecordDto } from './create-maintenance-record.dto'; +import { MaintenanceStatus } from '../entities/maintenance-record.entity'; + +export class UpdateMaintenanceRecordDto extends PartialType( + OmitType(CreateMaintenanceRecordDto, ['assetId'] as const), +) { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; +} diff --git a/backend/src/maintenance/entities/maintenance-record.entity.ts b/backend/src/maintenance/entities/maintenance-record.entity.ts index 4990e2092..2a4aed754 100644 --- a/backend/src/maintenance/entities/maintenance-record.entity.ts +++ b/backend/src/maintenance/entities/maintenance-record.entity.ts @@ -13,6 +13,12 @@ export enum MaintenanceStatus { CANCELLED = 'CANCELLED', } +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + SCHEDULED = 'SCHEDULED', +} + @Entity('maintenance_records') export class MaintenanceRecord { @PrimaryGeneratedColumn('uuid') @@ -27,6 +33,13 @@ export class MaintenanceRecord { @Column({ nullable: true }) description?: string; + @Column({ + type: 'enum', + enum: MaintenanceType, + default: MaintenanceType.SCHEDULED, + }) + type: MaintenanceType; + @Column({ type: 'enum', enum: MaintenanceStatus, @@ -37,6 +50,9 @@ export class MaintenanceRecord { @Column({ nullable: true }) vendorId?: string; + @Column({ nullable: true }) + performedByUserId?: string; + @Column({ type: 'integer', default: 0 }) cost: number; @@ -49,6 +65,9 @@ export class MaintenanceRecord { @Column({ nullable: true }) completedDate?: Date; + @Column({ nullable: true }) + notes?: string; + @CreateDateColumn() createdAt: Date; diff --git a/backend/src/maintenance/maintenance.controller.ts b/backend/src/maintenance/maintenance.controller.ts index 936b272a0..b9de50643 100644 --- a/backend/src/maintenance/maintenance.controller.ts +++ b/backend/src/maintenance/maintenance.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, Post, Patch, Body, Param } from '@nestjs/common'; +import { + Controller, + Get, + Post, + Patch, + Body, + Param, + Query, +} from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -6,6 +14,8 @@ import { ApiResponse, } from '@nestjs/swagger'; import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceRecordDto } from './dto/create-maintenance-record.dto'; +import { UpdateMaintenanceRecordDto } from './dto/update-maintenance-record.dto'; @ApiTags('maintenance') @ApiBearerAuth('JWT-auth') @@ -14,16 +24,32 @@ export class MaintenanceController { constructor(private readonly maintenanceService: MaintenanceService) {} @Get() - @ApiOperation({ summary: 'List all maintenance records' }) + @ApiOperation({ + summary: 'List all maintenance records across assets, with filters', + }) @ApiResponse({ status: 200, description: 'List of maintenance records' }) - findAll() { - return this.maintenanceService.findAll(); + findAll( + @Query('assetId') assetId?: string, + @Query('status') status?: string, + @Query('type') type?: string, + @Query('departmentId') departmentId?: string, + @Query('from') from?: string, + @Query('to') to?: string, + ) { + return this.maintenanceService.findAll({ + assetId, + status, + type, + departmentId, + from, + to, + }); } @Post() @ApiOperation({ summary: 'Create a maintenance record' }) @ApiResponse({ status: 201, description: 'Maintenance record created' }) - create(@Body() dto: any) { + create(@Body() dto: CreateMaintenanceRecordDto) { return this.maintenanceService.create(dto); } @@ -38,7 +64,7 @@ export class MaintenanceController { @Patch(':id') @ApiOperation({ summary: 'Update a maintenance record' }) @ApiResponse({ status: 200, description: 'Maintenance record updated' }) - update(@Param('id') id: string, @Body() dto: any) { + update(@Param('id') id: string, @Body() dto: UpdateMaintenanceRecordDto) { return this.maintenanceService.update(id, dto); } } diff --git a/backend/src/maintenance/maintenance.module.ts b/backend/src/maintenance/maintenance.module.ts index 7d4439644..3acbe09bb 100644 --- a/backend/src/maintenance/maintenance.module.ts +++ b/backend/src/maintenance/maintenance.module.ts @@ -3,11 +3,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { MaintenanceRecord } from './entities/maintenance-record.entity'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceController } from './maintenance.controller'; +import { AssetMaintenanceController } from './asset-maintenance.controller'; @Module({ imports: [TypeOrmModule.forFeature([MaintenanceRecord])], providers: [MaintenanceService], - controllers: [MaintenanceController], + controllers: [MaintenanceController, AssetMaintenanceController], exports: [MaintenanceService], }) export class MaintenanceModule {} diff --git a/backend/src/maintenance/maintenance.service.ts b/backend/src/maintenance/maintenance.service.ts index bf4cfd83e..35923ea54 100644 --- a/backend/src/maintenance/maintenance.service.ts +++ b/backend/src/maintenance/maintenance.service.ts @@ -1,7 +1,22 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { MaintenanceRecord } from './entities/maintenance-record.entity'; +import { CreateMaintenanceRecordDto } from './dto/create-maintenance-record.dto'; +import { UpdateMaintenanceRecordDto } from './dto/update-maintenance-record.dto'; + +export interface MaintenanceQuery { + assetId?: string; + status?: string; + type?: string; + departmentId?: string; + from?: string; + to?: string; +} @Injectable() export class MaintenanceService { @@ -10,8 +25,26 @@ export class MaintenanceService { private readonly maintenanceRepo: Repository, ) {} - async findAll() { - return this.maintenanceRepo.find(); + async findAll(query?: MaintenanceQuery) { + const qb = this.maintenanceRepo.createQueryBuilder('record'); + + if (query?.departmentId) { + qb.innerJoin('assets', 'asset', 'asset.id = record.assetId').andWhere( + 'asset.departmentId = :departmentId', + { departmentId: query.departmentId }, + ); + } + if (query?.assetId) + qb.andWhere('record.assetId = :assetId', { assetId: query.assetId }); + if (query?.status) + qb.andWhere('record.status = :status', { status: query.status }); + if (query?.type) qb.andWhere('record.type = :type', { type: query.type }); + if (query?.from) + qb.andWhere('record.scheduledDate >= :from', { from: query.from }); + if (query?.to) qb.andWhere('record.scheduledDate <= :to', { to: query.to }); + + qb.orderBy('record.scheduledDate', 'ASC'); + return qb.getMany(); } async findById(id: string) { @@ -21,14 +54,40 @@ export class MaintenanceService { return record; } - async create(dto: Partial) { - const record = this.maintenanceRepo.create(dto); + async create(dto: CreateMaintenanceRecordDto, assetIdFromParam?: string) { + const assetId = assetIdFromParam ?? dto.assetId; + if (!assetId) throw new BadRequestException('assetId is required'); + + const record = this.maintenanceRepo.create({ + ...dto, + assetId, + title: dto.title || dto.description || 'Maintenance', + scheduledDate: dto.scheduledDate + ? new Date(dto.scheduledDate) + : undefined, + }); + return this.maintenanceRepo.save(record); + } + + async update(id: string, dto: UpdateMaintenanceRecordDto) { + const record = await this.findById(id); + Object.assign(record, dto, { + scheduledDate: dto.scheduledDate + ? new Date(dto.scheduledDate) + : record.scheduledDate, + completedDate: dto.completedDate + ? new Date(dto.completedDate) + : record.completedDate, + }); return this.maintenanceRepo.save(record); } - async update(id: string, dto: Partial) { + async updateStatus(id: string, status: string) { const record = await this.findById(id); - Object.assign(record, dto); + record.status = status as MaintenanceRecord['status']; + if (status === 'COMPLETED' && !record.completedDate) { + record.completedDate = new Date(); + } return this.maintenanceRepo.save(record); } } diff --git a/frontend/app/(dashboard)/assets/page.tsx b/frontend/app/(dashboard)/assets/page.tsx index fa8049607..f990c66f5 100644 --- a/frontend/app/(dashboard)/assets/page.tsx +++ b/frontend/app/(dashboard)/assets/page.tsx @@ -1,27 +1,34 @@ "use client"; import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Plus, Search, SlidersHorizontal } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Plus, Search, SlidersHorizontal, MapPin, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { StatusBadge } from "@/components/assets/status-badge"; import { ConditionBadge } from "@/components/assets/condition-badge"; import { CreateAssetModal } from "@/components/assets/create-asset-modal"; import { useAssets } from "@/lib/query/hooks/useAssets"; +import { useLocations } from "@/lib/query/hooks/useLocations"; import { AssetStatus, AssetCondition } from "@/lib/query/types/asset"; const STATUS_OPTIONS = ["All", ...Object.values(AssetStatus)]; export default function AssetsPage() { const router = useRouter(); + const searchParams = useSearchParams(); const [showModal, setShowModal] = useState(false); const [search, setSearch] = useState(""); const [status, setStatus] = useState(""); const [page, setPage] = useState(1); + const [locationId, setLocationId] = useState(searchParams.get("locationId") || ""); + + const { data: locations = [] } = useLocations(); + const activeLocation = locations.find((l) => l.id === locationId); const { data, isLoading, refetch } = useAssets({ search: search || undefined, status: (status as AssetStatus) || undefined, + locationId: locationId || undefined, page, limit: 20, }); @@ -96,6 +103,26 @@ export default function AssetsPage() { + {activeLocation && ( +
+ + + {activeLocation.name} + + +
+ )} + {/* Table */}
diff --git a/frontend/app/(dashboard)/audits/[id]/page.tsx b/frontend/app/(dashboard)/audits/[id]/page.tsx new file mode 100644 index 000000000..2a0e3575d --- /dev/null +++ b/frontend/app/(dashboard)/audits/[id]/page.tsx @@ -0,0 +1,242 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { ArrowLeft, Search, CheckCircle2, Lock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { CsvExportButton } from "@/components/ui/csv"; +import { + useAuditSession, + useRecordAuditItem, + useCompleteAuditSession, +} from "@/lib/query/hooks/useAudits"; +import { useAssets } from "@/lib/query/hooks/useAssets"; +import { useLocations } from "@/lib/query/hooks/useLocations"; +import { AuditItem, AuditItemResult } from "@/lib/api/audits"; + +const RESULT_OPTIONS: { value: AuditItemResult; label: string; className: string }[] = [ + { value: "FOUND", label: "Found", className: "bg-green-100 text-green-700 border-green-200" }, + { value: "MISSING", label: "Missing", className: "bg-red-100 text-red-700 border-red-200" }, + { value: "WRONG_LOCATION", label: "Wrong Location", className: "bg-amber-100 text-amber-700 border-amber-200" }, + { value: "DAMAGED", label: "Damaged", className: "bg-orange-100 text-orange-700 border-orange-200" }, +]; + +const resultLabel: Record = { + PENDING: "Pending", + FOUND: "Found", + MISSING: "Missing", + WRONG_LOCATION: "Wrong Location", + DAMAGED: "Damaged", +}; + +export default function AuditSessionPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const { data: session, isLoading } = useAuditSession(params.id); + const { data: assetsPage } = useAssets({ limit: 500 }); + const { data: locations = [] } = useLocations(); + const recordItem = useRecordAuditItem(params.id); + const completeSession = useCompleteAuditSession(params.id); + + const [search, setSearch] = useState(""); + const [showCompleteConfirm, setShowCompleteConfirm] = useState(false); + const [noteDrafts, setNoteDrafts] = useState>({}); + + const assetMap = useMemo(() => { + const map = new Map(); + (assetsPage?.data ?? []).forEach((a) => map.set(a.id, a.name)); + return map; + }, [assetsPage]); + + const locationMap = useMemo(() => { + const map = new Map(); + locations.forEach((l) => map.set(l.id, l.name)); + return map; + }, [locations]); + + if (isLoading || !session) { + return
Loading audit session...
; + } + + const isCompleted = session.status === "COMPLETED"; + const filteredItems = session.items.filter((item) => { + if (!search.trim()) return true; + const assetName = assetMap.get(item.assetId) || ""; + return assetName.toLowerCase().includes(search.trim().toLowerCase()); + }); + + const discrepancies = session.items.filter((i) => i.result !== "PENDING" && i.result !== "FOUND"); + + const handleRecord = (item: AuditItem, result: AuditItemResult) => { + recordItem.mutate({ + itemId: item.id, + data: { result, note: noteDrafts[item.id] ?? item.note ?? undefined }, + }); + }; + + return ( +
+ + +
+

{session.name}

+
+ {isCompleted && ( + + + Immutable + + )} + + {session.status.replace("_", " ")} + +
+
+ +
+
+
+
+ + {session.checkedItems}/{session.totalItems} checked ({session.progressPercent}%) + +
+ + {isCompleted && ( +
+
+
+

Discrepancy Report

+

+ {discrepancies.length} item{discrepancies.length !== 1 ? "s" : ""} not matching expectations +

+
+ ({ + asset: assetMap.get(d.assetId) || d.assetId, + expectedLocation: d.expectedLocationId ? locationMap.get(d.expectedLocationId) || "" : "", + result: resultLabel[d.result], + note: d.note || "", + checkedAt: d.checkedAt || "", + }))} + columns={[ + { key: "asset", label: "Asset" }, + { key: "expectedLocation", label: "Expected Location" }, + { key: "result", label: "Result" }, + { key: "note", label: "Note" }, + { key: "checkedAt", label: "Checked At" }, + ]} + /> +
+ + {discrepancies.length === 0 ? ( +

+ + No discrepancies — every asset was found where expected. +

+ ) : ( +
    + {discrepancies.map((d) => ( +
  • + {assetMap.get(d.assetId) || d.assetId} + {resultLabel[d.result]}{d.note ? ` — ${d.note}` : ""} +
  • + ))} +
+ )} +
+ )} + + {!isCompleted && ( +
+
+ + setSearch(e.target.value)} + placeholder="Search assets to verify..." + className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-gray-900" + /> +
+ +
+ )} + +
+ {filteredItems.map((item) => ( +
+
+
+

{assetMap.get(item.assetId) || item.assetId}

+

+ Expected: {item.expectedLocationId ? locationMap.get(item.expectedLocationId) || "—" : "No location set"} +

+
+ {resultLabel[item.result]} +
+ + {!isCompleted && ( +
+ {RESULT_OPTIONS.map((opt) => ( + + ))} + setNoteDrafts((prev) => ({ ...prev, [item.id]: e.target.value }))} + onBlur={() => { + if (item.result !== "PENDING") handleRecord(item, item.result); + }} + className="flex-1 min-w-[140px] text-xs border border-gray-200 rounded-lg px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-gray-900" + /> +
+ )} + {isCompleted && item.note && ( +

{item.note}

+ )} +
+ ))} + {filteredItems.length === 0 && ( +

No assets match your search.

+ )} +
+ + {showCompleteConfirm && ( + { + await completeSession.mutateAsync(); + setShowCompleteConfirm(false); + }} + onCancel={() => setShowCompleteConfirm(false)} + loading={completeSession.isPending} + /> + )} +
+ ); +} diff --git a/frontend/app/(dashboard)/audits/page.tsx b/frontend/app/(dashboard)/audits/page.tsx new file mode 100644 index 000000000..fb462ecbe --- /dev/null +++ b/frontend/app/(dashboard)/audits/page.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { ClipboardCheck, Plus, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useAuditSessions, useCreateAuditSession } from "@/lib/query/hooks/useAudits"; +import { useDepartmentsList } from "@/lib/query/hooks/useAssets"; +import { useLocations } from "@/lib/query/hooks/useLocations"; +import { AuditSessionStatus } from "@/lib/api/audits"; + +const statusStyles: Record = { + DRAFT: "bg-gray-100 text-gray-600", + IN_PROGRESS: "bg-blue-100 text-blue-700", + COMPLETED: "bg-green-100 text-green-700", +}; + +function errorMessage(err: unknown, fallback: string): string { + return ( + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback + ); +} + +export default function AuditsPage() { + const router = useRouter(); + const { data: sessions = [], isLoading } = useAuditSessions(); + const [showWizard, setShowWizard] = useState(false); + + return ( +
+
+
+

Audits / Stocktake

+

+ Verify physical assets exist where the system says they do +

+
+ +
+ + {isLoading ? ( +
Loading audit sessions...
+ ) : sessions.length === 0 ? ( +
+
+ +
+

No audit sessions yet

+

+ Click "Start Audit" to run your first stocktake. +

+
+ ) : ( +
+ {sessions.map((session) => ( + + ))} +
+ )} + + {showWizard && setShowWizard(false)} />} +
+ ); +} + +function StartAuditWizard({ onClose }: { onClose: () => void }) { + const router = useRouter(); + const { data: departments = [] } = useDepartmentsList(); + const { data: locations = [] } = useLocations(); + const createSession = useCreateAuditSession(); + + const [name, setName] = useState(""); + const [scopeType, setScopeType] = useState<"department" | "location">("department"); + const [scopeId, setScopeId] = useState(""); + const [error, setError] = useState(""); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !scopeId) { + setError("Name and scope are required"); + return; + } + setError(""); + try { + const session = await createSession.mutateAsync({ + name: name.trim(), + departmentId: scopeType === "department" ? scopeId : undefined, + locationId: scopeType === "location" ? scopeId : undefined, + }); + router.push(`/audits/${session.id}`); + } catch (err) { + setError(errorMessage(err, "Failed to start audit session.")); + } + }; + + return ( +
+
+
+
+

Start Audit

+ +
+ +
+ setName(e.target.value)} + /> + +
+ + +
+ +
+ + +
+ +

+ The system will generate a checklist from every asset currently assigned to this scope. +

+ + {error && ( +

{error}

+ )} + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/licenses/page.tsx b/frontend/app/(dashboard)/licenses/page.tsx new file mode 100644 index 000000000..5469f069e --- /dev/null +++ b/frontend/app/(dashboard)/licenses/page.tsx @@ -0,0 +1,481 @@ +"use client"; + +import { useState } from "react"; +import { Plus, KeyRound, Eye, EyeOff, X, UserMinus, AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { + useLicenses, + useCreateLicense, + useUpdateLicense, + useDeleteLicense, + useRevealLicenseKey, + useLicenseAssignments, + useAssignSeat, + useUnassignSeat, +} from "@/lib/query/hooks/useLicenses"; +import { useUsers } from "@/lib/query/hooks/useAssets"; +import { License, LicenseType, BillingPeriod } from "@/lib/api/licenses"; + +function errorMessage(err: unknown, fallback: string): string { + return ( + (err as { response?: { data?: { message?: string } } })?.response?.data?.message || fallback + ); +} + +function formatDate(value?: string | null) { + if (!value) return "—"; + return new Date(value).toLocaleDateString(); +} + +export default function LicensesPage() { + const { data: licenses = [], isLoading } = useLicenses(); + const [formTarget, setFormTarget] = useState<{ mode: "create" | "edit"; license?: License } | null>( + null, + ); + const [detailLicense, setDetailLicense] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const deleteLicense = useDeleteLicense(); + + const handleDelete = async () => { + if (!deleteTarget) return; + await deleteLicense.mutateAsync(deleteTarget.id); + setDeleteTarget(null); + }; + + return ( +
+
+
+

Licenses & Subscriptions

+

+ Track software seats, costs and renewal dates +

+
+ +
+ +
+
+ + + + + + + + + + + + + {isLoading ? ( + + + + ) : licenses.length === 0 ? ( + + + + ) : ( + licenses.map((lic) => { + const pct = lic.seatsTotal > 0 ? Math.round((lic.seatsUsed / lic.seatsTotal) * 100) : 0; + return ( + setDetailLicense(lic)} + className="border-b border-gray-100 hover:bg-gray-50 cursor-pointer transition-colors" + > + + + + + + + + ); + }) + )} + +
NameVendorSeatsCostRenewalStatus
+ Loading licenses... +
+ No licenses yet. Click "Add License" to get started. +
{lic.name}{lic.vendorId || "—"} +
+
+
= 100 ? "bg-red-500" : "bg-gray-900"}`} + style={{ width: `${Math.min(pct, 100)}%` }} + /> +
+ + {lic.seatsUsed}/{lic.seatsTotal} + +
+
+ {lic.currency} {lic.cost.toLocaleString()} / {lic.billingPeriod.toLowerCase()} + + + {lic.renewsSoon && } + {formatDate(lic.expiryDate)} + + + + {lic.type} + +
+
+
+ + {formTarget && ( + setFormTarget(null)} + /> + )} + + {detailLicense && ( + l.id === detailLicense.id) || detailLicense} + onClose={() => setDetailLicense(null)} + onEdit={() => { + setFormTarget({ mode: "edit", license: detailLicense }); + setDetailLicense(null); + }} + onDelete={() => { + setDeleteTarget(detailLicense); + setDetailLicense(null); + }} + /> + )} + + {deleteTarget && ( + setDeleteTarget(null)} + loading={deleteLicense.isPending} + /> + )} +
+ ); +} + +function LicenseFormModal({ + mode, + license, + onClose, +}: { + mode: "create" | "edit"; + license?: License; + onClose: () => void; +}) { + const [name, setName] = useState(license?.name || ""); + const [vendorId, setVendorId] = useState(license?.vendorId || ""); + const [licenseKey, setLicenseKey] = useState(""); + const [type, setType] = useState(license?.type || LicenseType.SUBSCRIPTION); + const [billingPeriod, setBillingPeriod] = useState( + license?.billingPeriod || BillingPeriod.YEARLY, + ); + const [seatsTotal, setSeatsTotal] = useState(String(license?.seatsTotal ?? 1)); + const [cost, setCost] = useState(String(license?.cost ?? 0)); + const [currency, setCurrency] = useState(license?.currency || "USD"); + const [startDate, setStartDate] = useState(license?.startDate?.slice(0, 10) || ""); + const [expiryDate, setExpiryDate] = useState(license?.expiryDate?.slice(0, 10) || ""); + const [notes, setNotes] = useState(license?.notes || ""); + const [error, setError] = useState(""); + + const createLicense = useCreateLicense(); + const updateLicense = useUpdateLicense(); + const isPending = createLicense.isPending || updateLicense.isPending; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || (mode === "create" && !licenseKey.trim())) { + setError("Name and license key are required"); + return; + } + setError(""); + const payload = { + name: name.trim(), + vendorId: vendorId.trim() || undefined, + type, + billingPeriod, + seatsTotal: Number(seatsTotal) || 1, + cost: Number(cost) || 0, + currency, + startDate: startDate || undefined, + expiryDate: expiryDate || undefined, + notes: notes.trim() || undefined, + }; + try { + if (mode === "create") { + await createLicense.mutateAsync({ ...payload, licenseKey: licenseKey.trim() }); + } else if (license) { + await updateLicense.mutateAsync({ id: license.id, data: payload }); + } + onClose(); + } catch (err) { + setError(errorMessage(err, "Failed to save license.")); + } + }; + + return ( +
+
+
+
+

+ {mode === "create" ? "Add License" : "Edit License"} +

+ +
+ +
+
+ setName(e.target.value)} /> + setVendorId(e.target.value)} /> +
+ + {mode === "create" && ( + setLicenseKey(e.target.value)} + /> + )} + +
+
+ + +
+
+ + +
+ setSeatsTotal(e.target.value)} /> +
+ +
+ setCost(e.target.value)} /> + setCurrency(e.target.value)} /> +
+ +
+ setStartDate(e.target.value)} /> + setExpiryDate(e.target.value)} /> +
+ +
+ +