Sidebar Real Counts 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: Replace the hardcoded, fake numeric badges in the left sidebar (all 5 roles) with real, correctly-scoped counts from the backend, closing GitHub issue #133.

Architecture: The sidebar badges (meta/badge on NavItem) are currently literal numbers baked into frontend/src/navigation/menus/*.tsx. Each of the 5 roles already has a dashboard endpoint/hook (GET /dashboard/{role}) that returns organization- or user-scoped counts. We reuse those existing endpoints instead of building a new one: two of them (admin, technical) need one new count field each; the other three already carry everything the sidebar needs. On the frontend, a new useSidebarCounts() hook picks the one dashboard query matching the current user’s role (the other 4 stay disabled via React Query’s enabled flag), and a new pure applySidebarCounts() function stamps real numbers onto the static menu tree by matching NavItem.key.

Tech Stack: NestJS 11 + TypeORM (backend), React 19 + TanStack Query v5 + Zustand (frontend), Jest (backend tests), Vitest (frontend tests).


Role Sidebar item (NavItem.key) Source field
OWNER owner-ponds sections.farmOverview?.totalPonds ?? 0
OWNER owner-staff sections.staffPerformance.length
OWNER owner-cycles sections.farmOverview?.activeCycles ?? 0
OWNER owner-alerts sections.farmDeviceStatus.activeAlerts
STAFF staff-today-tasks sections.todayChecklist.length
STAFF staff-my-ponds sections.myPonds.length
TECHNICAL technical-my-tickets sections.assignedTicketQueue.length
TECHNICAL technical-maintenance-schedule sections.internalTicketQueue.length - sections.maintenanceHistory.length
SUPER_ADMIN super-admin-organizations sections.saasOverview.totalOrganizations
SUPER_ADMIN super-admin-device-overview sections.deviceInventoryLifecycle.total
SUPER_ADMIN super-admin-admin-accounts sections.saasOverview.totalUsers
ADMIN admin-farms sections.organizationOverview.totalFarms
ADMIN admin-ponds sections.organizationOverview.totalPonds
ADMIN admin-device-management sections.organizationOverview.totalDevices
ADMIN admin-alerts sections.organizationOverview.activeAlerts
Role Sidebar item New field Why
ADMIN admin-owners-staff organizationOverview.totalOwnersAndStaff The only existing owner/staff list (ownerStaffActivity) is capped at .limit(10) for the activity widget, so .length would undercount any org with more than 10 owners+staff.
TECHNICAL technical-devices organizationDeviceCount (new top-level section field) The technical dashboard has no org-wide device total today — only devices attached to the technician’s own tickets.

Files:

  • Test: backend/src/dashboard/dashboard-query.admin.spec.ts (new)

  • Step 1: Write the failing test

Create backend/src/dashboard/dashboard-query.admin.spec.ts:

import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { DailyLog } from '@/aquaculture/entities/daily-log.entity';
import { FarmingCycle } from '@/aquaculture/entities/farming-cycle.entity';
import { GrowthRecord } from '@/aquaculture/entities/growth-record.entity';
import { AuditService } from '@/audit/audit.service';
import { User } from '@/auth/entities/user.entity';
import { DashboardQueryService } from '@/dashboard/dashboard-query.service';
import { Device } from '@/devices/entities/device.entity';
import { DeviceAlert } from '@/devices/entities/device-alert.entity';
import { Area } from '@/farm/entities/area.entity';
import { Pond } from '@/farm/entities/pond.entity';
import { Organization } from '@/organizations/entities/organization.entity';
import { Ticket } from '@/tickets/entities/ticket.entity';

describe('DashboardQueryService admin sections — owners and staff count', () => {
  let service: DashboardQueryService;
  const areaRepository = { find: jest.fn() };
  const pondRepository = { createQueryBuilder: jest.fn() };
  const deviceRepository = { createQueryBuilder: jest.fn() };
  const deviceAlertRepository = { createQueryBuilder: jest.fn() };
  const farmingCycleRepository = { createQueryBuilder: jest.fn() };
  const dailyLogRepository = { createQueryBuilder: jest.fn() };
  const growthRecordRepository = { createQueryBuilder: jest.fn() };
  const userRepository = { createQueryBuilder: jest.fn() };

  const makeCountQb = (count: string) => ({
    where: jest.fn().mockReturnThis(),
    andWhere: jest.fn().mockReturnThis(),
    innerJoin: jest.fn().mockReturnThis(),
    leftJoin: jest.fn().mockReturnThis(),
    select: jest.fn().mockReturnThis(),
    addSelect: jest.fn().mockReturnThis(),
    groupBy: jest.fn().mockReturnThis(),
    orderBy: jest.fn().mockReturnThis(),
    addOrderBy: jest.fn().mockReturnThis(),
    limit: jest.fn().mockReturnThis(),
    getRawOne: jest.fn().mockResolvedValue({ count }),
    getRawMany: jest.fn().mockResolvedValue([]),
  });

  beforeEach(async () => {
    jest.clearAllMocks();

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        DashboardQueryService,
        { provide: getRepositoryToken(Area), useValue: areaRepository },
        { provide: getRepositoryToken(Pond), useValue: pondRepository },
        { provide: getRepositoryToken(Device), useValue: deviceRepository },
        { provide: getRepositoryToken(DeviceAlert), useValue: deviceAlertRepository },
        { provide: getRepositoryToken(DailyLog), useValue: dailyLogRepository },
        { provide: getRepositoryToken(GrowthRecord), useValue: growthRecordRepository },
        { provide: getRepositoryToken(FarmingCycle), useValue: farmingCycleRepository },
        { provide: getRepositoryToken(User), useValue: userRepository },
        { provide: getRepositoryToken(Organization), useValue: {} },
        { provide: getRepositoryToken(Ticket), useValue: {} },
        {
          provide: AuditService,
          useValue: { getRecentForDashboard: jest.fn().mockResolvedValue([]) },
        },
      ],
    }).compile();

    service = module.get(DashboardQueryService);
  });

  it('counts every owner and staff user in the org, not just the top-10 activity list', async () => {
    areaRepository.find.mockResolvedValue([{ id: 'farm-1', name: 'Farm 1' }]);
    pondRepository.createQueryBuilder.mockReturnValue(makeCountQb('0'));
    deviceRepository.createQueryBuilder.mockReturnValue(makeCountQb('0'));
    deviceAlertRepository.createQueryBuilder.mockReturnValue(makeCountQb('0'));
    farmingCycleRepository.createQueryBuilder.mockReturnValue(makeCountQb('0'));
    dailyLogRepository.createQueryBuilder.mockReturnValue(makeCountQb('0'));
    growthRecordRepository.createQueryBuilder.mockReturnValue(makeCountQb('0'));

    const ownerStaffActivityQb = makeCountQb('0');
    ownerStaffActivityQb.getRawMany.mockResolvedValue([]);
    const ownersAndStaffCountQb = makeCountQb('18');
    userRepository.createQueryBuilder
      .mockReturnValueOnce(ownerStaffActivityQb)
      .mockReturnValueOnce(ownersAndStaffCountQb);

    const sections = await service.buildAdminSections({ organizationId: 'org-1' });

    expect(sections.organizationOverview.totalOwnersAndStaff).toBe(18);
  });
});
  • Step 2: Run test to verify it fails

Run: make backend-test FILE=src/dashboard/dashboard-query.admin.spec.ts
Expected: FAIL — sections.organizationOverview.totalOwnersAndStaff is undefined, not 18 (the field doesn’t exist yet).


Files:

  • Modify: backend/src/dashboard/dto/admin-dashboard-response.dto.ts:3-8

  • Modify: backend/src/dashboard/dashboard-query.service.ts:125-133 (buildEmptyAdminSections)

  • Modify: backend/src/dashboard/dashboard-query.service.ts:338-459 (buildAdminSections)

  • Modify: backend/src/dashboard/dashboard-query.service.ts:578-585 (return block)

  • Modify: frontend/src/types/dashboard/admin-dashboard.types.ts:3-8

  • Step 1: Add the field to the backend DTO

In backend/src/dashboard/dto/admin-dashboard-response.dto.ts, change:

export interface AdminOrganizationOverviewDto {
  totalFarms: number;
  totalPonds: number;
  activePonds: number;
  totalDevices: number;
  activeAlerts: number;
}

to:

export interface AdminOrganizationOverviewDto {
  totalFarms: number;
  totalPonds: number;
  activePonds: number;
  totalDevices: number;
  activeAlerts: number;
  totalOwnersAndStaff: number;
}
  • Step 2: Mirror the field on the frontend type

In frontend/src/types/dashboard/admin-dashboard.types.ts, apply the identical change to AdminOrganizationOverview:

export interface AdminOrganizationOverview {
  totalFarms: number;
  totalPonds: number;
  activePonds: number;
  totalDevices: number;
  activeAlerts: number;
  totalOwnersAndStaff: number;
}
  • Step 3: Update buildEmptyAdminSections

In backend/src/dashboard/dashboard-query.service.ts, change:

  buildEmptyAdminSections(): AdminDashboardSectionsDto {
    return {
      organizationOverview: {
        totalFarms: 0,
        totalPonds: 0,
        activePonds: 0,
        totalDevices: 0,
        activeAlerts: 0,
      },

to:

  buildEmptyAdminSections(): AdminDashboardSectionsDto {
    return {
      organizationOverview: {
        totalFarms: 0,
        totalPonds: 0,
        activePonds: 0,
        totalDevices: 0,
        activeAlerts: 0,
        totalOwnersAndStaff: 0,
      },
  • Step 4: Add the count query to buildAdminSections

In backend/src/dashboard/dashboard-query.service.ts, the Promise.all inside buildAdminSections currently destructures 8 values ending in ownerStaffActivity. Add a 9th value, ownersAndStaffCount, right after it — both queries run against userRepository but are functionally distinct (the first is the capped top-10 activity list, the second is the true total).

Change the destructure and type tuple:

    const [
      pondRows,
      activeAlertRows,
      devices,
      alertCenter,
      ownerStaffActivity,
      activeCycles,
      todayLogs,
      growthRecordsLast7Days,
    ]: [
      Array<{ pondId: string; farmId: string; status: string }>,
      Array<{ farmId: string; count: string }>,
      Array<{ farmId: string; status: string }>,
      Array<{
        id: string;
        pondId: string | null;
        pondName: string | null;
        type: string;
        severity: string;
        message: string;
        status: string;
        createdAt: string;
      }>,
      AdminDashboardSectionsDto['ownerStaffActivity'],
      { count: string } | undefined,
      { count: string } | undefined,
      { count: string } | undefined,
    ] = await Promise.all([

to:

    const [
      pondRows,
      activeAlertRows,
      devices,
      alertCenter,
      ownerStaffActivity,
      ownersAndStaffCount,
      activeCycles,
      todayLogs,
      growthRecordsLast7Days,
    ]: [
      Array<{ pondId: string; farmId: string; status: string }>,
      Array<{ farmId: string; count: string }>,
      Array<{ farmId: string; status: string }>,
      Array<{
        id: string;
        pondId: string | null;
        pondName: string | null;
        type: string;
        severity: string;
        message: string;
        status: string;
        createdAt: string;
      }>,
      AdminDashboardSectionsDto['ownerStaffActivity'],
      number,
      { count: string } | undefined,
      { count: string } | undefined,
      { count: string } | undefined,
    ] = await Promise.all([

Then, right after the existing this.getAdminOwnerStaffActivity(params.organizationId), line inside that same array, add:

      this.getAdminOwnersAndStaffCount(params.organizationId),

So the array reads (only the relevant slice shown):

      this.getAdminOwnerStaffActivity(params.organizationId),
      this.getAdminOwnersAndStaffCount(params.organizationId),
      this.farmingCycleRepository
        .createQueryBuilder('cycle')
  • Step 5: Add the getAdminOwnersAndStaffCount private method

Immediately above private async getAdminOwnerStaffActivity( (currently line 1462), add a new private method that counts every OWNER/STAFF user in the org — no .limit(), unlike the activity list:

  private async getAdminOwnersAndStaffCount(organizationId: string): Promise<number> {
    const result = await this.userRepository
      .createQueryBuilder('user')
      .innerJoin('user_roles', 'ur', 'ur.user_id = user.id')
      .innerJoin('roles', 'role', 'role.id = ur.role_id')
      .where('user.organization_id = :organizationId', { organizationId })
      .andWhere('role.name IN (:...roleNames)', { roleNames: ['OWNER', 'STAFF'] })
      .select('COUNT(DISTINCT user.id)', 'count')
      .getRawOne<{ count: string }>();

    return Number(result?.count ?? 0);
  }

  • Step 6: Wire the count into the returned organizationOverview

In the return statement at the end of buildAdminSections, change:

      organizationOverview: {
        totalFarms: farms.length,
        totalPonds: pondRows.length,
        activePonds: pondRows.filter((pond) => pond.status === 'active').length,
        totalDevices: deviceSummary.total,
        activeAlerts: activeAlertsTotal,
      },

to:

      organizationOverview: {
        totalFarms: farms.length,
        totalPonds: pondRows.length,
        activePonds: pondRows.filter((pond) => pond.status === 'active').length,
        totalDevices: deviceSummary.total,
        activeAlerts: activeAlertsTotal,
        totalOwnersAndStaff: ownersAndStaffCount,
      },
  • Step 7: Run the test to verify it passes

Run: make backend-test FILE=src/dashboard/dashboard-query.admin.spec.ts
Expected: PASS

  • Step 8: Run the full admin dashboard test suite to check nothing broke

Run: make backend-test FILE=src/dashboard/services/admin-dashboard.service.spec.ts
Expected: PASS (this suite mocks DashboardQueryService at a higher level, so it is unaffected by the internal query change)

  • Step 9: Commit
git add backend/src/dashboard/dto/admin-dashboard-response.dto.ts \
        backend/src/dashboard/dashboard-query.service.ts \
        backend/src/dashboard/dashboard-query.admin.spec.ts \
        frontend/src/types/dashboard/admin-dashboard.types.ts
git commit -m "feat(dashboard): add real totalOwnersAndStaff count to admin overview"

Files:

  • Modify: backend/src/dashboard/dashboard-query.technical.spec.ts

  • Step 1: Extend the existing test file with a failing test

In backend/src/dashboard/dashboard-query.technical.spec.ts, the Device repository is currently registered as an empty object:

        { provide: getRepositoryToken(Device), useValue: {} },

Change it to a mockable repository:

  const deviceRepository = { count: jest.fn() };

Add that const declaration next to the existing const ticketRepository = { find: jest.fn() }; line, then update the provider registration:

        { provide: getRepositoryToken(Device), useValue: deviceRepository },

In beforeEach, add a default so the existing test keeps passing once buildTechnicalSections starts querying devices:

  beforeEach(async () => {
    deviceRepository.count.mockResolvedValue(0);

    const module: TestingModule = await Test.createTestingModule({

Update the existing call to buildTechnicalSections to pass organizationId:

    const sections = await service.buildTechnicalSections({
      organizationId: 'org-1',
      userId: 'tech-1',
      assignedTicketIds: ['ticket-a'],
      internalTicketIds: [],
    });

Then add a new test at the end of the describe block, right after the existing it:


  it('counts every device in the technical user\'s organization, not just ticketed devices', async () => {
    ticketRepository.find.mockResolvedValue([]);
    deviceRepository.count.mockResolvedValue(142);

    const sections = await service.buildTechnicalSections({
      organizationId: 'org-1',
      userId: 'tech-1',
      assignedTicketIds: [],
      internalTicketIds: [],
    });

    expect(deviceRepository.count).toHaveBeenCalledWith({
      where: { organizationId: 'org-1' },
    });
    expect(sections.organizationDeviceCount).toBe(142);
  });
  • Step 2: Run test to verify it fails

Run: make backend-test FILE=src/dashboard/dashboard-query.technical.spec.ts
Expected: FAIL — buildTechnicalSections doesn’t accept organizationId yet and sections.organizationDeviceCount is undefined.


Files:

  • Modify: backend/src/dashboard/dto/technical-dashboard-response.dto.ts:56-63

  • Modify: backend/src/dashboard/dashboard-query.service.ts:203-218 (buildEmptyTechnicalSections)

  • Modify: backend/src/dashboard/dashboard-query.service.ts:220-329 (buildTechnicalSections)

  • Modify: backend/src/dashboard/services/technical-dashboard.service.ts

  • Modify: backend/src/dashboard/services/technical-dashboard.service.spec.ts

  • Modify: frontend/src/types/dashboard/technical-dashboard.types.ts

  • Step 1: Add the field to the backend DTO

In backend/src/dashboard/dto/technical-dashboard-response.dto.ts, change:

export interface TechnicalDashboardSectionsDto {
  assignedTicketQueue: TechnicalTicketQueueItemDto[];
  internalTicketQueue: TechnicalTicketQueueItemDto[];
  todayRoute: TechnicalTodayRouteItemDto[];
  deviceDiagnostics: TechnicalDeviceDiagnosticsItemDto[];
  partsNeeded: TechnicalPartNeededItemDto[];
  maintenanceHistory: TechnicalMaintenanceHistoryItemDto[];
  serviceReportState: TechnicalServiceReportStateDto;
}

to:

export interface TechnicalDashboardSectionsDto {
  assignedTicketQueue: TechnicalTicketQueueItemDto[];
  internalTicketQueue: TechnicalTicketQueueItemDto[];
  todayRoute: TechnicalTodayRouteItemDto[];
  deviceDiagnostics: TechnicalDeviceDiagnosticsItemDto[];
  partsNeeded: TechnicalPartNeededItemDto[];
  maintenanceHistory: TechnicalMaintenanceHistoryItemDto[];
  serviceReportState: TechnicalServiceReportStateDto;
  organizationDeviceCount: number;
}
  • Step 2: Mirror the field on the frontend type

In frontend/src/types/dashboard/technical-dashboard.types.ts, apply the identical change to TechnicalDashboardSections:

export interface TechnicalDashboardSections {
  assignedTicketQueue: TechnicalTicketQueueItem[];
  internalTicketQueue: TechnicalTicketQueueItem[];
  todayRoute: TechnicalTodayRouteItem[];
  deviceDiagnostics: TechnicalDeviceDiagnosticsItem[];
  partsNeeded: TechnicalPartNeededItem[];
  maintenanceHistory: TechnicalMaintenanceHistoryItem[];
  serviceReportState: TechnicalServiceReportState;
  organizationDeviceCount: number;
}
  • Step 3: Update buildEmptyTechnicalSections

In backend/src/dashboard/dashboard-query.service.ts, change:

  buildEmptyTechnicalSections(): TechnicalDashboardSectionsDto {
    return {
      assignedTicketQueue: [],
      internalTicketQueue: [],
      todayRoute: [],
      deviceDiagnostics: [],
      partsNeeded: [],
      maintenanceHistory: [],
      serviceReportState: {
        pending: 0,
        inProgress: 0,
        completed: 0,
        waitingParts: 0,
      },
    };
  }

to:

  buildEmptyTechnicalSections(): TechnicalDashboardSectionsDto {
    return {
      assignedTicketQueue: [],
      internalTicketQueue: [],
      todayRoute: [],
      deviceDiagnostics: [],
      partsNeeded: [],
      maintenanceHistory: [],
      serviceReportState: {
        pending: 0,
        inProgress: 0,
        completed: 0,
        waitingParts: 0,
      },
      organizationDeviceCount: 0,
    };
  }
  • Step 4: Take organizationId in buildTechnicalSections and count devices

In backend/src/dashboard/dashboard-query.service.ts, change the signature:

  async buildTechnicalSections(params: {
    userId: string;
    assignedTicketIds: string[];
    internalTicketIds: string[];
  }): Promise<TechnicalDashboardSectionsDto> {
    const scopedTicketIds = Array.from(
      new Set([...params.assignedTicketIds, ...params.internalTicketIds]),
    );

    if (scopedTicketIds.length === 0) {
      return this.buildEmptyTechnicalSections();
    }

to:

  async buildTechnicalSections(params: {
    organizationId: string;
    userId: string;
    assignedTicketIds: string[];
    internalTicketIds: string[];
  }): Promise<TechnicalDashboardSectionsDto> {
    const organizationDeviceCount = await this.deviceRepository.count({
      where: { organizationId: params.organizationId },
    });

    const scopedTicketIds = Array.from(
      new Set([...params.assignedTicketIds, ...params.internalTicketIds]),
    );

    if (scopedTicketIds.length === 0) {
      return { ...this.buildEmptyTechnicalSections(), organizationDeviceCount };
    }

Then, in the same method’s final return statement, change:

    return {
      assignedTicketQueue,
      internalTicketQueue,
      todayRoute,
      deviceDiagnostics,
      partsNeeded,
      maintenanceHistory,
      serviceReportState,
    };
  }

to:

    return {
      assignedTicketQueue,
      internalTicketQueue,
      todayRoute,
      deviceDiagnostics,
      partsNeeded,
      maintenanceHistory,
      serviceReportState,
      organizationDeviceCount,
    };
  }
  • Step 5: Pass organizationId from the service layer

In backend/src/dashboard/services/technical-dashboard.service.ts, change:

  async getDashboard(currentUser: CurrentUserData): Promise<TechnicalDashboardResponseDto> {
    this.dashboardPermissionService.assertRole(currentUser, [RoleName.TECHNICAL]);

    const [assignedTicketIds, internalTicketIds] = await Promise.all([
      this.dashboardPermissionService.getTechnicalAssignedTicketIds(currentUser),
      this.dashboardPermissionService.getTechnicalInternalTicketIds(currentUser),
    ]);

    const sections = await this.dashboardQueryService.buildTechnicalSections({
      userId: currentUser.userId,
      assignedTicketIds,
      internalTicketIds,
    });

to:

  async getDashboard(currentUser: CurrentUserData): Promise<TechnicalDashboardResponseDto> {
    this.dashboardPermissionService.assertRole(currentUser, [RoleName.TECHNICAL]);
    const organizationId = this.dashboardPermissionService.getOrganizationIdOrThrow(currentUser);

    const [assignedTicketIds, internalTicketIds] = await Promise.all([
      this.dashboardPermissionService.getTechnicalAssignedTicketIds(currentUser),
      this.dashboardPermissionService.getTechnicalInternalTicketIds(currentUser),
    ]);

    const sections = await this.dashboardQueryService.buildTechnicalSections({
      organizationId,
      userId: currentUser.userId,
      assignedTicketIds,
      internalTicketIds,
    });
  • Step 6: Update technical-dashboard.service.spec.ts for the new dependency

In backend/src/dashboard/services/technical-dashboard.service.spec.ts, the DashboardPermissionService mock is missing getOrganizationIdOrThrow. Change:

        {
          provide: DashboardPermissionService,
          useValue: {
            assertRole: jest.fn(),
            getTechnicalAssignedTicketIds: jest.fn().mockResolvedValue(['ticket-a']),
            getTechnicalInternalTicketIds: jest.fn().mockResolvedValue(['ticket-b']),
          },
        },

to:

        {
          provide: DashboardPermissionService,
          useValue: {
            assertRole: jest.fn(),
            getOrganizationIdOrThrow: jest.fn().mockReturnValue('org-1'),
            getTechnicalAssignedTicketIds: jest.fn().mockResolvedValue(['ticket-a']),
            getTechnicalInternalTicketIds: jest.fn().mockResolvedValue(['ticket-b']),
          },
        },

Also add organizationDeviceCount: 5 to the mocked buildTechnicalSections resolved value:

            buildTechnicalSections: jest.fn().mockResolvedValue({
              assignedTicketQueue: [{ id: 'ticket-a' }],
              internalTicketQueue: [{ id: 'ticket-b' }],
              todayRoute: [],
              deviceDiagnostics: [],
              partsNeeded: [],
              maintenanceHistory: [],
              serviceReportState: {
                pending: 1,
                inProgress: 0,
                completed: 0,
                waitingParts: 0,
              },
              organizationDeviceCount: 5,
            }),

Finally, update the assertion that checks the call arguments:

    expect(dashboardQueryService.buildTechnicalSections).toHaveBeenCalledWith({
      userId: 'tech-1',
      assignedTicketIds: ['ticket-a'],
      internalTicketIds: ['ticket-b'],
    });

to:

    expect(dashboardQueryService.buildTechnicalSections).toHaveBeenCalledWith({
      organizationId: 'org-1',
      userId: 'tech-1',
      assignedTicketIds: ['ticket-a'],
      internalTicketIds: ['ticket-b'],
    });
  • Step 7: Run both tests to verify they pass

Run: make backend-test FILE=src/dashboard/dashboard-query.technical.spec.ts
Expected: PASS

Run: make backend-test FILE=src/dashboard/services/technical-dashboard.service.spec.ts
Expected: PASS

  • Step 8: Commit
git add backend/src/dashboard/dto/technical-dashboard-response.dto.ts \
        backend/src/dashboard/dashboard-query.service.ts \
        backend/src/dashboard/dashboard-query.technical.spec.ts \
        backend/src/dashboard/services/technical-dashboard.service.ts \
        backend/src/dashboard/services/technical-dashboard.service.spec.ts \
        frontend/src/types/dashboard/technical-dashboard.types.ts
git commit -m "feat(dashboard): add real organizationDeviceCount to technical sections"

The sidebar must only query the one dashboard endpoint matching the logged-in user’s role — the other 4 would 403 if fired unconditionally (each is guarded by a role-specific permission). Add an optional enabled flag to every dashboard hook, defaulting to true so the existing dashboard pages are unaffected.

Files:

  • Modify: frontend/src/hooks/dashboard/useAdminDashboard.ts

  • Modify: frontend/src/hooks/dashboard/useOwnerDashboard.ts

  • Modify: frontend/src/hooks/dashboard/useStaffDashboard.ts

  • Modify: frontend/src/hooks/dashboard/useTechnicalDashboard.ts

  • Modify: frontend/src/hooks/dashboard/useSuperAdminDashboard.ts

  • Step 1: Update useAdminDashboard.ts

Change:

import { useQuery } from '@tanstack/react-query';
import { getAdminDashboard } from '@/services/dashboard/admin-dashboard.service';
import { RoleEnum } from '@/shared/auth/roles';

export const useAdminDashboard = () =>
  useQuery({
    queryKey: ['dashboard', RoleEnum.ADMIN, 'organization'],
    queryFn: getAdminDashboard,
    staleTime: 60_000,
  });

to:

import { useQuery } from '@tanstack/react-query';
import { getAdminDashboard } from '@/services/dashboard/admin-dashboard.service';
import { RoleEnum } from '@/shared/auth/roles';

export const useAdminDashboard = (options?: { enabled?: boolean }) =>
  useQuery({
    queryKey: ['dashboard', RoleEnum.ADMIN, 'organization'],
    queryFn: getAdminDashboard,
    staleTime: 60_000,
    enabled: options?.enabled ?? true,
  });
  • Step 2: Apply the identical change to the other 4 hooks

frontend/src/hooks/dashboard/useOwnerDashboard.ts:

import { useQuery } from '@tanstack/react-query';
import { getOwnerDashboard } from '@/services/dashboard/owner-dashboard.service';
import { RoleEnum } from '@/shared/auth/roles';

export const useOwnerDashboard = (options?: { enabled?: boolean }) =>
  useQuery({
    queryKey: ['dashboard', RoleEnum.OWNER, 'own'],
    queryFn: getOwnerDashboard,
    staleTime: 60_000,
    enabled: options?.enabled ?? true,
  });

frontend/src/hooks/dashboard/useStaffDashboard.ts:

import { useQuery } from '@tanstack/react-query';
import { getStaffDashboard } from '@/services/dashboard/staff-dashboard.service';
import { RoleEnum } from '@/shared/auth/roles';

export const useStaffDashboard = (options?: { enabled?: boolean }) =>
  useQuery({
    queryKey: ['dashboard', RoleEnum.STAFF, 'own'],
    queryFn: getStaffDashboard,
    staleTime: 60_000,
    enabled: options?.enabled ?? true,
  });

frontend/src/hooks/dashboard/useTechnicalDashboard.ts:

import { useQuery } from '@tanstack/react-query';
import { getTechnicalDashboard } from '@/services/dashboard/technical-dashboard.service';
import { RoleEnum } from '@/shared/auth/roles';

export const useTechnicalDashboard = (options?: { enabled?: boolean }) =>
  useQuery({
    queryKey: ['dashboard', RoleEnum.TECHNICAL, 'own'],
    queryFn: getTechnicalDashboard,
    staleTime: 60_000,
    enabled: options?.enabled ?? true,
  });

frontend/src/hooks/dashboard/useSuperAdminDashboard.ts:

import { useQuery } from '@tanstack/react-query';
import { getSuperAdminDashboard } from '@/services/dashboard/super-admin-dashboard.service';
import { RoleEnum } from '@/shared/auth/roles';

export const useSuperAdminDashboard = (options?: { enabled?: boolean }) =>
  useQuery({
    queryKey: ['dashboard', RoleEnum.SUPER_ADMIN, 'system'],
    queryFn: getSuperAdminDashboard,
    staleTime: 60_000,
    enabled: options?.enabled ?? true,
  });
  • Step 3: Type-check the frontend

Run: cd frontend && bun run build
Expected: succeeds (the 5 existing call sites in AdminDashboard.tsx, OwnerDashboard.tsx, StaffDashboard.tsx, TechnicalDashboard.tsx, SuperAdminDashboard.tsx call these hooks with no arguments, which still type-checks since options is optional).

  • Step 4: Commit
git add frontend/src/hooks/dashboard/useAdminDashboard.ts \
        frontend/src/hooks/dashboard/useOwnerDashboard.ts \
        frontend/src/hooks/dashboard/useStaffDashboard.ts \
        frontend/src/hooks/dashboard/useTechnicalDashboard.ts \
        frontend/src/hooks/dashboard/useSuperAdminDashboard.ts
git commit -m "feat(dashboard): allow disabling dashboard queries via an enabled option"

Files:

  • Modify: frontend/src/navigation/nav.types.ts

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

  • Modify: frontend/src/navigation/menus/owner.menu.tsx

  • Modify: frontend/src/navigation/menus/staff.menu.tsx

  • Modify: frontend/src/navigation/menus/technical.menu.tsx

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

  • Step 1: Add countVariant to NavItem

In frontend/src/navigation/nav.types.ts, change:

export interface NavItem {
  key: string;
  label: string;
  icon?: React.ReactNode;
  path?: string;
  meta?: string | number;
  badge?: number;
  group?: string;
  requiredPermission?: { resource: string; action?: string; scope?: string };
  roles?: DashboardRole[];
  children?: NavItem[];
}

to:

export interface NavItem {
  key: string;
  label: string;
  icon?: React.ReactNode;
  path?: string;
  meta?: string | number;
  badge?: number;
  /** Marks this item as sidebar-count-driven: 'meta' shows a plain gray count (incl. 0), 'badge' shows a red pill (hidden at 0). The number is filled in at render time from real data, keyed by `key` — see applySidebarCounts. */
  countVariant?: 'meta' | 'badge';
  group?: string;
  requiredPermission?: { resource: string; action?: string; scope?: string };
  roles?: DashboardRole[];
  children?: NavItem[];
}
  • Step 2: Strip the literals from admin.menu.tsx

In frontend/src/navigation/menus/admin.menu.tsx, replace each hardcoded literal with countVariant:

Change meta: 3, (on admin-farms) to countVariant: 'meta',.
Change meta: 24, (on admin-ponds) to countVariant: 'meta',.
Change meta: 18, (on admin-owners-staff) to countVariant: 'meta',.
Change meta: 142, (on admin-device-management) to countVariant: 'meta',.
Change badge: 12, (on admin-alerts) to countVariant: 'badge',.

  • Step 3: Strip the literals from owner.menu.tsx

In frontend/src/navigation/menus/owner.menu.tsx:

Change meta: 24, (on owner-ponds) to countVariant: 'meta',.
Change meta: 13, (on owner-staff) to countVariant: 'meta',.
Change meta: 8, (on owner-cycles) to countVariant: 'meta',.
Change badge: 7, (on owner-alerts) to countVariant: 'badge',.

  • Step 4: Strip the literals from staff.menu.tsx

In frontend/src/navigation/menus/staff.menu.tsx:

Change badge: 6, (on staff-today-tasks) to countVariant: 'badge',.
Change meta: 4, (on staff-my-ponds) to countVariant: 'meta',.

  • Step 5: Strip the literals from technical.menu.tsx

In frontend/src/navigation/menus/technical.menu.tsx:

