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:
parent
3b8bb1d5d6
commit
f99ddde503
3 changed files with 259 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
30
src/app/api/dashboard/deadlines/route.ts
Normal file
30
src/app/api/dashboard/deadlines/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
211
src/lib/services/deadline-service.ts
Normal file
211
src/lib/services/deadline-service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue