Add deadline tracking with approaching/overdue detection

Deadline service checks deliverables and stages for approaching
(within 3 days) and overdue deadlines. API endpoint for fetching
deadlines and optionally generating notifications for producers.
Visual overdue indicators (red text + warning icon) on stage cards.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Leivur R. Djurhuus 2026-02-28 21:47:42 -06:00
parent 3b8bb1d5d6
commit f99ddde503
3 changed files with 259 additions and 2 deletions

View file

@ -3,8 +3,9 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { format } from "date-fns";
import { format, isPast, differenceInDays } from "date-fns";
import {
AlertTriangle,
ArrowLeft,
ChevronRight,
FileText,
@ -249,8 +250,23 @@ export default function DeliverableDetailPage() {
</span>
)}
{stage.dueDate && !stage.completedDate && (
<span>
<span
className={cn(
isPast(new Date(stage.dueDate)) &&
"font-medium text-[var(--status-blocked)]",
!isPast(new Date(stage.dueDate)) &&
differenceInDays(
new Date(stage.dueDate),
new Date()
) <= 3 &&
"font-medium text-[var(--status-in-review)]"
)}
>
{isPast(new Date(stage.dueDate)) && (
<AlertTriangle className="mr-0.5 inline h-3 w-3" />
)}
Due: {format(new Date(stage.dueDate), "MMM d")}
{isPast(new Date(stage.dueDate)) && " (overdue)"}
</span>
)}
</div>

View file

@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { getAuthSession, serverError } from "@/lib/api-utils";
import { checkDeadlines, generateDeadlineNotifications } from "@/lib/services/deadline-service";
// GET /api/dashboard/deadlines — check approaching and overdue deadlines
// ?notify=true will also generate notifications
export async function GET(request: Request) {
const { session, error } = await getAuthSession();
if (error) return error;
try {
const organizationId = (session as any)?.user?.organizationId;
if (!organizationId) {
return NextResponse.json({ approaching: [], overdue: [] });
}
const url = new URL(request.url);
const notify = url.searchParams.get("notify") === "true";
if (notify) {
const result = await generateDeadlineNotifications(organizationId);
return NextResponse.json(result);
}
const deadlines = await checkDeadlines(organizationId);
return NextResponse.json(deadlines);
} catch (e) {
return serverError(e);
}
}

View file

@ -0,0 +1,211 @@
import { prisma } from "@/lib/prisma";
import { createNotifications } from "@/lib/services/notification-service";
import type { NotificationType } from "@/generated/prisma/client";
import { addDays, isBefore, isAfter, startOfDay } from "date-fns";
interface DeadlineResult {
approaching: {
id: string;
name: string;
dueDate: Date;
type: "deliverable" | "stage";
projectId: string;
projectName: string;
daysUntilDue: number;
}[];
overdue: {
id: string;
name: string;
dueDate: Date;
type: "deliverable" | "stage";
projectId: string;
projectName: string;
daysOverdue: number;
}[];
}
/**
* Check for approaching and overdue deadlines across the organization.
*/
export async function checkDeadlines(
organizationId: string
): Promise<DeadlineResult> {
const now = startOfDay(new Date());
const approachingThreshold = addDays(now, 3);
// Fetch deliverables with due dates that are approaching or overdue
const deliverables = await prisma.deliverable.findMany({
where: {
project: { organizationId },
dueDate: { not: null },
status: { notIn: ["APPROVED", "ON_HOLD"] },
},
select: {
id: true,
name: true,
dueDate: true,
project: { select: { id: true, name: true } },
},
});
// Fetch stages with due dates
const stages = await prisma.deliverableStage.findMany({
where: {
deliverable: { project: { organizationId } },
dueDate: { not: null },
status: { notIn: ["APPROVED", "SKIPPED"] },
},
select: {
id: true,
dueDate: true,
template: { select: { name: true } },
deliverable: {
select: {
name: true,
project: { select: { id: true, name: true } },
},
},
},
});
const approaching: DeadlineResult["approaching"] = [];
const overdue: DeadlineResult["overdue"] = [];
// Check deliverables
for (const d of deliverables) {
if (!d.dueDate) continue;
const due = startOfDay(d.dueDate);
if (isBefore(due, now)) {
const daysOverdue = Math.ceil(
(now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24)
);
overdue.push({
id: d.id,
name: d.name,
dueDate: d.dueDate,
type: "deliverable",
projectId: d.project.id,
projectName: d.project.name,
daysOverdue,
});
} else if (isBefore(due, approachingThreshold)) {
const daysUntilDue = Math.ceil(
(due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
approaching.push({
id: d.id,
name: d.name,
dueDate: d.dueDate,
type: "deliverable",
projectId: d.project.id,
projectName: d.project.name,
daysUntilDue,
});
}
}
// Check stages
for (const s of stages) {
if (!s.dueDate) continue;
const due = startOfDay(s.dueDate);
if (isBefore(due, now)) {
const daysOverdue = Math.ceil(
(now.getTime() - due.getTime()) / (1000 * 60 * 60 * 24)
);
overdue.push({
id: s.id,
name: `${s.template.name}${s.deliverable.name}`,
dueDate: s.dueDate,
type: "stage",
projectId: s.deliverable.project.id,
projectName: s.deliverable.project.name,
daysOverdue,
});
} else if (isBefore(due, approachingThreshold)) {
const daysUntilDue = Math.ceil(
(due.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
approaching.push({
id: s.id,
name: `${s.template.name}${s.deliverable.name}`,
dueDate: s.dueDate,
type: "stage",
projectId: s.deliverable.project.id,
projectName: s.deliverable.project.name,
daysUntilDue,
});
}
}
return { approaching, overdue };
}
/**
* Generate notifications for approaching and overdue deadlines.
* Should be called periodically (e.g., daily cron or on dashboard load).
*/
export async function generateDeadlineNotifications(organizationId: string) {
const { approaching, overdue } = await checkDeadlines(organizationId);
// Get all producers in the org to notify
const producers = await prisma.user.findMany({
where: {
organizationId,
role: { in: ["ADMIN", "PRODUCER"] },
},
select: { id: true },
});
if (producers.length === 0) return { approaching: 0, overdue: 0 };
const notifications: {
userId: string;
type: NotificationType;
title: string;
message: string;
link?: string;
}[] = [];
// Overdue notifications
for (const item of overdue) {
for (const producer of producers) {
notifications.push({
userId: producer.id,
type: "DEADLINE_OVERDUE",
title: `Overdue: ${item.name}`,
message: `${item.daysOverdue} day${item.daysOverdue !== 1 ? "s" : ""} overdue in ${item.projectName}`,
link:
item.type === "deliverable"
? `/projects/${item.projectId}/deliverables/${item.id}`
: `/projects/${item.projectId}`,
});
}
}
// Approaching notifications
for (const item of approaching) {
for (const producer of producers) {
notifications.push({
userId: producer.id,
type: "DEADLINE_APPROACHING",
title: `Due soon: ${item.name}`,
message: `Due in ${item.daysUntilDue} day${item.daysUntilDue !== 1 ? "s" : ""} (${item.projectName})`,
link:
item.type === "deliverable"
? `/projects/${item.projectId}/deliverables/${item.id}`
: `/projects/${item.projectId}`,
});
}
}
if (notifications.length > 0) {
await createNotifications(notifications);
}
return {
approaching: approaching.length,
overdue: overdue.length,
};
}