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