Change meta: 7, (on technical-my-tickets) to countVariant: 'meta',.
Change meta: 142, (on technical-devices) to countVariant: 'meta',.
Change meta: 4, (on technical-maintenance-schedule) to countVariant: 'meta',.

  • Step 6: Strip the literals from super-admin.menu.tsx

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

Change meta: 47, (on super-admin-organizations) to countVariant: 'meta',.
Change meta: '4.2k', (on super-admin-device-overview) to countVariant: 'meta',.
Change meta: '1.8k', (on super-admin-admin-accounts) to countVariant: 'meta',.

  • Step 7: Run the existing nav-resolver test suite to confirm nothing broke

Run: cd frontend && bun run test src/navigation/nav-resolver.spec.ts
Expected: PASS (that suite checks item visibility/keys/paths, not meta/badge values, so it is unaffected)

  • Step 8: Commit
git add frontend/src/navigation/nav.types.ts frontend/src/navigation/menus/
git commit -m "fix(sidebar): remove hardcoded fake sidebar count literals from every role menu"

Files:

  • Create: frontend/src/navigation/apply-sidebar-counts.ts

  • Test: frontend/src/navigation/apply-sidebar-counts.spec.ts

  • Step 1: Write the failing test

Create frontend/src/navigation/apply-sidebar-counts.spec.ts:

