Super Admin System Device Inventory Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a dedicated Super Admin system device inventory module at /system/devices with explicit system-scope RBAC, system-owned device lifecycle fields, safe delete rules, organization assignment actions, and a separate frontend UX from the tenant /devices flow.

Architecture: Keep the existing Device entity as the single source of truth, but extend it with inventory metadata and expose that data through a new vertical slice: SystemDeviceInventoryController + SystemDeviceInventoryService on the backend and a separate /system/devices page on the frontend. Preserve the legacy tenant /devices slice for organization operations, but make device system scope explicit in RBAC so enabling system inventory does not accidentally widen existing device:read:all tenant permissions.

Tech Stack: NestJS, TypeORM, PostgreSQL migrations, Jest, React 19, React Router, TanStack Query, Ant Design, Vitest, Testing Library, Bun, Biome

Assumptions: assignedAt is sticky history and is not cleared on unassign, so delete rules can detect “ever assigned” devices without introducing a separate audit table. Moving a device to maintenance clears organizationId, pondId, and areaId, because maintenance is treated as a system-inventory state rather than an organization-operational state.


File Structure

Create

  • backend/src/devices/enums/device-inventory-status.enum.ts
    Responsibility: canonical inventory lifecycle enum shared by entity, DTOs, and tests.
  • backend/src/devices/device-identity.util.ts
    Responsibility: pure helpers for system-generated device name/serial previews.
  • backend/src/devices/device-identity.util.spec.ts
    Responsibility: unit tests for preview prefix and sequence parsing rules.
  • backend/src/devices/system-device-inventory.service.ts
    Responsibility: system-scope query, create/update/delete, assignment, maintenance, retire, and preview logic.
  • backend/src/devices/system-device-inventory.service.spec.ts
    Responsibility: service tests for system inventory behaviors and safety rules.
  • backend/src/devices/controllers/system-device-inventory.controller.ts
    Responsibility: /system/devices HTTP contract with explicit device:*:system permissions.
  • backend/src/devices/controllers/system-device-inventory.controller.spec.ts
    Responsibility: controller delegation coverage for the new route surface.
  • backend/src/devices/dto/query-system-device-inventory.dto.ts
    Responsibility: list query validation for search and inventory filters.
  • backend/src/devices/dto/create-system-device.dto.ts
    Responsibility: quick-create payload validation for system device creation.
  • backend/src/devices/dto/update-system-device.dto.ts
    Responsibility: edit payload validation for system inventory records.
  • backend/src/devices/dto/assign-system-device.dto.ts
    Responsibility: organization assignment payload validation.
  • backend/src/devices/dto/update-system-device-inventory-status.dto.ts
    Responsibility: validate maintenance and retired transitions.
  • backend/src/devices/dto/generate-system-device-identity-query.dto.ts
    Responsibility: validate preview generation query input.
  • backend/src/devices/dto/system-device-response.dto.ts
    Responsibility: Swagger-visible response contract for system inventory rows.
  • backend/src/migrations/1777282800000-AddSystemDeviceInventoryFields.ts
    Responsibility: make organization_id nullable, add inventory columns, and grant Super Admin explicit device:*:system permissions.
  • frontend/src/types/system-device.types.ts
    Responsibility: system inventory query, row, mutation, and preview types.
  • frontend/src/services/system-device-inventory.service.ts
    Responsibility: API client for /system/devices endpoints.
  • frontend/src/hooks/useSystemDeviceInventory.ts
    Responsibility: TanStack Query hooks and mutations for the system inventory page.
  • frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx
    Responsibility: isolated Super Admin inventory screen.
  • frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx
    Responsibility: UI coverage for preview autofill, filters, and inventory actions.
  • frontend/src/routes/PermissionRoute.spec.tsx
    Responsibility: route guard coverage for roles + permission checks.

Modify

  • backend/src/devices/entities/device.entity.ts
    Add nullable ownership and inventory lifecycle columns while preserving the legacy operational status column.
  • backend/src/devices/devices.module.ts
    Register the new controller/service and add Organization repository access.
  • backend/src/rbac/resource-scope-map.ts
    Mark device as system-capable without treating tenant all scope as implicit system access.
  • backend/src/rbac/authorization-policy.service.ts
    Separate all from system for explicit-only resources and deny null-organization tenant access.
  • backend/src/rbac/authorization-policy.service.spec.ts
    Lock down the explicit device system-scope behavior.
  • frontend/src/routes/PermissionRoute.tsx
    Allow route-level role gating in addition to permission gating.
  • frontend/src/App.tsx
    Register /system/devices behind Super Admin-only route protection.
  • frontend/src/navigation/menus/super-admin.menu.tsx
    Point the Super Admin device inventory menu to /system/devices and remove the duplicate assignment entry.
  • frontend/src/navigation/nav-resolver.spec.ts
    Assert the updated Super Admin navigation surface.

Task 1: Harden RBAC For Explicit Device System Scope

Files:

  • Modify: backend/src/rbac/resource-scope-map.ts

  • Modify: backend/src/rbac/authorization-policy.service.ts

  • Test: backend/src/rbac/authorization-policy.service.spec.ts

  • Step 1: Write the failing RBAC tests

Add these cases to backend/src/rbac/authorization-policy.service.spec.ts:

it('keeps device:read:all tenant-scoped even after device supports system scope', () => {
  const user = createUser({ permissions: ['device:read:all'], organizationId: 'org-1' });

  const sameOrg = service.isTenantResourceAccessible(user, {
    resource: 'device',
    organizationId: 'org-1',
  });
  const crossOrg = service.isTenantResourceAccessible(user, {
    resource: 'device',
    organizationId: 'org-2',
  });
  const unassignedSystemInventory = service.isTenantResourceAccessible(user, {
    resource: 'device',
    organizationId: null,
  });

  expect(sameOrg).toBe(true);
  expect(crossOrg).toBe(false);
  expect(unassignedSystemInventory).toBe(false);
});

it('allows explicit device:read:system for unassigned inventory devices', () => {
  const user = createUser({ permissions: ['device:read:system'], organizationId: null });

  const allowed = service.isTenantResourceAccessible(user, {
    resource: 'device',
    organizationId: null,
  });

  expect(allowed).toBe(true);
});

it('does not add organization filters for explicit system reads on device', () => {
  const user = createUser({ permissions: ['device:read:system'], organizationId: null });
  const { qb, andWhere } = createQueryBuilderMock();

  service.applyReadScopeToQueryBuilder(qb, user, {
    resource: 'device',
    alias: 'device',
  });

  expect(andWhere).not.toHaveBeenCalledWith('device.organizationId = :scopeOrgId', {
    scopeOrgId: 'org-1',
  });
});
  • Step 2: Run the RBAC test to verify it fails

Run: cd backend && bun run test -- src/rbac/authorization-policy.service.spec.ts --runInBand

Expected: FAIL because device:read:all still resolves to system access once device becomes system-capable, and null-organization tenant access is not denied.

  • Step 3: Implement explicit-only system scope for device

