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).
Backend counts already available (no backend change needed)
| 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 |
Backend counts that need a new field
| 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. |
Task 1: Add totalOwnersAndStaff to the admin dashboard — failing test
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).
Task 2: Implement totalOwnersAndStaff
Files:
Modify:
backend/src/dashboard/dto/admin-dashboard-response.dto.ts:3-8Modify:
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-8Step 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
getAdminOwnersAndStaffCountprivate 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"
Task 3: Add organizationDeviceCount to the technical dashboard — failing test
Files:
Modify:
backend/src/dashboard/dashboard-query.technical.spec.tsStep 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.
Task 4: Implement organizationDeviceCount
Files:
Modify:
backend/src/dashboard/dto/technical-dashboard-response.dto.ts:56-63Modify:
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.tsModify:
backend/src/dashboard/services/technical-dashboard.service.spec.tsModify:
frontend/src/types/dashboard/technical-dashboard.types.tsStep 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
organizationIdinbuildTechnicalSectionsand 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
organizationIdfrom 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.tsfor 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"
Task 5: Let the 5 dashboard hooks be conditionally disabled
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.tsModify:
frontend/src/hooks/dashboard/useOwnerDashboard.tsModify:
frontend/src/hooks/dashboard/useStaffDashboard.tsModify:
frontend/src/hooks/dashboard/useTechnicalDashboard.tsModify:
frontend/src/hooks/dashboard/useSuperAdminDashboard.tsStep 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"
Task 6: Add countVariant to NavItem and strip hardcoded numbers from every menu
Files:
Modify:
frontend/src/navigation/nav.types.tsModify:
frontend/src/navigation/menus/admin.menu.tsxModify:
frontend/src/navigation/menus/owner.menu.tsxModify:
frontend/src/navigation/menus/staff.menu.tsxModify:
frontend/src/navigation/menus/technical.menu.tsxModify:
frontend/src/navigation/menus/super-admin.menu.tsxStep 1: Add
countVarianttoNavItem
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"
Task 7: applySidebarCounts — failing test
Files:
Create:
frontend/src/navigation/apply-sidebar-counts.tsTest:
frontend/src/navigation/apply-sidebar-counts.spec.tsStep 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'
Task 8: Implement applySidebarCounts
Files:
Create:
frontend/src/navigation/apply-sidebar-counts.tsStep 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"
Task 9: useSidebarCounts — failing test
Files:
Create:
frontend/src/hooks/useSidebarCounts.tsTest:
frontend/src/hooks/useSidebarCounts.spec.tsx(.tsx, not.ts— the wrapper below uses JSX; seeuseNotifications.spec.tsxfor 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'
Task 10: Implement useSidebarCounts
Files:
Create:
frontend/src/hooks/useSidebarCounts.tsStep 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"
Task 11: Wire useSidebarCounts into SidebarMenu
Files:
Modify:
frontend/src/components/layouts/SidebarMenu.tsx:139-167Step 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"
Task 12: Manual verification
- 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(not3)“Ao nuôi” shows
0(not24)“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)
Task 13: Full verification and PR
- 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"