import { describe, expect, it } from 'vitest';
import { applySidebarCounts } from '@/navigation/apply-sidebar-counts';
import type { NavItem } from '@/navigation/nav.types';

describe('applySidebarCounts', () => {
  it('fills in meta from the counts map for meta-variant items', () => {
    const items: NavItem[] = [{ key: 'admin-farms', label: 'Trang trại', countVariant: 'meta' }];

    const result = applySidebarCounts(items, { 'admin-farms': 0 });

    expect(result[0].meta).toBe(0);
  });

  it('fills in badge from the counts map for badge-variant items', () => {
    const items: NavItem[] = [{ key: 'admin-alerts', label: 'Cảnh báo', countVariant: 'badge' }];

    const result = applySidebarCounts(items, { 'admin-alerts': 12 });

    expect(result[0].badge).toBe(12);
  });

  it('defaults to 0 when the key is missing from the counts map', () => {
    const items: NavItem[] = [{ key: 'admin-ponds', label: 'Ao nuôi', countVariant: 'meta' }];

    const result = applySidebarCounts(items, {});

    expect(result[0].meta).toBe(0);
  });

  it('leaves items without a countVariant untouched', () => {
    const items: NavItem[] = [{ key: 'admin-dashboard', label: 'Bảng điều khiển', path: '/dashboard' }];

    const result = applySidebarCounts(items, { 'admin-dashboard': 999 });

    expect(result[0].meta).toBeUndefined();
    expect(result[0].badge).toBeUndefined();
  });

  it('recurses into children', () => {
    const items: NavItem[] = [
      {
        key: 'parent',
        label: 'Parent',
        children: [{ key: 'admin-ponds', label: 'Ao nuôi', countVariant: 'meta' }],
      },
    ];

    const result = applySidebarCounts(items, { 'admin-ponds': 24 });

    expect(result[0].children?.[0].meta).toBe(24);
  });
});
  • Step 2: Run test to verify it fails

Run: make frontend-test FILE=src/navigation/apply-sidebar-counts.spec.ts
Expected: FAIL — Cannot find module '@/navigation/apply-sidebar-counts'


Files:

  • Create: frontend/src/navigation/apply-sidebar-counts.ts

  • Step 1: Write the implementation

Create frontend/src/navigation/apply-sidebar-counts.ts:

import type { NavItem } from '@/navigation/nav.types';

export const applySidebarCounts = (
  items: NavItem[],
  counts: Record<string, number>,
): NavItem[] =>
  items.map((item) => {
    const resolved: NavItem = { ...item };

    if (item.countVariant === 'meta') {
      resolved.meta = counts[item.key] ?? 0;
    } else if (item.countVariant === 'badge') {
      resolved.badge = counts[item.key] ?? 0;
    }

    if (item.children) {
      resolved.children = applySidebarCounts(item.children, counts);
    }

    return resolved;
  });
  • Step 2: Run test to verify it passes

Run: make frontend-test FILE=src/navigation/apply-sidebar-counts.spec.ts
Expected: PASS

  • Step 3: Commit
git add frontend/src/navigation/apply-sidebar-counts.ts frontend/src/navigation/apply-sidebar-counts.spec.ts
git commit -m "feat(sidebar): add applySidebarCounts to stamp real counts onto nav items"