Update backend/src/rbac/resource-scope-map.ts:

export type ResourceAssignmentMode = 'none' | 'direct' | 'by-pond';
export type SystemScopeMode = 'implicit-all' | 'explicit-only';

export interface ResourceScopeConfig {
  tenantResource: boolean;
  assignmentMode: ResourceAssignmentMode;
  supportsSystemScope?: boolean;
  systemScopeMode?: SystemScopeMode;
}

device: {
  tenantResource: true,
  assignmentMode: 'by-pond',
  supportsSystemScope: true,
  systemScopeMode: 'explicit-only',
},
organization: {
  tenantResource: true,
  assignmentMode: 'none',
  supportsSystemScope: true,
  systemScopeMode: 'implicit-all',
},

Update backend/src/rbac/authorization-policy.service.ts:

const systemScopeMode = config.systemScopeMode ?? 'implicit-all';

if (matchedScopes.some((scope) => scope === '*')) {
  return config.supportsSystemScope ? 'system' : 'all';
}

if (matchedScopes.some((scope) => scope === 'system')) {
  return config.supportsSystemScope ? 'system' : 'all';
}

if (matchedScopes.some((scope) => scope === 'all')) {
  return config.supportsSystemScope && systemScopeMode === 'implicit-all'
    ? 'system'
    : 'all';
}

if (config.tenantResource && context.organizationId == null && scope !== 'system') {
  return false;
}
  • Step 4: Run the RBAC test to verify it passes

Run: cd backend && bun run test -- src/rbac/authorization-policy.service.spec.ts --runInBand

Expected: PASS with the new explicit-system device coverage and no regressions in the existing auth policy suite.

  • Step 5: Commit
git add backend/src/rbac/resource-scope-map.ts backend/src/rbac/authorization-policy.service.ts backend/src/rbac/authorization-policy.service.spec.ts
git commit -m "test: lock device system scope behind explicit permissions"

Task 2: Add Inventory Lifecycle Fields And Preview Utility Foundation

Files:

  • Create: backend/src/devices/enums/device-inventory-status.enum.ts

  • Create: backend/src/devices/device-identity.util.ts

  • Test: backend/src/devices/device-identity.util.spec.ts

  • Create: backend/src/migrations/1777282800000-AddSystemDeviceInventoryFields.ts

  • Modify: backend/src/devices/entities/device.entity.ts

  • Modify: backend/src/devices/devices.module.ts

  • Step 1: Write the failing preview utility test

Create backend/src/devices/device-identity.util.spec.ts:

import {
  buildGeneratedDeviceIdentity,
  getNextGeneratedSequence,
  parseGeneratedSequence,
} from '@/devices/device-identity.util';

describe('device identity util', () => {
  it('builds default preview values per device type', () => {
    expect(buildGeneratedDeviceIdentity('sensor', 1)).toEqual({
      type: 'sensor',
      sequence: 1,
      name: 'Sensor 001',
      serialNumber: 'SNS-001',
    });
    expect(buildGeneratedDeviceIdentity('feeder', 7)).toEqual({
      type: 'feeder',
      sequence: 7,
      name: 'Feeder 007',
      serialNumber: 'FDR-007',
    });
  });

  it('ignores custom serials when computing the next default sequence', () => {
    expect(getNextGeneratedSequence('gateway', ['GTW-001', 'GTW-009', 'CUSTOM-GW'])).toBe(10);
  });

  it('parses only matching default serial patterns', () => {
    expect(parseGeneratedSequence('inverter', 'INV-014')).toBe(14);
    expect(parseGeneratedSequence('inverter', 'SNS-014')).toBeNull();
  });
});
  • Step 2: Run the utility test to verify it fails

Run: cd backend && bun run test -- src/devices/device-identity.util.spec.ts --runInBand

Expected: FAIL with module-not-found errors for device-identity.util.

  • Step 3: Implement inventory fields, module wiring, migration, and preview utility

Create backend/src/devices/enums/device-inventory-status.enum.ts:

export enum DeviceInventoryStatus {
  IN_INVENTORY = 'in_inventory',
  ASSIGNED = 'assigned',
  MAINTENANCE = 'maintenance',
  RETIRED = 'retired',
}

Create backend/src/devices/device-identity.util.ts:

export const DEVICE_IDENTITY_RULES = {
  sensor: { namePrefix: 'Sensor', serialPrefix: 'SNS' },
  gateway: { namePrefix: 'Gateway', serialPrefix: 'GTW' },
  inverter: { namePrefix: 'Inverter', serialPrefix: 'INV' },
  feeder: { namePrefix: 'Feeder', serialPrefix: 'FDR' },
} as const;

type DeviceIdentityType = keyof typeof DEVICE_IDENTITY_RULES;

const formatSequence = (sequence: number) => sequence.toString().padStart(3, '0');

export function buildGeneratedDeviceIdentity(type: DeviceIdentityType, sequence: number) {
  const rule = DEVICE_IDENTITY_RULES[type];
  const suffix = formatSequence(sequence);

  return {
    type,
    sequence,
    name: `${rule.namePrefix} ${suffix}`,
    serialNumber: `${rule.serialPrefix}-${suffix}`,
  };
}

export function parseGeneratedSequence(
  type: DeviceIdentityType,
  serialNumber: string,
): number | null {
  const { serialPrefix } = DEVICE_IDENTITY_RULES[type];
  const match = new RegExp(`^${serialPrefix}-(\\d{3})$`).exec(serialNumber);
  return match ? Number(match[1]) : null;
}

export function getNextGeneratedSequence(type: DeviceIdentityType, serialNumbers: string[]) {
  return (
    serialNumbers.reduce((max, current) => {
      const parsed = parseGeneratedSequence(type, current);
      return parsed && parsed > max ? parsed : max;
    }, 0) + 1
  );
}

Update backend/src/devices/entities/device.entity.ts:

@Column({ name: 'organization_id', type: 'uuid', nullable: true })
organizationId: string | null;

@Column({
  name: 'inventory_status',
  type: 'enum',
  enum: DeviceInventoryStatus,
  default: DeviceInventoryStatus.IN_INVENTORY,
})
inventoryStatus: DeviceInventoryStatus;

@Column({ name: 'assigned_at', type: 'timestamptz', nullable: true })
assignedAt: Date | null;

@Column({ name: 'retired_at', type: 'timestamptz', nullable: true })
retiredAt: Date | null;

Create backend/src/migrations/1777282800000-AddSystemDeviceInventoryFields.ts:

import type { MigrationInterface, QueryRunner } from 'typeorm';

