Assignment notifications + workload approved-filter + My Work fixes

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 <Link> 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.
This commit is contained in:
DJP 2026-04-21 15:29:10 -04:00
parent 827ed587bb
commit d4bee0e8d3
3 changed files with 162 additions and 58 deletions

View file

@ -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<CompletedWindow, string> = {
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<CompletedWindow>("none");
// Group by project
const grouped = new Map<string, { project: any; items: Assignment[] }>();
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<string, { project: Assignment["deliverableStage"]["deliverable"]["project"]; items: Assignment[] }>();
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 (
<div>
<div className="flex items-center gap-3">
<ClipboardList className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">My Work</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Your assigned pipeline stages across all projects
</p>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<ClipboardList className="h-6 w-6 text-[var(--primary)]" />
<div>
<h1 className="font-heading text-2xl font-bold">My Work</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Your assigned pipeline stages across all projects
</p>
</div>
</div>
{/* Completed-window toggle pill group. Default "Active only"
keeps the list focused on what's still in flight. */}
<div className="flex items-center gap-0.5 rounded-md border bg-[var(--card)] p-0.5">
{(Object.keys(WINDOW_LABELS) as CompletedWindow[]).map((w) => (
<Button
key={w}
size="sm"
variant={completedWindow === w ? "default" : "ghost"}
className={cn(
"h-7 px-2 text-[11px]",
completedWindow !== w && "text-[var(--muted-foreground)]"
)}
onClick={() => setCompletedWindow(w)}
>
{WINDOW_LABELS[w]}
</Button>
))}
</div>
</div>
@ -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) => (
<div
key={assignment.id}
className="flex items-center justify-between rounded-md border px-4 py-3"
>
<div className="flex items-center gap-3">
<div>
<p className="text-sm font-medium">
{assignment.deliverableStage.deliverable.name}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{assignment.deliverableStage.stageDefinition?.name ?? assignment.deliverableStage.template.name}
</p>
.map((assignment) => {
const deliverable = assignment.deliverableStage.deliverable;
const href = `/projects/${deliverable.project.id}/deliverables/${deliverable.id}`;
return (
<Link
key={assignment.id}
href={href}
className="flex items-center justify-between rounded-md border px-4 py-3 transition-colors hover:border-[var(--primary)] hover:bg-[var(--muted)]/30"
>
<div className="flex items-center gap-3">
<div>
<p className="text-sm font-medium">
{deliverable.name}
</p>
<p className="text-xs text-[var(--muted-foreground)]">
{assignment.deliverableStage.stageDefinition?.name ??
assignment.deliverableStage.template.name}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{assignment.role && (
<Badge variant="outline" className="text-xs">
{assignment.role}
</Badge>
)}
<StageStatusBadge
status={assignment.deliverableStage.status}
/>
</div>
</div>
))}
<div className="flex items-center gap-2">
{assignment.role && (
<Badge variant="outline" className="text-xs">
{assignment.role}
</Badge>
)}
<StageStatusBadge
status={assignment.deliverableStage.status}
/>
</div>
</Link>
);
})}
</div>
</CardContent>
</Card>
@ -122,10 +207,15 @@ export default function MyWorkPage() {
{!isLoading && grouped.size === 0 && (
<div className="py-16 text-center text-[var(--muted-foreground)]">
<ClipboardList className="mx-auto h-12 w-12 opacity-30" />
<p className="mt-4">No assignments yet.</p>
<p className="mt-4">
{completedWindow === "none"
? "No active assignments."
: "Nothing matches this window."}
</p>
<p className="text-sm">
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."}
</p>
</div>
)}

View file

@ -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,

View file

@ -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;
});