Files:

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

  • Test: frontend/src/hooks/useSidebarCounts.spec.tsx (.tsx, not .ts — the wrapper below uses JSX; see useNotifications.spec.tsx for the same pattern already in this codebase)

  • Step 1: Write the failing test

Create frontend/src/hooks/useSidebarCounts.spec.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSidebarCounts } from '@/hooks/useSidebarCounts';
import { getAdminDashboard } from '@/services/dashboard/admin-dashboard.service';
import { getOwnerDashboard } from '@/services/dashboard/owner-dashboard.service';
import { useAuthStore } from '@/stores/auth.store';

vi.mock('@/services/dashboard/admin-dashboard.service');
vi.mock('@/services/dashboard/owner-dashboard.service');
vi.mock('@/services/dashboard/staff-dashboard.service');
vi.mock('@/services/dashboard/technical-dashboard.service');
vi.mock('@/services/dashboard/super-admin-dashboard.service');

function wrapper({ children }: { children: React.ReactNode }) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}

describe('useSidebarCounts', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useAuthStore.setState({ role: 'ADMIN' });
  });

  it('only fetches the dashboard endpoint matching the current role', async () => {
    vi.mocked(getAdminDashboard).mockResolvedValue({
      generatedAt: '2026-07-05T00:00:00.000Z',
      scope: 'organization',
      kpis: { total: 0 },
      sections: {
        organizationOverview: {
          totalFarms: 0,
          totalPonds: 0,
          activePonds: 0,
          totalDevices: 0,
          activeAlerts: 0,
          totalOwnersAndStaff: 0,
        },
        farmPerformanceTable: [],
        farmRanking: [],
        alertCenter: [],
        organizationDeviceSummary: { total: 0, online: 0, offline: 0, error: 0, onlineRate: 0 },
        dataInterventionQueue: [],
        ownerStaffActivity: [],
        reportsSnapshot: { activeCycles: 0, todayLogs: 0, growthRecordsLast7Days: 0 },
      },
    });

    const { result } = renderHook(() => useSidebarCounts(), { wrapper });

    await waitFor(() => {
      expect(result.current['admin-farms']).toBe(0);
    });

    expect(getAdminDashboard).toHaveBeenCalledTimes(1);
    expect(getOwnerDashboard).not.toHaveBeenCalled();
    expect(result.current['admin-ponds']).toBe(0);
    expect(result.current['admin-owners-staff']).toBe(0);
    expect(result.current['admin-device-management']).toBe(0);
    expect(result.current['admin-alerts']).toBe(0);
  });
});
  • Step 2: Run test to verify it fails

Run: make frontend-test FILE=src/hooks/useSidebarCounts.spec.tsx
Expected: FAIL — Cannot find module '@/hooks/useSidebarCounts'


Files:

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

  • Step 1: Write the implementation

Create frontend/src/hooks/useSidebarCounts.ts:

import { useMemo } from 'react';
import { useAdminDashboard } from '@/hooks/dashboard/useAdminDashboard';
import { useOwnerDashboard } from '@/hooks/dashboard/useOwnerDashboard';
import { useStaffDashboard } from '@/hooks/dashboard/useStaffDashboard';
import { useSuperAdminDashboard } from '@/hooks/dashboard/useSuperAdminDashboard';
import { useTechnicalDashboard } from '@/hooks/dashboard/useTechnicalDashboard';
import { RoleEnum } from '@/shared/auth/roles';
import { useAuthStore } from '@/stores/auth.store';

export const useSidebarCounts = (): Record<string, number> => {
  const role = useAuthStore((state) => state.role);

  const adminQuery = useAdminDashboard({ enabled: role === RoleEnum.ADMIN });
  const ownerQuery = useOwnerDashboard({ enabled: role === RoleEnum.OWNER });
  const staffQuery = useStaffDashboard({ enabled: role === RoleEnum.STAFF });
  const technicalQuery = useTechnicalDashboard({ enabled: role === RoleEnum.TECHNICAL });
  const superAdminQuery = useSuperAdminDashboard({ enabled: role === RoleEnum.SUPER_ADMIN });

  return useMemo((): Record<string, number> => {
    switch (role) {
      case RoleEnum.ADMIN: {
        const overview = adminQuery.data?.sections.organizationOverview;
        if (!overview) return {};
        return {
          'admin-farms': overview.totalFarms,
          'admin-ponds': overview.totalPonds,
          'admin-owners-staff': overview.totalOwnersAndStaff,
          'admin-device-management': overview.totalDevices,
          'admin-alerts': overview.activeAlerts,
        };
      }
      case RoleEnum.OWNER: {
        const sections = ownerQuery.data?.sections;
        if (!sections) return {};
        return {
          'owner-ponds': sections.farmOverview?.totalPonds ?? 0,
          'owner-staff': sections.staffPerformance.length,
          'owner-cycles': sections.farmOverview?.activeCycles ?? 0,
          'owner-alerts': sections.farmDeviceStatus.activeAlerts,
        };
      }
      case RoleEnum.STAFF: {
        const sections = staffQuery.data?.sections;
        if (!sections) return {};
        return {
          'staff-today-tasks': sections.todayChecklist.length,
          'staff-my-ponds': sections.myPonds.length,
        };
      }
      case RoleEnum.TECHNICAL: {
        const sections = technicalQuery.data?.sections;
        if (!sections) return {};
        return {
          'technical-my-tickets': sections.assignedTicketQueue.length,
          'technical-devices': sections.organizationDeviceCount,
          'technical-maintenance-schedule': Math.max(
            sections.internalTicketQueue.length - sections.maintenanceHistory.length,
            0,
          ),
        };
      }
      case RoleEnum.SUPER_ADMIN: {
        const sections = superAdminQuery.data?.sections;
        if (!sections) return {};
        return {
          'super-admin-organizations': sections.saasOverview.totalOrganizations,
          'super-admin-device-overview': sections.deviceInventoryLifecycle.total,
          'super-admin-admin-accounts': sections.saasOverview.totalUsers,
        };
      }
      default:
        return {};
    }
  }, [role, adminQuery.data, ownerQuery.data, staffQuery.data, technicalQuery.data, superAdminQuery.data]);
};
  • Step 2: Run test to verify it passes