export class AddSystemDeviceInventoryFields1777282800000 implements MigrationInterface {
  name = 'AddSystemDeviceInventoryFields1777282800000';

  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      ALTER TABLE devices ALTER COLUMN organization_id DROP NOT NULL
    `);
    await queryRunner.query(`
      CREATE TYPE devices_inventory_status_enum AS ENUM
      ('in_inventory', 'assigned', 'maintenance', 'retired')
    `);
    await queryRunner.query(`
      ALTER TABLE devices
      ADD COLUMN inventory_status devices_inventory_status_enum NOT NULL DEFAULT 'in_inventory',
      ADD COLUMN assigned_at timestamptz NULL,
      ADD COLUMN retired_at timestamptz NULL
    `);
    await queryRunner.query(`
      UPDATE roles
      SET permissions = permissions || '[
        {"resource":"device","action":"create","scope":"system"},
        {"resource":"device","action":"read","scope":"system"},
        {"resource":"device","action":"update","scope":"system"},
        {"resource":"device","action":"delete","scope":"system"},
        {"resource":"device","action":"assign","scope":"system"}
      ]'::jsonb
      WHERE id = '00000000-0000-0000-0000-000000000001'
    `);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(`
      UPDATE roles
      SET permissions = (
        SELECT jsonb_agg(permission)
        FROM jsonb_array_elements(permissions) permission
        WHERE permission NOT IN (
          '{"resource":"device","action":"create","scope":"system"}'::jsonb,
          '{"resource":"device","action":"read","scope":"system"}'::jsonb,
          '{"resource":"device","action":"update","scope":"system"}'::jsonb,
          '{"resource":"device","action":"delete","scope":"system"}'::jsonb,
          '{"resource":"device","action":"assign","scope":"system"}'::jsonb
        )
      )
      WHERE id = '00000000-0000-0000-0000-000000000001'
    `);
    await queryRunner.query(`
      ALTER TABLE devices
      DROP COLUMN retired_at,
      DROP COLUMN assigned_at,
      DROP COLUMN inventory_status
    `);
    await queryRunner.query(`DROP TYPE devices_inventory_status_enum`);
    await queryRunner.query(`
      ALTER TABLE devices ALTER COLUMN organization_id SET NOT NULL
    `);
  }
}

Update backend/src/devices/devices.module.ts:

TypeOrmModule.forFeature([
  Device,
  Pond,
  Area,
  DeviceTelemetry,
  AlertConfig,
  DeviceAlert,
  User,
  Organization,
]),
  • Step 4: Run the utility test to verify it passes

Run: cd backend && bun run test -- src/devices/device-identity.util.spec.ts --runInBand

Expected: PASS with 3 passing tests for preview generation rules.

  • Step 5: Commit
git add backend/src/devices/enums/device-inventory-status.enum.ts backend/src/devices/device-identity.util.ts backend/src/devices/device-identity.util.spec.ts backend/src/devices/entities/device.entity.ts backend/src/devices/devices.module.ts backend/src/migrations/1777282800000-AddSystemDeviceInventoryFields.ts
git commit -m "feat: add system device inventory schema foundation"

Task 3: Implement System Inventory Read, Create, Preview, And Safe Delete APIs

Files:

  • Create: backend/src/devices/system-device-inventory.service.ts

  • Create: backend/src/devices/system-device-inventory.service.spec.ts

  • Create: backend/src/devices/controllers/system-device-inventory.controller.ts

  • Create: backend/src/devices/controllers/system-device-inventory.controller.spec.ts

  • Create: backend/src/devices/dto/query-system-device-inventory.dto.ts

  • Create: backend/src/devices/dto/create-system-device.dto.ts

  • Create: backend/src/devices/dto/update-system-device.dto.ts

  • Create: backend/src/devices/dto/generate-system-device-identity-query.dto.ts

  • Create: backend/src/devices/dto/system-device-response.dto.ts

  • Modify: backend/src/devices/devices.module.ts

  • Step 1: Write the failing service and controller tests

Create backend/src/devices/system-device-inventory.service.spec.ts with these cases:

it('lists devices across organizations for a system-scoped actor', async () => {
  deviceRepository.createQueryBuilder.mockReturnValue(mockQb);
  mockQb.getMany.mockResolvedValue([
    buildMockDevice({ organizationId: null, inventoryStatus: DeviceInventoryStatus.IN_INVENTORY }),
    buildMockDevice({
      id: 'device-2',
      organizationId: 'org-2',
      inventoryStatus: DeviceInventoryStatus.ASSIGNED,
    }),
  ]);
  mockQb.getCount.mockResolvedValue(2);

  const result = await service.findAll(systemUser, { inventoryStatus: 'assigned' });

  expect(result.meta.total).toBe(2);
  expect(result.data[1].organization?.id).toBe('org-2');
});

it('creates a system inventory device with null organization and in_inventory status', async () => {
  deviceRepository.find.mockResolvedValueOnce([]);
  mockManager.save.mockImplementation(async (device) => ({
    ...buildMockDevice(),
    ...device,
    organizationId: null,
    inventoryStatus: DeviceInventoryStatus.IN_INVENTORY,
  }));

  const result = await service.create(
    { type: 'sensor', name: 'Sensor 001', serialNumber: 'SNS-001', model: undefined },
    systemUser,
  );

  expect(result.organizationId).toBeNull();
  expect(result.inventoryStatus).toBe(DeviceInventoryStatus.IN_INVENTORY);
});

it('returns the next generated identity preview for the selected type', async () => {
  deviceRepository.find.mockResolvedValueOnce([
    { serialNumber: 'SNS-001' },
    { serialNumber: 'SNS-004' },
    { serialNumber: 'CUSTOM-SNS' },
  ] as Device[]);

  await expect(service.generateIdentity('sensor')).resolves.toEqual({
    type: 'sensor',
    sequence: 5,
    name: 'Sensor 005',
    serialNumber: 'SNS-005',
  });
});

it('rejects hard delete for devices with telemetry or assignment history', async () => {
  deviceRepository.findOne.mockResolvedValueOnce(
    buildMockDevice({ organizationId: null, assignedAt: new Date('2026-05-01') }),
  );

  await expect(service.remove('device-1', systemUser)).rejects.toThrow(BadRequestException);
});

Create backend/src/devices/controllers/system-device-inventory.controller.spec.ts with this delegation check:

it('delegates identity preview generation to the system inventory service', async () => {
  service.generateIdentity.mockResolvedValue({
    type: 'gateway',
    sequence: 3,
    name: 'Gateway 003',
    serialNumber: 'GTW-003',
  });

  await expect(controller.generateIdentity({ type: 'gateway' })).resolves.toEqual({
    type: 'gateway',
    sequence: 3,
    name: 'Gateway 003',
    serialNumber: 'GTW-003',
  });
});
  • Step 2: Run the new backend tests to verify they fail

Run: cd backend && bun run test -- src/devices/system-device-inventory.service.spec.ts src/devices/controllers/system-device-inventory.controller.spec.ts --runInBand

Expected: FAIL with missing service/controller/DTO files.

  • Step 3: Implement the system inventory service, DTOs, and controller

Create backend/src/devices/dto/query-system-device-inventory.dto.ts:

export class QuerySystemDeviceInventoryDto {
  @IsOptional() @Type(() => Number) @IsNumber() @Min(1) page?: number;
  @IsOptional() @Type(() => Number) @IsNumber() @Min(1) limit?: number;
  @IsOptional() @IsString() search?: string;
  @IsOptional() @IsEnum(['sensor', 'gateway', 'inverter', 'feeder']) type?: Device['type'];
  @IsOptional() @IsEnum(DeviceInventoryStatus) inventoryStatus?: DeviceInventoryStatus;
  @IsOptional() @IsEnum(['online', 'offline', 'error']) operationalStatus?: Device['status'];
  @IsOptional() @IsUUID() organizationId?: string;
  @IsOptional() @IsBooleanString() unassigned?: 'true' | 'false';
}

Create backend/src/devices/system-device-inventory.service.ts:

async findAll(currentUser: CurrentUserData, query: QuerySystemDeviceInventoryDto) {
  this.assertSystemScope(currentUser, 'read');

  const page = query.page || 1;
  const limit = Math.min(query.limit || 20, 100);
  const qb = this.deviceRepository
    .createQueryBuilder('device')
    .leftJoinAndSelect('device.organization', 'organization');

  if (query.search) {
    qb.andWhere('(device.name ILIKE :search OR device.serialNumber ILIKE :search)', {
      search: `%${query.search}%`,
    });
  }
  if (query.type) qb.andWhere('device.type = :type', { type: query.type });
  if (query.inventoryStatus) {
    qb.andWhere('device.inventoryStatus = :inventoryStatus', {
      inventoryStatus: query.inventoryStatus,
    });
  }
  if (query.operationalStatus) {
    qb.andWhere('device.status = :operationalStatus', {
      operationalStatus: query.operationalStatus,
    });
  }
  if (query.organizationId) {
    qb.andWhere('device.organizationId = :organizationId', { organizationId: query.organizationId });
  }
  if (query.unassigned === 'true') {
    qb.andWhere('device.organizationId IS NULL');
  }

  qb.orderBy('device.createdAt', 'DESC');
  const total = await qb.getCount();
  const devices = await qb.skip((page - 1) * limit).take(limit).getMany();

  return {
    data: devices.map((device) => this.toResponse(device)),
    meta: { total, page, limit, totalPages: Math.ceil(total / limit) },
  };
}

async create(dto: CreateSystemDeviceDto, currentUser: CurrentUserData) {
  this.assertSystemScope(currentUser, 'create');

  const existingDevice = await this.deviceRepository.findOne({
    where: { serialNumber: dto.serialNumber },
  });
  if (existingDevice) {
    throw new BadRequestException('Device with this serial number already exists');
  }

  return this.dataSource.transaction(async (manager) => {
    const device = manager.create(Device, {
      serialNumber: dto.serialNumber,
      name: dto.name,
      type: dto.type,
      model: dto.model ?? null,
      capabilities: dto.capabilities ?? null,
      organizationId: null,
      inventoryStatus: DeviceInventoryStatus.IN_INVENTORY,
      status: 'offline',
      pondId: null,
      areaId: null,
      assignedAt: null,
      retiredAt: null,
      warrantyMonths: dto.warrantyMonths ?? 12,
      ownershipType: dto.ownershipType ?? 'sold',
      accessToken: this.generateAccessToken(),
      secret: this.generateSecret(),
      mqttTopic: `hmpiot/devices/${dto.serialNumber}/telemetry`,
      isActive: true,
    });

    return this.toResponse(await manager.save(device));
  });
}

async generateIdentity(type: Device['type']) {
  const serials = await this.deviceRepository.find({
    where: { type },
    select: { serialNumber: true },
  });

  return buildGeneratedDeviceIdentity(
    type,
    getNextGeneratedSequence(type, serials.map((device) => device.serialNumber)),
  );
}

Create backend/src/devices/controllers/system-device-inventory.controller.ts:

@ApiTags('System Device Inventory')
@UseGuards(JwtAuthGuard, PermissionsGuard)
@ApiBearerAuth()
@Controller('system/devices')
export class SystemDeviceInventoryController {
  constructor(private readonly service: SystemDeviceInventoryService) {}

  @Get()
  @RequirePermissions('device:read:system')
  findAll(@CurrentUser() currentUser: CurrentUserData, @Query() query: QuerySystemDeviceInventoryDto) {
    return this.service.findAll(currentUser, query);
  }

  @Get('generate-identity')
  @RequirePermissions('device:read:system')
  generateIdentity(@Query() query: GenerateSystemDeviceIdentityQueryDto) {
    return this.service.generateIdentity(query.type);
  }

  @Post()
  @HttpCode(HttpStatus.CREATED)
  @RequirePermissions('device:create:system')
  create(@CurrentUser() currentUser: CurrentUserData, @Body() dto: CreateSystemDeviceDto) {
    return this.service.create(dto, currentUser);
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  @RequirePermissions('device:delete:system')
  remove(@CurrentUser() currentUser: CurrentUserData, @Param('id') id: string) {
    return this.service.remove(id, currentUser);
  }
}
  • Step 4: Run the new backend tests to verify they pass

Run: cd backend && bun run test -- src/devices/system-device-inventory.service.spec.ts src/devices/controllers/system-device-inventory.controller.spec.ts --runInBand

Expected: PASS with system inventory list/create/preview/delete coverage.

  • Step 5: Commit
git add backend/src/devices/system-device-inventory.service.ts backend/src/devices/system-device-inventory.service.spec.ts backend/src/devices/controllers/system-device-inventory.controller.ts backend/src/devices/controllers/system-device-inventory.controller.spec.ts backend/src/devices/dto/query-system-device-inventory.dto.ts backend/src/devices/dto/create-system-device.dto.ts backend/src/devices/dto/update-system-device.dto.ts backend/src/devices/dto/generate-system-device-identity-query.dto.ts backend/src/devices/dto/system-device-response.dto.ts backend/src/devices/devices.module.ts
git commit -m "feat: add super admin system device inventory api"

Task 4: Implement Assign, Unassign, Maintenance, And Retire Transitions

Files:

  • Create: backend/src/devices/dto/assign-system-device.dto.ts

  • Create: backend/src/devices/dto/update-system-device-inventory-status.dto.ts

  • Modify: backend/src/devices/system-device-inventory.service.ts

  • Modify: backend/src/devices/system-device-inventory.service.spec.ts

  • Modify: backend/src/devices/controllers/system-device-inventory.controller.ts

  • Modify: backend/src/devices/controllers/system-device-inventory.controller.spec.ts

  • Step 1: Write the failing lifecycle tests

Add these cases to backend/src/devices/system-device-inventory.service.spec.ts:

it('assigns a device to an organization and records assignedAt', async () => {
  organizationRepository.findOne.mockResolvedValueOnce({ id: 'org-2', name: 'Org 2' } as Organization);
  deviceRepository.findOne.mockResolvedValueOnce(
    buildMockDevice({ organizationId: null, inventoryStatus: DeviceInventoryStatus.IN_INVENTORY }),
  );
  deviceRepository.save.mockImplementation(async (device) => ({
    ...buildMockDevice(),
    ...device,
    organizationId: 'org-2',
    inventoryStatus: DeviceInventoryStatus.ASSIGNED,
    assignedAt: new Date('2026-05-02T10:00:00.000Z'),
  }));

  const result = await service.assignOrganization(
    'device-1',
    { organizationId: 'org-2' },
    systemUser,
  );

  expect(result.organizationId).toBe('org-2');
  expect(result.inventoryStatus).toBe(DeviceInventoryStatus.ASSIGNED);
  expect(result.assignedAt).toBeTruthy();
});

it('returns a device to inventory on unassign without clearing assignment history', async () => {
  deviceRepository.findOne.mockResolvedValueOnce(
    buildMockDevice({
      organizationId: 'org-2',
      pondId: 'pond-9',
      areaId: 'area-9',
      inventoryStatus: DeviceInventoryStatus.ASSIGNED,
      assignedAt: new Date('2026-05-01T10:00:00.000Z'),
    }),
  );

  const result = await service.unassignOrganization('device-1', systemUser);

  expect(result.organizationId).toBeNull();
  expect(result.inventoryStatus).toBe(DeviceInventoryStatus.IN_INVENTORY);
  expect(result.assignedAt).toEqual(new Date('2026-05-01T10:00:00.000Z'));
});

it('rejects assignment when a device is in maintenance or retired', async () => {
  organizationRepository.findOne.mockResolvedValue({ id: 'org-2' } as Organization);
  deviceRepository.findOne.mockResolvedValueOnce(
    buildMockDevice({ inventoryStatus: DeviceInventoryStatus.MAINTENANCE }),
  );

  await expect(
    service.assignOrganization('device-1', { organizationId: 'org-2' }, systemUser),
  ).rejects.toThrow(BadRequestException);
});

it('marks a device as retired and prevents future assignment', async () => {
  deviceRepository.findOne.mockResolvedValueOnce(
    buildMockDevice({ organizationId: null, inventoryStatus: DeviceInventoryStatus.IN_INVENTORY }),
  );

  const result = await service.updateInventoryStatus(
    'device-1',
    { inventoryStatus: DeviceInventoryStatus.RETIRED },
    systemUser,
  );

  expect(result.inventoryStatus).toBe(DeviceInventoryStatus.RETIRED);
  expect(result.retiredAt).toBeTruthy();
});
  • Step 2: Run the lifecycle tests to verify they fail

Run: cd backend && bun run test -- src/devices/system-device-inventory.service.spec.ts src/devices/controllers/system-device-inventory.controller.spec.ts --runInBand

Expected: FAIL with missing assignment/status methods and DTOs.

  • Step 3: Implement lifecycle transition methods and routes

Create backend/src/devices/dto/assign-system-device.dto.ts:

export class AssignSystemDeviceDto {
  @ApiProperty({ example: '8f4a8c18-2d61-4f66-8db1-7ff0ad8b1ca2' })
  @IsUUID()
  @IsNotEmpty()
  organizationId: string;
}

Create backend/src/devices/dto/update-system-device-inventory-status.dto.ts:

export class UpdateSystemDeviceInventoryStatusDto {
  @ApiProperty({ enum: [DeviceInventoryStatus.MAINTENANCE, DeviceInventoryStatus.RETIRED] })
  @IsEnum([DeviceInventoryStatus.MAINTENANCE, DeviceInventoryStatus.RETIRED])
  inventoryStatus: DeviceInventoryStatus.MAINTENANCE | DeviceInventoryStatus.RETIRED;
}

Update backend/src/devices/system-device-inventory.service.ts:

async assignOrganization(id: string, dto: AssignSystemDeviceDto, currentUser: CurrentUserData) {
  this.assertSystemScope(currentUser, 'assign');

  const device = await this.getSystemDeviceOrThrow(id);
  if ([DeviceInventoryStatus.MAINTENANCE, DeviceInventoryStatus.RETIRED].includes(device.inventoryStatus)) {
    throw new BadRequestException('Device is not assignable in the current inventory status');
  }

  const organization = await this.organizationRepository.findOne({ where: { id: dto.organizationId } });
  if (!organization) {
    throw new NotFoundException('Organization not found');
  }

  device.organizationId = organization.id;
  device.inventoryStatus = DeviceInventoryStatus.ASSIGNED;
  device.assignedAt ??= new Date();

  return this.toResponse(await this.deviceRepository.save(device));
}

async unassignOrganization(id: string, currentUser: CurrentUserData) {
  this.assertSystemScope(currentUser, 'assign');

  const device = await this.getSystemDeviceOrThrow(id);
  device.organizationId = null;
  device.pondId = null;
  device.areaId = null;
  device.inventoryStatus = DeviceInventoryStatus.IN_INVENTORY;

  return this.toResponse(await this.deviceRepository.save(device));
}

async updateInventoryStatus(
  id: string,
  dto: UpdateSystemDeviceInventoryStatusDto,
  currentUser: CurrentUserData,
) {
  this.assertSystemScope(currentUser, 'update');

  const device = await this.getSystemDeviceOrThrow(id);

  if (dto.inventoryStatus === DeviceInventoryStatus.MAINTENANCE) {
    device.organizationId = null;
    device.pondId = null;
    device.areaId = null;
  }

  if (dto.inventoryStatus === DeviceInventoryStatus.RETIRED) {
    device.organizationId = null;
    device.pondId = null;
    device.areaId = null;
    device.retiredAt = new Date();
  }

  device.inventoryStatus = dto.inventoryStatus;
  return this.toResponse(await this.deviceRepository.save(device));
}

Update backend/src/devices/controllers/system-device-inventory.controller.ts:

@Post(':id/assign-organization')
@RequirePermissions('device:assign:system')
assignOrganization(
  @CurrentUser() currentUser: CurrentUserData,
  @Param('id') id: string,
  @Body() dto: AssignSystemDeviceDto,
) {
  return this.service.assignOrganization(id, dto, currentUser);
}

@Post(':id/unassign-organization')
@RequirePermissions('device:assign:system')
unassignOrganization(@CurrentUser() currentUser: CurrentUserData, @Param('id') id: string) {
  return this.service.unassignOrganization(id, currentUser);
}

@Post(':id/inventory-status')
@RequirePermissions('device:update:system')
updateInventoryStatus(
  @CurrentUser() currentUser: CurrentUserData,
  @Param('id') id: string,
  @Body() dto: UpdateSystemDeviceInventoryStatusDto,
) {
  return this.service.updateInventoryStatus(id, dto, currentUser);
}
  • Step 4: Run the lifecycle tests to verify they pass

Run: cd backend && bun run test -- src/devices/system-device-inventory.service.spec.ts src/devices/controllers/system-device-inventory.controller.spec.ts --runInBand

Expected: PASS with assignment, unassignment, maintenance, and retire transitions covered.

  • Step 5: Commit
git add backend/src/devices/dto/assign-system-device.dto.ts backend/src/devices/dto/update-system-device-inventory-status.dto.ts backend/src/devices/system-device-inventory.service.ts backend/src/devices/system-device-inventory.service.spec.ts backend/src/devices/controllers/system-device-inventory.controller.ts backend/src/devices/controllers/system-device-inventory.controller.spec.ts
git commit -m "feat: add system device inventory lifecycle actions"

Task 5: Add Frontend Route Guarding, Navigation, Types, And API Hooks

Files:

  • Create: frontend/src/types/system-device.types.ts

  • Create: frontend/src/services/system-device-inventory.service.ts

  • Create: frontend/src/hooks/useSystemDeviceInventory.ts

  • Create: frontend/src/routes/PermissionRoute.spec.tsx

  • Modify: frontend/src/routes/PermissionRoute.tsx

  • Modify: frontend/src/App.tsx

  • Modify: frontend/src/navigation/menus/super-admin.menu.tsx

  • Modify: frontend/src/navigation/nav-resolver.spec.ts

  • Step 1: Write the failing frontend guard and navigation tests

Create frontend/src/routes/PermissionRoute.spec.tsx:

import { screen } from '@testing-library/react';
import { Route, Routes } from 'react-router-dom';
import { describe, expect, it, vi } from 'vitest';
import { PermissionRoute } from '@/routes/PermissionRoute';
import { renderWithProviders } from '@/test/renderWithProviders';

let role: 'SUPER_ADMIN' | 'TECHNICAL' = 'TECHNICAL';
let permissions = ['device:read:all'];

vi.mock('@/stores/auth.store', () => ({
  useAuthStore: (
    selector: (state: { role: typeof role; permissions: string[] }) => unknown,
  ) => selector({ role, permissions }),
}));

describe('PermissionRoute', () => {
  it('denies access when the user role is not allowed even if the resource permission matches', async () => {
    role = 'TECHNICAL';

    renderWithProviders(
      <Routes>
        <Route element={<PermissionRoute resource="device" roles={['SUPER_ADMIN']} />}>
          <Route path="/system/devices" element={<div>inventory-page</div>} />
        </Route>
      </Routes>,
      { initialEntries: ['/system/devices'] },
    );

    expect(await screen.findByText(/không có quyền/i)).toBeInTheDocument();
  });
});

Update frontend/src/navigation/nav-resolver.spec.ts:

it('keeps the dedicated system inventory entry for super admin', () => {
  const items = flatten(resolveNavigation('SUPER_ADMIN', fullPermissions));
  const inventoryItem = items.find((item) => item.key === 'super-admin-device-inventory');

  expect(inventoryItem?.path).toBe('/system/devices');
});
  • Step 2: Run the frontend tests to verify they fail

Run: cd frontend && bun run test -- src/routes/PermissionRoute.spec.tsx src/navigation/nav-resolver.spec.ts

Expected: FAIL because PermissionRoute has no roles prop and the menu still points to /devices/list.

  • Step 3: Implement route gating, navigation updates, and system inventory client hooks

Create frontend/src/types/system-device.types.ts:

export type SystemDeviceInventoryStatus = 'in_inventory' | 'assigned' | 'maintenance' | 'retired';
export type SystemDeviceOperationalStatus = 'online' | 'offline' | 'error';
export type SystemDeviceType = 'sensor' | 'gateway' | 'inverter' | 'feeder';

export interface SystemDeviceRow {
  id: string;
  organizationId: string | null;
  organization?: { id: string; name: string; code: string } | null;
  serialNumber: string;
  name: string | null;
  type: SystemDeviceType;
  model: string | null;
  inventoryStatus: SystemDeviceInventoryStatus;
  operationalStatus: SystemDeviceOperationalStatus;
  assignedAt: string | null;
  retiredAt: string | null;
  createdAt: string;
  updatedAt: string;
}

export interface SystemDeviceQuery {
  page?: number;
  limit?: number;
  search?: string;
  type?: SystemDeviceType;
  inventoryStatus?: SystemDeviceInventoryStatus;
  operationalStatus?: SystemDeviceOperationalStatus;
  organizationId?: string;
  unassigned?: boolean;
}

Create frontend/src/services/system-device-inventory.service.ts:

export const systemDeviceInventoryService = {
  getAll(query: SystemDeviceQuery = {}) {
    return api.get('/system/devices', { params: query }).then((response) => response.data);
  },
  create(data: CreateSystemDeviceDto) {
    return api.post('/system/devices', data).then((response) => response.data);
  },
  update(id: string, data: UpdateSystemDeviceDto) {
    return api.put(`/system/devices/${id}`, data).then((response) => response.data);
  },
  delete(id: string) {
    return api.delete(`/system/devices/${id}`);
  },
  assignOrganization(id: string, organizationId: string) {
    return api.post(`/system/devices/${id}/assign-organization`, { organizationId }).then((response) => response.data);
  },
  unassignOrganization(id: string) {
    return api.post(`/system/devices/${id}/unassign-organization`).then((response) => response.data);
  },
  updateInventoryStatus(id: string, inventoryStatus: 'maintenance' | 'retired') {
    return api.post(`/system/devices/${id}/inventory-status`, { inventoryStatus }).then((response) => response.data);
  },
  generateIdentity(type: SystemDeviceType) {
    return api.get('/system/devices/generate-identity', { params: { type } }).then((response) => response.data);
  },
};

Create frontend/src/hooks/useSystemDeviceInventory.ts:

export const useSystemDeviceInventory = (query: SystemDeviceQuery) =>
  useQuery({
    queryKey: ['system-device-inventory', query],
    queryFn: () => systemDeviceInventoryService.getAll(query),
  });

export const useAssignSystemDeviceOrganization = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, organizationId }: { id: string; organizationId: string }) =>
      systemDeviceInventoryService.assignOrganization(id, organizationId),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-device-inventory'] }),
  });
};

export const useUnassignSystemDeviceOrganization = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: string) => systemDeviceInventoryService.unassignOrganization(id),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-device-inventory'] }),
  });
};

export const useCreateSystemDevice = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (dto: CreateSystemDeviceDto) => systemDeviceInventoryService.create(dto),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-device-inventory'] }),
  });
};

export const useUpdateSystemDevice = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, dto }: { id: string; dto: UpdateSystemDeviceDto }) =>
      systemDeviceInventoryService.update(id, dto),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-device-inventory'] }),
  });
};

export const useGenerateSystemDeviceIdentity = () =>
  useMutation({
    mutationFn: (type: SystemDeviceType) => systemDeviceInventoryService.generateIdentity(type),
  });

export const useUpdateSystemDeviceInventoryStatus = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, inventoryStatus }: { id: string; inventoryStatus: 'maintenance' | 'retired' }) =>
      systemDeviceInventoryService.updateInventoryStatus(id, inventoryStatus),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-device-inventory'] }),
  });
};

export const useDeleteSystemDevice = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: (id: string) => systemDeviceInventoryService.delete(id),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['system-device-inventory'] }),
  });
};

Update frontend/src/routes/PermissionRoute.tsx:

interface PermissionRouteProps {
  resource: string;
  action?: 'read' | 'create' | 'update' | 'delete' | 'manage';
  roles?: DashboardRole[];
}

export const PermissionRoute = ({ resource, action = 'read', roles }: PermissionRouteProps) => {
  const permissions = useAuthStore((state) => state.permissions);
  const role = useAuthStore((state) => state.role);

  if (roles && (!role || !roles.includes(role))) {
    return <ForbiddenPage />;
  }

  if (!hasPermission(permissions, resource, action)) {
    return <ForbiddenPage />;
  }

  return <Outlet />;
};

Update frontend/src/App.tsx:

<Route element={<PermissionRoute resource="device" roles={['SUPER_ADMIN']} />}>
  <Route path="/system/devices" element={<SystemDeviceInventoryPage />} />
</Route>

Update frontend/src/navigation/menus/super-admin.menu.tsx:

{
  key: 'super-admin-device-inventory',
  icon: <ToolOutlined />,
  label: 'Kho thiết bị',
  path: '/system/devices',
  requiredPermission: { resource: 'device' },
},
  • Step 4: Run the frontend tests to verify they pass

Run: cd frontend && bun run test -- src/routes/PermissionRoute.spec.tsx src/navigation/nav-resolver.spec.ts

Expected: PASS with role-restricted route guarding and the updated Super Admin navigation path.

  • Step 5: Commit
git add frontend/src/types/system-device.types.ts frontend/src/services/system-device-inventory.service.ts frontend/src/hooks/useSystemDeviceInventory.ts frontend/src/routes/PermissionRoute.tsx frontend/src/routes/PermissionRoute.spec.tsx frontend/src/App.tsx frontend/src/navigation/menus/super-admin.menu.tsx frontend/src/navigation/nav-resolver.spec.ts
git commit -m "feat: add frontend routing and client for system device inventory"

Task 6: Build The /system/devices Inventory Page

Files:

  • Create: frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx

  • Test: frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

  • Step 1: Write the failing page test

Create frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx:

import { fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { SystemDeviceInventoryPage } from '@/pages/system-devices/SystemDeviceInventoryPage';
import { renderWithProviders } from '@/test/renderWithProviders';

const generateIdentity = vi.fn().mockResolvedValue({
  type: 'sensor',
  sequence: 1,
  name: 'Sensor 001',
  serialNumber: 'SNS-001',
});
const createDevice = vi.fn().mockResolvedValue(undefined);

vi.mock('@/hooks/useSystemDeviceInventory', () => ({
  useSystemDeviceInventory: () => ({
    data: { data: [], meta: { total: 0, page: 1, limit: 20, totalPages: 0 } },
    isLoading: false,
  }),
  useCreateSystemDevice: () => ({ mutateAsync: createDevice, isPending: false }),
  useUpdateSystemDevice: () => ({ mutateAsync: vi.fn(), isPending: false }),
  useGenerateSystemDeviceIdentity: () => ({ mutateAsync: generateIdentity, isPending: false }),
  useAssignSystemDeviceOrganization: () => ({ mutateAsync: vi.fn(), isPending: false }),
  useUnassignSystemDeviceOrganization: () => ({ mutateAsync: vi.fn(), isPending: false }),
  useUpdateSystemDeviceInventoryStatus: () => ({ mutateAsync: vi.fn(), isPending: false }),
  useDeleteSystemDevice: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));

vi.mock('@/hooks/useOrganizations', () => ({
  useOrganizations: () => ({
    data: { data: [{ id: 'org-1', name: 'Org 1', code: 'ORG-1' }], meta: { total: 1, page: 1, limit: 20, totalPages: 1 } },
  }),
}));

describe('SystemDeviceInventoryPage', () => {
  it('prefills name and serial after selecting a device type and preserves manual edits', async () => {
    renderWithProviders(<SystemDeviceInventoryPage />, { initialEntries: ['/system/devices'] });

    fireEvent.click(screen.getByRole('button', { name: /thêm thiết bị/i }));
    fireEvent.mouseDown(screen.getByLabelText(/loại thiết bị/i));
    fireEvent.click(await screen.findByText('sensor'));

    await waitFor(() => expect(generateIdentity).toHaveBeenCalledWith('sensor'));
    expect(screen.getByDisplayValue('Sensor 001')).toBeInTheDocument();
    expect(screen.getByDisplayValue('SNS-001')).toBeInTheDocument();

    fireEvent.change(screen.getByLabelText(/tên thiết bị/i), {
      target: { value: 'Sensor ao A' },
    });
    fireEvent.mouseDown(screen.getByLabelText(/loại thiết bị/i));
    fireEvent.click(await screen.findByText('gateway'));

    expect(screen.getByDisplayValue('Sensor ao A')).toBeInTheDocument();
  });
});
  • Step 2: Run the page test to verify it fails

Run: cd frontend && bun run test -- src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

Expected: FAIL with missing page/component exports.

  • Step 3: Implement the system inventory page

Create frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx:

export const SystemDeviceInventoryPage: React.FC = () => {
  const [query, setQuery] = useState<SystemDeviceQuery>({ page: 1, limit: 20 });
  const [drawerOpen, setDrawerOpen] = useState(false);
  const [editingDevice, setEditingDevice] = useState<SystemDeviceRow | null>(null);
  const [assigningDevice, setAssigningDevice] = useState<SystemDeviceRow | null>(null);
  const [assignOrganizationId, setAssignOrganizationId] = useState<string>();
  const [manualFields, setManualFields] = useState({ name: false, serialNumber: false });
  const [form] = Form.useForm<CreateSystemDeviceDto>();

  const inventoryQuery = useSystemDeviceInventory(query);
  const { mutateAsync: createDevice, isPending: isCreating } = useCreateSystemDevice();
  const { mutateAsync: updateDevice, isPending: isUpdating } = useUpdateSystemDevice();
  const { mutateAsync: generateIdentity } = useGenerateSystemDeviceIdentity();
  const { mutateAsync: assignOrganization } = useAssignSystemDeviceOrganization();
  const { mutateAsync: unassignOrganization } = useUnassignSystemDeviceOrganization();
  const { mutateAsync: updateInventoryStatus } = useUpdateSystemDeviceInventoryStatus();
  const { mutateAsync: deleteDevice } = useDeleteSystemDevice();
  const organizations = useOrganizations({ page: 1, limit: 100 });

  const handleTypeChange = async (type: SystemDeviceType) => {
    const preview = await generateIdentity(type);
    if (!manualFields.name) form.setFieldValue('name', preview.name);
    if (!manualFields.serialNumber) form.setFieldValue('serialNumber', preview.serialNumber);
  };

  const openEditDrawer = (record: SystemDeviceRow) => {
    setEditingDevice(record);
    setDrawerOpen(true);
    form.setFieldsValue({
      type: record.type,
      name: record.name ?? '',
      serialNumber: record.serialNumber,
      model: record.model ?? undefined,
    });
  };

  const openAssignModal = (record: SystemDeviceRow) => {
    setAssigningDevice(record);
    setAssignOrganizationId(record.organizationId ?? undefined);
  };

  const columns: TableColumnsType<SystemDeviceRow> = [
    { title: 'Tên thiết bị', dataIndex: 'name', key: 'name' },
    { title: 'Mã serial', dataIndex: 'serialNumber', key: 'serialNumber' },
    { title: 'Loại', dataIndex: 'type', key: 'type' },
    {
      title: 'Tổ chức',
      key: 'organization',
      render: (_, record) => record.organization?.name ?? 'Chưa assign',
    },
    {
      title: 'Inventory',
      dataIndex: 'inventoryStatus',
      key: 'inventoryStatus',
      render: (value) => <Tag>{value}</Tag>,
    },
    {
      title: 'Vận hành',
      dataIndex: 'operationalStatus',
      key: 'operationalStatus',
      render: (value) => <Tag color={value === 'online' ? 'success' : value === 'error' ? 'error' : 'default'}>{value}</Tag>,
    },
    {
      title: 'Hành động',
      key: 'actions',
      render: (_, record) => (
        <Space>
          <Button onClick={() => openEditDrawer(record)}>Chỉnh sửa</Button>
          <Button onClick={() => openAssignModal(record)}>Assign</Button>
          <Button onClick={() => unassignOrganization(record.id)}>Unassign</Button>
          <Button onClick={() => updateInventoryStatus({ id: record.id, inventoryStatus: 'maintenance' })}>Maintenance</Button>
          <Button onClick={() => updateInventoryStatus({ id: record.id, inventoryStatus: 'retired' })}>Retire</Button>
          <Popconfirm title="Xóa thiết bị?" onConfirm={() => deleteDevice(record.id)}>
            <Button danger>Xóa</Button>
          </Popconfirm>
        </Space>
      ),
    },
  ];

  return (
    <>
      <PageHeader
        title="Kho thiết bị"
        actions={<Button type="primary" onClick={() => setDrawerOpen(true)}>Thêm thiết bị</Button>}
      />
      <Card>
        <Space wrap>
          <Input.Search
            placeholder="Tìm theo tên hoặc serial"
            onSearch={(search) => setQuery((prev) => ({ ...prev, page: 1, search }))}
          />
          <Select allowClear placeholder="Trạng thái inventory" onChange={(inventoryStatus) => setQuery((prev) => ({ ...prev, page: 1, inventoryStatus }))} />
          <Select allowClear placeholder="Trạng thái vận hành" onChange={(operationalStatus) => setQuery((prev) => ({ ...prev, page: 1, operationalStatus }))} />
          <Select
            allowClear
            placeholder="Tổ chức"
            options={[
              { label: 'Chưa assign', value: '__unassigned__' },
              ...((organizations.data?.data ?? []).map((organization) => ({
                label: organization.name,
                value: organization.id,
              }))),
            ]}
            onChange={(value) =>
              setQuery((prev) => ({
                ...prev,
                page: 1,
                organizationId: value === '__unassigned__' ? undefined : value,
                unassigned: value === '__unassigned__',
              }))
            }
          />
        </Space>
      </Card>
      <Table
        rowKey="id"
        loading={inventoryQuery.isLoading}
        dataSource={inventoryQuery.data?.data ?? []}
        columns={columns}
      />
      <Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Thêm thiết bị">
        <Form
          form={form}
          layout="vertical"
          onFinish={async (values) => {
            if (editingDevice) {
              await updateDevice({ id: editingDevice.id, dto: values });
            } else {
              await createDevice(values);
            }
            setDrawerOpen(false);
            setEditingDevice(null);
          }}
        >
          <Form.Item label="Loại thiết bị" name="type" rules={[{ required: true }]}>
            <Select onChange={handleTypeChange} options={deviceTypeOptions} />
          </Form.Item>
          <Form.Item label="Tên thiết bị" name="name" rules={[{ required: true }]}>
            <Input onChange={() => setManualFields((prev) => ({ ...prev, name: true }))} />
          </Form.Item>
          <Form.Item label="Mã serial" name="serialNumber" rules={[{ required: true }]}>
            <Input onChange={() => setManualFields((prev) => ({ ...prev, serialNumber: true }))} />
          </Form.Item>
          <Form.Item label="Mô hình" name="model">
            <Input />
          </Form.Item>
          <Button type="primary" htmlType="submit" loading={isCreating || isUpdating}>
            Lưu thiết bị
          </Button>
        </Form>
      </Drawer>
      <Modal
        open={!!assigningDevice}
        title="Assign organization"
        onCancel={() => setAssigningDevice(null)}
        onOk={async () => {
          if (assigningDevice && assignOrganizationId) {
            await assignOrganization({ id: assigningDevice.id, organizationId: assignOrganizationId });
            setAssigningDevice(null);
          }
        }}
      >
        <Select
          value={assignOrganizationId}
          onChange={setAssignOrganizationId}
          options={(organizations.data?.data ?? []).map((organization) => ({
            label: organization.name,
            value: organization.id,
          }))}
        />
      </Modal>
    </>
  );
};
  • Step 4: Run the page test to verify it passes

Run: cd frontend && bun run test -- src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

Expected: PASS with quick-create preview/manual override coverage.

  • Step 5: Commit
git add frontend/src/pages/system-devices/SystemDeviceInventoryPage.tsx frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx
git commit -m "feat: add super admin system device inventory page"

Task 7: Verify The Vertical Slice End To End

Files:

  • Modify: none

  • Test: backend/src/rbac/authorization-policy.service.spec.ts

  • Test: backend/src/devices/device-identity.util.spec.ts

  • Test: backend/src/devices/system-device-inventory.service.spec.ts

  • Test: backend/src/devices/controllers/system-device-inventory.controller.spec.ts

  • Test: frontend/src/routes/PermissionRoute.spec.tsx

  • Test: frontend/src/navigation/nav-resolver.spec.ts

  • Test: frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

  • Step 1: Run the targeted backend and frontend suites

Run: cd backend && bun run test -- src/rbac/authorization-policy.service.spec.ts src/devices/device-identity.util.spec.ts src/devices/system-device-inventory.service.spec.ts src/devices/controllers/system-device-inventory.controller.spec.ts --runInBand

Expected: PASS for RBAC, preview utility, and system inventory backend coverage.

Run: cd frontend && bun run test -- src/routes/PermissionRoute.spec.tsx src/navigation/nav-resolver.spec.ts src/pages/system-devices/SystemDeviceInventoryPage.spec.tsx

Expected: PASS for route gating, menu wiring, and the new inventory page behavior.

  • Step 2: Run repository formatting and static checks

Run: bun run check

Expected: PASS with Biome applying any required formatting and no remaining lint errors.

  • Step 3: Commit
git add .
git commit -m "feat: ship super admin system device inventory slice"