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/devicesHTTP contract with explicitdevice:*:systempermissions.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: validatemaintenanceandretiredtransitions.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: makeorganization_idnullable, add inventory columns, and grant Super Admin explicitdevice:*:systempermissions.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/devicesendpoints.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 forroles+ permission checks.
Modify
backend/src/devices/entities/device.entity.ts
Add nullable ownership and inventory lifecycle columns while preserving the legacy operationalstatuscolumn.backend/src/devices/devices.module.ts
Register the new controller/service and addOrganizationrepository access.backend/src/rbac/resource-scope-map.ts
Markdeviceas system-capable without treating tenantallscope as implicit system access.backend/src/rbac/authorization-policy.service.ts
Separateallfromsystemfor 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/devicesbehind Super Admin-only route protection.frontend/src/navigation/menus/super-admin.menu.tsx
Point the Super Admin device inventory menu to/system/devicesand 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.tsModify:
backend/src/rbac/authorization-policy.service.tsTest:
backend/src/rbac/authorization-policy.service.spec.tsStep 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.tsCreate:
backend/src/devices/device-identity.util.tsTest:
backend/src/devices/device-identity.util.spec.tsCreate:
backend/src/migrations/1777282800000-AddSystemDeviceInventoryFields.tsModify:
backend/src/devices/entities/device.entity.tsModify:
backend/src/devices/devices.module.tsStep 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.tsCreate:
backend/src/devices/system-device-inventory.service.spec.tsCreate:
backend/src/devices/controllers/system-device-inventory.controller.tsCreate:
backend/src/devices/controllers/system-device-inventory.controller.spec.tsCreate:
backend/src/devices/dto/query-system-device-inventory.dto.tsCreate:
backend/src/devices/dto/create-system-device.dto.tsCreate:
backend/src/devices/dto/update-system-device.dto.tsCreate:
backend/src/devices/dto/generate-system-device-identity-query.dto.tsCreate:
backend/src/devices/dto/system-device-response.dto.tsModify:
backend/src/devices/devices.module.tsStep 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.tsCreate:
backend/src/devices/dto/update-system-device-inventory-status.dto.tsModify:
backend/src/devices/system-device-inventory.service.tsModify:
backend/src/devices/system-device-inventory.service.spec.tsModify:
backend/src/devices/controllers/system-device-inventory.controller.tsModify:
backend/src/devices/controllers/system-device-inventory.controller.spec.tsStep 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.tsCreate:
frontend/src/services/system-device-inventory.service.tsCreate:
frontend/src/hooks/useSystemDeviceInventory.tsCreate:
frontend/src/routes/PermissionRoute.spec.tsxModify:
frontend/src/routes/PermissionRoute.tsxModify:
frontend/src/App.tsxModify:
frontend/src/navigation/menus/super-admin.menu.tsxModify:
frontend/src/navigation/nav-resolver.spec.tsStep 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.tsxTest:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsxStep 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.tsTest:
backend/src/devices/device-identity.util.spec.tsTest:
backend/src/devices/system-device-inventory.service.spec.tsTest:
backend/src/devices/controllers/system-device-inventory.controller.spec.tsTest:
frontend/src/routes/PermissionRoute.spec.tsxTest:
frontend/src/navigation/nav-resolver.spec.tsTest:
frontend/src/pages/system-devices/SystemDeviceInventoryPage.spec.tsxStep 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"