diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts index 43e5b33..247187b 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.spec.ts @@ -10,6 +10,7 @@ describe('DashboardService', () => { task: { count: jest.Mock; findMany: jest.Mock }; honeyBatch: { count: jest.Mock }; stockItem: { findMany: jest.Mock }; + swarmControlCheck: { findMany: jest.Mock }; }; beforeEach(() => { @@ -26,6 +27,7 @@ describe('DashboardService', () => { }, honeyBatch: { count: jest.fn().mockResolvedValue(1) }, stockItem: { findMany: jest.fn().mockResolvedValue([]) }, + swarmControlCheck: { findMany: jest.fn().mockResolvedValue([]) }, }; service = new DashboardService(prisma as unknown as PrismaService); }); @@ -63,4 +65,46 @@ describe('DashboardService', () => { 'LOW_STOCK', ]); }); + + it('flags a colony whose most recent swarm check is overdue', async () => { + prisma.swarmControlCheck.findMany.mockResolvedValue([ + { + colonyId: 'colony-1', + nextCheckDate: new Date('2020-01-01'), + colony: { code: 'V-001' }, + }, + ]); + + const alerts = await service.alerts(); + + expect(alerts).toEqual([ + { + type: 'OVERDUE_SWARM_CHECK', + message: 'Schwarmkontrolle für Volk V-001 überfällig', + relatedType: 'Colony', + relatedId: 'colony-1', + }, + ]); + }); + + it('ignores an overdue swarm check when a newer check already superseded it', async () => { + prisma.swarmControlCheck.findMany.mockResolvedValue([ + { + colonyId: 'colony-1', + date: new Date('2026-06-10'), + nextCheckDate: null, + colony: { code: 'V-001' }, + }, + { + colonyId: 'colony-1', + date: new Date('2026-06-01'), + nextCheckDate: new Date('2020-01-01'), + colony: { code: 'V-001' }, + }, + ]); + + const alerts = await service.alerts(); + + expect(alerts).toHaveLength(0); + }); }); diff --git a/ApiCore-Backend/src/dashboard/dashboard.service.ts b/ApiCore-Backend/src/dashboard/dashboard.service.ts index 0cd6129..73ffeeb 100644 --- a/ApiCore-Backend/src/dashboard/dashboard.service.ts +++ b/ApiCore-Backend/src/dashboard/dashboard.service.ts @@ -11,7 +11,8 @@ export interface DashboardSummary { } export interface DashboardAlert { - type: 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK'; + type: + 'OVERDUE_TASK' | 'COLONY_ATTENTION' | 'LOW_STOCK' | 'OVERDUE_SWARM_CHECK'; message: string; relatedType: string; relatedId: string; @@ -60,20 +61,30 @@ export class DashboardService { } async alerts(): Promise { - const [overdueTasks, attentionColonies, stockItems] = await Promise.all([ - this.prisma.task.findMany({ - where: { - status: { in: ['OPEN', 'IN_PROGRESS'] }, - dueDate: { lt: new Date() }, - }, - }), - this.prisma.colony.findMany({ - where: { status: { in: [...ATTENTION_COLONY_STATUSES] } }, - }), - this.prisma.stockItem.findMany({ - where: { minThreshold: { not: null } }, - }), - ]); + const [overdueTasks, attentionColonies, stockItems, swarmChecks] = + await Promise.all([ + this.prisma.task.findMany({ + where: { + status: { in: ['OPEN', 'IN_PROGRESS'] }, + dueDate: { lt: new Date() }, + }, + }), + this.prisma.colony.findMany({ + where: { status: { in: [...ATTENTION_COLONY_STATUSES] } }, + }), + this.prisma.stockItem.findMany({ + where: { minThreshold: { not: null } }, + }), + this.prisma.swarmControlCheck.findMany({ + where: { colony: { status: 'ACTIVE' } }, + orderBy: [{ colonyId: 'asc' }, { date: 'desc' }], + select: { + colonyId: true, + nextCheckDate: true, + colony: { select: { code: true } }, + }, + }), + ]); const alerts: DashboardAlert[] = []; for (const task of overdueTasks) { @@ -102,6 +113,26 @@ export class DashboardService { }); } } + // swarmChecks is ordered by colonyId then date desc, so the first entry + // seen per colonyId is that colony's most recent check - only the most + // recent nextCheckDate matters, an older overdue one has been superseded. + const now = new Date(); + const latestCheckByColony = new Map(); + for (const check of swarmChecks) { + if (!latestCheckByColony.has(check.colonyId)) { + latestCheckByColony.set(check.colonyId, check); + } + } + for (const check of latestCheckByColony.values()) { + if (check.nextCheckDate && check.nextCheckDate < now) { + alerts.push({ + type: 'OVERDUE_SWARM_CHECK', + message: `Schwarmkontrolle für Volk ${check.colony.code} überfällig`, + relatedType: 'Colony', + relatedId: check.colonyId, + }); + } + } return alerts; } } diff --git a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx index db48c82..2a1e76d 100644 --- a/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/dashboard/page.tsx @@ -12,6 +12,7 @@ import { Layers, PackageSearch, ShieldAlert, + Sprout, } from "lucide-react"; import { useAuth } from "@/lib/auth/auth-context"; import { fetchDashboardAlerts, fetchDashboardSummary } from "@/lib/dashboard/api"; @@ -60,6 +61,11 @@ const ALERT_META: Record< icon: PackageSearch, className: "border-warning/40 bg-warning/10", }, + OVERDUE_SWARM_CHECK: { + label: "Schwarmkontrolle überfällig", + icon: Sprout, + className: "border-destructive/30 bg-destructive/5", + }, }; export default function DashboardPage() { diff --git a/ApiCore-Frontend/src/lib/dashboard/types.ts b/ApiCore-Frontend/src/lib/dashboard/types.ts index 1a5ecd6..61c8260 100644 --- a/ApiCore-Frontend/src/lib/dashboard/types.ts +++ b/ApiCore-Frontend/src/lib/dashboard/types.ts @@ -7,7 +7,11 @@ export interface DashboardSummary { stockItemsLowCount: number; } -export type DashboardAlertType = "OVERDUE_TASK" | "COLONY_ATTENTION" | "LOW_STOCK"; +export type DashboardAlertType = + | "OVERDUE_TASK" + | "COLONY_ATTENTION" + | "LOW_STOCK" + | "OVERDUE_SWARM_CHECK"; export interface DashboardAlert { type: DashboardAlertType;