Run: make frontend-test FILE=src/hooks/useSidebarCounts.spec.tsx
Expected: PASS

  • Step 3: Commit
git add frontend/src/hooks/useSidebarCounts.ts frontend/src/hooks/useSidebarCounts.spec.tsx
git commit -m "feat(sidebar): add useSidebarCounts to fetch real counts for the logged-in role"

Files:

  • Modify: frontend/src/components/layouts/SidebarMenu.tsx:139-167

  • Step 1: Apply real counts before building the Ant Design menu tree

In frontend/src/components/layouts/SidebarMenu.tsx, add the import:

import { applySidebarCounts } from '@/navigation/apply-sidebar-counts';
import { resolveNavigation } from '@/navigation/nav-resolver';
import { useSidebarCounts } from '@/hooks/useSidebarCounts';

(keep the existing resolveNavigation import line, just add the new applySidebarCounts and useSidebarCounts imports alongside the other @/navigation / @/hooks imports already in the file)

Then change:

export const SidebarMenu = ({ onNavigate, collapsed }: SidebarMenuProps) => {
  const navigate = useNavigate();
  const location = useLocation();
  const permissions = useAuthStore((state) => state.permissions);
  const role = useAuthStore((state) => state.role);

  const navItems = useMemo(() => {
    if (!role) {
      return [];
    }
    return resolveNavigation(role, permissions);
  }, [permissions, role]);

to:

export const SidebarMenu = ({ onNavigate, collapsed }: SidebarMenuProps) => {
  const navigate = useNavigate();
  const location = useLocation();
  const permissions = useAuthStore((state) => state.permissions);
  const role = useAuthStore((state) => state.role);
  const sidebarCounts = useSidebarCounts();

  const navItems = useMemo(() => {
    if (!role) {
      return [];
    }
    return applySidebarCounts(resolveNavigation(role, permissions), sidebarCounts);
  }, [permissions, role, sidebarCounts]);
  • Step 2: Type-check and run the frontend test suite

Run: cd frontend && bun run build
Expected: succeeds

Run: make frontend-test FILE=src/navigation/nav-resolver.spec.ts
Expected: PASS

  • Step 3: Commit
git add frontend/src/components/layouts/SidebarMenu.tsx
git commit -m "fix(sidebar): render real per-role counts instead of hardcoded literals"

  • Step 1: Start both servers

Run: bun run backend:dev (in one terminal)
Run: bun run frontend:dev (in another terminal)

  • Step 2: Log in and check the sidebar

Log in as adm-admin@hmpiot.com / Demo@123456. Confirm:

  • “Trang trại” shows 0 (not 3)

  • “Ao nuôi” shows 0 (not 24)

  • “Chủ Farm & Nhân viên” shows the real count of owner+staff users in this org

  • “Quản lý thiết bị” shows the real device count

  • “Cảnh báo” shows the real active-alert count (or no badge if 0)

  • Step 3: Spot-check one other role

Log in as an OWNER, STAFF, TECHNICAL, or SUPER_ADMIN test account (check docs/ or seed scripts for available demo accounts) and confirm the sidebar counts for that role also reflect real data rather than the old literals (24, 13, 8, 7 for OWNER; 6, 4 for STAFF; 7, 142, 4 for TECHNICAL; 47, 4.2k, 1.8k for SUPER_ADMIN).

  • Step 4: Confirm no stray fake numbers remain

Run: grep -rn "meta: [0-9]\|badge: [0-9]\|meta: '" frontend/src/navigation/menus/
Expected: no matches (every numeric literal has been replaced by countVariant)


  • Step 1: Run the full backend test suite

Run: cd backend && bun run test
Expected: all tests pass

  • Step 2: Run the full frontend test suite

Run: cd frontend && bun run test
Expected: all tests pass

  • Step 3: Run Biome across both workspaces

Run: bun run check
Expected: no errors

  • Step 4: Push and open the PR
git push -u origin <branch-name>
gh-axi pr create --title "Fix sidebar showing hardcoded fake farm/pond/device/alert/staff counts" --body "Closes #133"