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:
parent
827ed587bb
commit
d4bee0e8d3
3 changed files with 162 additions and 58 deletions
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue