From d4bee0e8d33891989719bd005d30bc5be5d5fe94 Mon Sep 17 00:00:00 2001 From: DJP Date: Tue, 21 Apr 2026 15:29:10 -0400 Subject: [PATCH] Assignment notifications + workload approved-filter + My Work fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four connected UX improvements in one commit: 1. Assignment creates a Notification row. notifyAssignment() was already written in notification-service but nothing called it. Wired into assignUserToStage right after the upsert — fires only on NEW assignments (not role updates), never self-notifies, non-blocking. The assignee sees it on /notifications with a link straight to the deliverable. 2. Workload drops approved stages from weekly totals. Previously terminal stages (APPROVED / DELIVERED / SKIPPED) stayed in the week they completed, making people look busier than they were. Now they drop the moment the stage goes terminal, matching the user's intuition ("if it's approved it shouldn't count as my workload any more"). 3. My Work rows are clickable — each row is now a to the deliverable detail page. Hover state too. 4. My Work has a completed-window toggle. Pill group in the header: "Active only" (default) / "+ completed 1w" / 2w / 4w. Switches in APPROVED / DELIVERED / SKIPPED assignments whose completedDate falls inside the chosen window. No "all time" option — that list grows without bound. --- src/app/(app)/my-work/page.tsx | 182 ++++++++++++++++++------- src/lib/services/assignment-service.ts | 20 ++- src/lib/services/workload-service.ts | 18 +-- 3 files changed, 162 insertions(+), 58 deletions(-) diff --git a/src/app/(app)/my-work/page.tsx b/src/app/(app)/my-work/page.tsx index e947724..4bdf5d8 100644 --- a/src/app/(app)/my-work/page.tsx +++ b/src/app/(app)/my-work/page.tsx @@ -1,11 +1,28 @@ "use client"; +/** + * "My Work" — the current user's assignments grouped by project. + * + * Default view: only ACTIVE stages (NOT_STARTED / IN_PROGRESS / + * IN_REVIEW / CHANGES_REQUESTED / BLOCKED). Producers can flip the + * filter to include recently-completed work (APPROVED / DELIVERED / + * SKIPPED) from the last 1 / 2 / 4 weeks. "All time" is intentionally + * not offered — the list would grow unbounded. + * + * Every row is clickable and navigates straight to the deliverable + * detail page for updates, approvals, notes, etc. + */ + +import { useMemo, useState } from "react"; import Link from "next/link"; import { ClipboardList } from "lucide-react"; +import { subWeeks } from "date-fns"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { StageStatusBadge } from "@/components/stages/stage-status-badge"; +import { cn } from "@/lib/utils"; import { useMyWork } from "@/hooks/use-my-work"; interface Assignment { @@ -14,6 +31,7 @@ interface Assignment { deliverableStage: { id: string; status: string; + completedDate: string | null; template: { name: string; order: number }; stageDefinition?: { name: string; order: number } | null; deliverable: { @@ -24,30 +42,91 @@ interface Assignment { }; } +const ACTIVE_STAGE_STATUSES = new Set([ + "NOT_STARTED", + "IN_PROGRESS", + "IN_REVIEW", + "CHANGES_REQUESTED", + "BLOCKED", +]); + +type CompletedWindow = "none" | "1w" | "2w" | "4w"; + +const WINDOW_LABELS: Record = { + none: "Active only", + "1w": "+ completed 1w", + "2w": "+ completed 2w", + "4w": "+ completed 4w", +}; + export default function MyWorkPage() { const { data: assignments, isLoading } = useMyWork(); + const [completedWindow, setCompletedWindow] = useState("none"); - // Group by project - const grouped = new Map(); - if (Array.isArray(assignments)) { - for (const assignment of assignments as Assignment[]) { - const project = assignment.deliverableStage.deliverable.project; - if (!grouped.has(project.id)) { - grouped.set(project.id, { project, items: [] }); - } - grouped.get(project.id)!.items.push(assignment); + // Apply the active/completed filter. Active stages always show; + // terminal ones (APPROVED / DELIVERED / SKIPPED) appear only when the + // filter includes them AND they completed inside the chosen window. + const filtered = useMemo(() => { + const rows: Assignment[] = Array.isArray(assignments) + ? (assignments as Assignment[]) + : []; + const now = Date.now(); + const cutoff = + completedWindow === "1w" ? subWeeks(now, 1).getTime() + : completedWindow === "2w" ? subWeeks(now, 2).getTime() + : completedWindow === "4w" ? subWeeks(now, 4).getTime() + : null; + + return rows.filter((a) => { + const status = a.deliverableStage.status; + if (ACTIVE_STAGE_STATUSES.has(status)) return true; + if (cutoff === null) return false; + const done = a.deliverableStage.completedDate; + if (!done) return false; + return new Date(done).getTime() >= cutoff; + }); + }, [assignments, completedWindow]); + + const grouped = useMemo(() => { + const g = new Map(); + for (const a of filtered) { + const project = a.deliverableStage.deliverable.project; + if (!g.has(project.id)) g.set(project.id, { project, items: [] }); + g.get(project.id)!.items.push(a); } - } + return g; + }, [filtered]); return (
-
- -
-

My Work

-

- Your assigned pipeline stages across all projects -

+
+
+ +
+

My Work

+

+ Your assigned pipeline stages across all projects +

+
+
+ + {/* Completed-window toggle — pill group. Default "Active only" + keeps the list focused on what's still in flight. */} +
+ {(Object.keys(WINDOW_LABELS) as CompletedWindow[]).map((w) => ( + + ))}
@@ -87,33 +166,39 @@ export default function MyWorkPage() { (a.deliverableStage.stageDefinition?.order ?? a.deliverableStage.template.order) - (b.deliverableStage.stageDefinition?.order ?? b.deliverableStage.template.order) ) - .map((assignment) => ( -
-
-
-

- {assignment.deliverableStage.deliverable.name} -

-

- {assignment.deliverableStage.stageDefinition?.name ?? assignment.deliverableStage.template.name} -

+ .map((assignment) => { + const deliverable = assignment.deliverableStage.deliverable; + const href = `/projects/${deliverable.project.id}/deliverables/${deliverable.id}`; + return ( + +
+
+

+ {deliverable.name} +

+

+ {assignment.deliverableStage.stageDefinition?.name ?? + assignment.deliverableStage.template.name} +

+
-
-
- {assignment.role && ( - - {assignment.role} - - )} - -
-
- ))} +
+ {assignment.role && ( + + {assignment.role} + + )} + +
+ + ); + })}
@@ -122,10 +207,15 @@ export default function MyWorkPage() { {!isLoading && grouped.size === 0 && (
-

No assignments yet.

+

+ {completedWindow === "none" + ? "No active assignments." + : "Nothing matches this window."} +

- You'll see your assigned stages here once a producer assigns work - to you. + {completedWindow === "none" + ? "You'll see your assigned stages here once a producer assigns work to you." + : "Try a wider window or switch back to active only."}

)} diff --git a/src/lib/services/assignment-service.ts b/src/lib/services/assignment-service.ts index b7e49fd..40e360a 100644 --- a/src/lib/services/assignment-service.ts +++ b/src/lib/services/assignment-service.ts @@ -1,6 +1,7 @@ import { prisma } from "@/lib/prisma"; import type { AssignmentRole } from "@/generated/prisma/client"; import { emitAssignmentCreated } from "@/lib/automation/event-bus"; +import { notifyAssignment } from "@/lib/services/notification-service"; import { assertProjectVisible, VisibilityError, @@ -68,7 +69,24 @@ export async function assignUserToStage( include: { user: true, deliverableStage: { include: { template: true } } }, }); - // Emit automation event (non-blocking) + // Fire a Notification row for the assignee — shows up on their + // /notifications page. Only for NEW assignments, not role updates + // (where `existing` was truthy), and never self-notify. Non-blocking. + if (!existing) { + notifyAssignment( + userId, + ctx.userId, + stage!.deliverable.name, + stage!.template.name, + stage!.deliverable.project.id, + stage!.deliverable.id + ).catch((err) => { + console.error("[Notification] notifyAssignment failed:", err); + }); + } + + // Emit automation event (non-blocking) — downstream rules may fire + // additional side effects beyond the direct notification above. emitAssignmentCreated(stage!.deliverable.project.organizationId, { assignmentId: result.id, stageId: deliverableStageId, diff --git a/src/lib/services/workload-service.ts b/src/lib/services/workload-service.ts index b7d0937..4abab4d 100644 --- a/src/lib/services/workload-service.ts +++ b/src/lib/services/workload-service.ts @@ -208,8 +208,13 @@ export async function getWorkloadData( const weekAssignments = assignments.filter((a) => { const stage = a.deliverableStage; - // If the stage is completed before this week starts, skip it - if (stage.completedDate && new Date(stage.completedDate) < weekStartDate) { + // Terminal stages drop out entirely — once a stage is APPROVED + // / DELIVERED / SKIPPED the assignee is done with it, so it + // shouldn't keep counting toward their workload even in the + // week it completed. This was previously showing approved + // stages in the "just-finished" week which made people look + // busier than they actually were. + if ((TERMINAL_STATUSES as readonly string[]).includes(stage.status)) { return false; } @@ -219,15 +224,6 @@ export async function getWorkloadData( return false; } - // If stage is in a terminal state but completed during or after this week - if ((TERMINAL_STATUSES as readonly string[]).includes(stage.status)) { - // It was active during this week if it completed during or after this week - if (stage.completedDate) { - return new Date(stage.completedDate) >= weekStartDate; - } - return false; - } - // Stage is currently active — it spans into this week return true; });