Add cross-project Deliverables view + role/team filters on Resources

Deliverables view (new /deliverables):
- listAllDeliverables service uses visibleDeliverablesWhere so the
  flat list respects per-team scoping automatically
- GET /api/deliverables returns the flat list joined with project +
  stages + assignments
- useAllDeliverables hook + typed AllDeliverableRow
- New page: searchable/sortable table, filter by project, stage,
  team, status, priority. Current-stage column uses the same
  "highest-order in-flight stage" logic as the Projects grid so
  both views agree on what counts as "where this deliverable is"
- Sidebar: new "Deliverables" nav entry between Projects and My Work

Resources page filters:
- Extended listUsers to include homePod + clientTeams so the page
  can filter/group without an extra round-trip
- Added Role + Team filters, Group-by selector (Team / Role / Pod /
  Department), Sort-by selector. Default is Group by Team + Sort by
  Name — matches how the roster is organised on the team-list xlsx
- primaryTeam() picks the isPrimary membership so Shared users
  (members of multiple teams) land in one bucket when grouped
This commit is contained in:
DJP 2026-04-21 11:13:40 -04:00
parent 985f8effbc
commit 405da7d2f8
7 changed files with 783 additions and 6 deletions

View file

@ -0,0 +1,470 @@
"use client";
/**
* Deliverables flat cross-project list.
*
* Every deliverable the signed-in user can see, in one table. Filter by
* project / status / priority / current-stage / assignee; search on
* deliverable name, project name, or OMG job number.
*
* Row click the deliverable detail page under its parent project.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { format, parseISO } from "date-fns";
import { ClipboardList, Search, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useAllDeliverables, type AllDeliverableRow } from "@/hooks/use-deliverables";
type SortKey = "name" | "project" | "stage" | "dueDate" | "priority" | "status";
type SortDir = "asc" | "desc";
// Stages considered "still in flight" — mirrors listProjects pipelineProgress
// logic so "Current stage" in this view matches the Projects grid.
const IN_FLIGHT = new Set([
"NOT_STARTED",
"IN_PROGRESS",
"IN_REVIEW",
"CHANGES_REQUESTED",
"BLOCKED",
]);
const DELIVERABLE_STATUSES = [
"NOT_STARTED",
"IN_PROGRESS",
"IN_REVIEW",
"APPROVED",
"ON_HOLD",
];
const PRIORITIES = ["LOW", "MEDIUM", "HIGH", "URGENT"];
const PRIORITY_COLORS: Record<string, string> = {
URGENT: "bg-red-100 text-red-700 border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-900",
HIGH: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-900/40 dark:text-orange-300 dark:border-orange-900",
MEDIUM: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:border-blue-900",
LOW: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-700",
};
function currentStage(row: AllDeliverableRow) {
let best: { order: number; name: string; slug: string } | null = null;
for (const s of row.stages) {
if (!IN_FLIGHT.has(s.status)) continue;
const order = s.stageDefinition?.order ?? s.template?.order ?? -1;
if (!best || order > best.order) {
best = {
order,
name: s.stageDefinition?.name ?? "—",
slug: s.stageDefinition?.slug ?? "",
};
}
}
return best;
}
function primaryAssignee(row: AllDeliverableRow): string | null {
// First assignment on the current (in-flight) stage if any, else first
// assignment on any stage. Keeps the column legible when stages have
// different assignees across the pipeline.
const inFlight = row.stages.find(
(s) => IN_FLIGHT.has(s.status) && s.assignments.length > 0
);
if (inFlight) return inFlight.assignments[0].user.name ?? inFlight.assignments[0].user.email;
for (const s of row.stages) {
if (s.assignments.length > 0) {
return s.assignments[0].user.name ?? s.assignments[0].user.email;
}
}
return null;
}
export default function DeliverablesPage() {
const { data, isLoading } = useAllDeliverables();
const [query, setQuery] = useState("");
const [projectFilter, setProjectFilter] = useState<string>("__all__");
const [statusFilter, setStatusFilter] = useState<string>("__all__");
const [priorityFilter, setPriorityFilter] = useState<string>("__all__");
const [stageFilter, setStageFilter] = useState<string>("__all__");
const [teamFilter, setTeamFilter] = useState<string>("__all__");
const [sortKey, setSortKey] = useState<SortKey>("dueDate");
const [sortDir, setSortDir] = useState<SortDir>("asc");
const rows = data ?? [];
// Option lists derived from the data itself — so the dropdowns only
// show values that actually exist in the visible set.
const projectOptions = useMemo(() => {
const m = new Map<string, string>();
for (const r of rows) m.set(r.project.id, r.project.name);
return Array.from(m, ([id, name]) => ({ id, name })).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [rows]);
const stageOptions = useMemo(() => {
const m = new Map<string, { slug: string; name: string; order: number }>();
for (const r of rows) {
for (const s of r.stages) {
if (s.stageDefinition) {
m.set(s.stageDefinition.slug, s.stageDefinition);
}
}
}
return Array.from(m.values()).sort((a, b) => a.order - b.order);
}, [rows]);
const teamOptions = useMemo(() => {
const m = new Map<string, string>();
for (const r of rows) {
if (r.project.clientTeam) {
m.set(r.project.clientTeam.slug, r.project.clientTeam.name);
}
}
return Array.from(m, ([slug, name]) => ({ slug, name })).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [rows]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return rows.filter((r) => {
if (projectFilter !== "__all__" && r.project.id !== projectFilter) return false;
if (statusFilter !== "__all__" && r.status !== statusFilter) return false;
if (priorityFilter !== "__all__" && r.priority !== priorityFilter) return false;
if (teamFilter !== "__all__" && r.project.clientTeam?.slug !== teamFilter) return false;
if (stageFilter !== "__all__") {
const cs = currentStage(r);
if (!cs || cs.slug !== stageFilter) return false;
}
if (q) {
const hay = [
r.name,
r.project.name,
r.project.omgJobNumber ?? "",
r.project.projectCode ?? "",
]
.join(" ")
.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [rows, query, projectFilter, statusFilter, priorityFilter, teamFilter, stageFilter]);
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1;
const priOrder = ["LOW", "MEDIUM", "HIGH", "URGENT"];
return [...filtered].sort((a, b) => {
switch (sortKey) {
case "name":
return dir * a.name.localeCompare(b.name);
case "project":
return dir * a.project.name.localeCompare(b.project.name);
case "stage": {
const ao = currentStage(a)?.order ?? -1;
const bo = currentStage(b)?.order ?? -1;
return dir * (ao - bo);
}
case "dueDate": {
const at = a.dueDate ? parseISO(a.dueDate).getTime() : Number.POSITIVE_INFINITY;
const bt = b.dueDate ? parseISO(b.dueDate).getTime() : Number.POSITIVE_INFINITY;
return dir * (at - bt);
}
case "priority":
return dir * (priOrder.indexOf(a.priority) - priOrder.indexOf(b.priority));
case "status":
return dir * a.status.localeCompare(b.status);
}
});
}, [filtered, sortKey, sortDir]);
const toggleSort = (key: SortKey) => {
if (sortKey === key) setSortDir((d) => (d === "asc" ? "desc" : "asc"));
else {
setSortKey(key);
setSortDir("asc");
}
};
const clearFilters = () => {
setQuery("");
setProjectFilter("__all__");
setStatusFilter("__all__");
setPriorityFilter("__all__");
setStageFilter("__all__");
setTeamFilter("__all__");
};
const anyFilterActive =
query !== "" ||
projectFilter !== "__all__" ||
statusFilter !== "__all__" ||
priorityFilter !== "__all__" ||
stageFilter !== "__all__" ||
teamFilter !== "__all__";
return (
<div className="flex h-full flex-col gap-4">
{/* Header */}
<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">Deliverables</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Every deliverable across every project you have access to.
</p>
</div>
</div>
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-[var(--card)] p-3">
<div className="relative flex-1 min-w-[220px]">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-[var(--muted-foreground)]" />
<Input
placeholder="Search name, project, or OMG number…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 pl-8 text-sm"
/>
</div>
<FilterSelect
value={projectFilter}
onChange={setProjectFilter}
placeholder="All Projects"
options={projectOptions.map((p) => ({ value: p.id, label: p.name }))}
/>
<FilterSelect
value={stageFilter}
onChange={setStageFilter}
placeholder="All Stages"
options={stageOptions.map((s) => ({ value: s.slug, label: s.name }))}
/>
<FilterSelect
value={teamFilter}
onChange={setTeamFilter}
placeholder="All Teams"
options={teamOptions.map((t) => ({ value: t.slug, label: t.name }))}
/>
<FilterSelect
value={statusFilter}
onChange={setStatusFilter}
placeholder="All Statuses"
options={DELIVERABLE_STATUSES.map((s) => ({ value: s, label: s.replace(/_/g, " ") }))}
/>
<FilterSelect
value={priorityFilter}
onChange={setPriorityFilter}
placeholder="All Priorities"
options={PRIORITIES.map((p) => ({ value: p, label: p }))}
/>
{anyFilterActive && (
<Button size="sm" variant="ghost" onClick={clearFilters} className="h-9">
<X className="mr-1 h-3 w-3" />
Clear
</Button>
)}
<div className="ml-auto text-xs text-[var(--muted-foreground)]">
{isLoading
? "Loading…"
: `${sorted.length} of ${rows.length} deliverable${rows.length === 1 ? "" : "s"}`}
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-auto rounded-lg border bg-[var(--card)]">
<table className="w-full min-w-[1100px] border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-[var(--muted)]/60">
<tr className="border-b-2 border-[var(--border)]">
<Th label="OMG #" className="w-24" />
<SortableTh label="Project" sortKey="project" activeKey={sortKey} dir={sortDir} onClick={toggleSort} />
<SortableTh label="Deliverable" sortKey="name" activeKey={sortKey} dir={sortDir} onClick={toggleSort} />
<SortableTh label="Current Stage" sortKey="stage" activeKey={sortKey} dir={sortDir} onClick={toggleSort} />
<Th label="Assignee" />
<SortableTh label="Due" sortKey="dueDate" activeKey={sortKey} dir={sortDir} onClick={toggleSort} className="w-24" />
<SortableTh label="Priority" sortKey="priority" activeKey={sortKey} dir={sortDir} onClick={toggleSort} className="w-24" />
<SortableTh label="Status" sortKey="status" activeKey={sortKey} dir={sortDir} onClick={toggleSort} className="w-32" />
</tr>
</thead>
<tbody>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-3 py-2.5">
<Skeleton className="h-4 w-full" />
</td>
))}
</tr>
))
) : sorted.length === 0 ? (
<tr>
<td colSpan={8} className="py-12 text-center text-[var(--muted-foreground)]">
{rows.length === 0
? "No deliverables yet — create a project and add deliverables."
: "No deliverables match your filters."}
</td>
</tr>
) : (
sorted.map((r) => {
const cs = currentStage(r);
const assignee = primaryAssignee(r);
const due = r.dueDate ? parseISO(r.dueDate) : null;
const overdue = due && due.getTime() < Date.now() && r.status !== "APPROVED";
return (
<tr
key={r.id}
className="border-b transition-colors hover:bg-[var(--muted)]/40"
>
<td className="px-3 py-2 align-middle">
<span className="font-mono text-[11px] tabular-nums text-[var(--muted-foreground)]">
{r.project.omgJobNumber ?? "—"}
</span>
</td>
<td className="px-3 py-2 align-middle">
<Link
href={`/projects/${r.project.id}`}
className="text-[var(--foreground)] hover:text-[var(--primary)] hover:underline"
>
<span className="font-medium">{r.project.name}</span>
{r.project.clientTeam && (
<span className="ml-1.5 text-[10px] text-[var(--muted-foreground)]">
· {r.project.clientTeam.name}
</span>
)}
</Link>
</td>
<td className="px-3 py-2 align-middle">
<Link
href={`/projects/${r.project.id}/deliverables/${r.id}`}
className="font-semibold text-[var(--foreground)] hover:text-[var(--primary)] hover:underline"
>
{r.name}
</Link>
</td>
<td className="px-3 py-2 align-middle">
{cs ? (
<span className="text-[var(--foreground)]">{cs.name}</span>
) : (
<span className="text-[var(--muted-foreground)]"></span>
)}
</td>
<td className="px-3 py-2 align-middle">
{assignee ? (
<span>{assignee}</span>
) : (
<span className="text-[var(--muted-foreground)]">Unassigned</span>
)}
</td>
<td className={cn("px-3 py-2 align-middle tabular-nums", overdue && "text-red-600 font-semibold")}>
{due ? format(due, "MMM d") : <span className="text-[var(--muted-foreground)]"></span>}
</td>
<td className="px-3 py-2 align-middle">
<Badge variant="outline" className={cn("text-[10px]", PRIORITY_COLORS[r.priority])}>
{r.priority}
</Badge>
</td>
<td className="px-3 py-2 align-middle">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{r.status.replace(/_/g, " ")}
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}
// ── Helpers ───────────────────────────────────────────────────
function Th({ label, className }: { label: string; className?: string }) {
return (
<th
className={cn(
"px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]",
className
)}
>
{label}
</th>
);
}
function SortableTh({
label,
sortKey,
activeKey,
dir,
onClick,
className,
}: {
label: string;
sortKey: SortKey;
activeKey: SortKey;
dir: SortDir;
onClick: (k: SortKey) => void;
className?: string;
}) {
const isActive = activeKey === sortKey;
return (
<th
className={cn(
"cursor-pointer select-none px-3 py-2 text-left text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)] hover:text-[var(--foreground)]",
className
)}
onClick={() => onClick(sortKey)}
>
{label}
{isActive && <span className="ml-1">{dir === "asc" ? "▲" : "▼"}</span>}
</th>
);
}
function FilterSelect({
value,
onChange,
placeholder,
options,
}: {
value: string;
onChange: (v: string) => void;
placeholder: string;
options: { value: string; label: string }[];
}) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-9 w-[160px] text-xs">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">{placeholder}</SelectItem>
{options.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}

View file

@ -25,6 +25,13 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useQuery } from "@tanstack/react-query";
import { apiUrl } from "@/lib/api-client";
import { cn } from "@/lib/utils";
@ -58,6 +65,22 @@ interface UserRow {
role: string;
department: string | null;
maxCapacity: number;
homePod: { id: string; name: string; slug: string } | null;
clientTeams: Array<{
isPrimary: boolean;
clientTeam: { id: string; name: string; slug: string };
}>;
}
type GroupBy = "team" | "role" | "pod" | "department";
// Primary team for a user — the membership flagged isPrimary if any,
// otherwise the first team in the list. Used for grouping/filter
// so a "shared" user (member of multiple teams) lands in one bucket.
function primaryTeam(u: UserRow): { name: string; slug: string } | null {
if (!u.clientTeams.length) return null;
const primary = u.clientTeams.find((m) => m.isPrimary) ?? u.clientTeams[0];
return { name: primary.clientTeam.name, slug: primary.clientTeam.slug };
}
function isoDateOnly(d: Date): string {
@ -120,16 +143,77 @@ export default function ResourcesPage() {
const weekHours = (userId: string): number =>
weekDays.reduce((s, d) => s + cellHours(userId, d), 0);
// Group users by department for the Resources.html-style role bands.
// Filter + sort + group controls
const [roleFilter, setRoleFilter] = useState<string>("__all__");
const [teamFilter, setTeamFilter] = useState<string>("__all__");
const [groupBy, setGroupBy] = useState<GroupBy>("team");
const [sortKey, setSortKey] = useState<"name" | "role" | "team">("name");
// Team options are derived from whatever teams actually appear in the
// user list — covers the case where ClientTeams haven't been seeded yet.
const teamOptions = useMemo(() => {
const m = new Map<string, string>();
for (const u of users ?? []) {
const pt = primaryTeam(u);
if (pt) m.set(pt.slug, pt.name);
}
return Array.from(m, ([slug, name]) => ({ slug, name })).sort((a, b) =>
a.name.localeCompare(b.name)
);
}, [users]);
// Apply filters first so sort + group both operate on the filtered set.
const filteredUsers = useMemo(() => {
return (users ?? []).filter((u) => {
if (roleFilter !== "__all__" && u.role !== roleFilter) return false;
if (teamFilter !== "__all__") {
const pt = primaryTeam(u);
if (!pt || pt.slug !== teamFilter) return false;
}
return true;
});
}, [users, roleFilter, teamFilter]);
// Group by the selected dimension. Teams fall back to "No team" for
// users without any ClientTeamMembership (e.g., pre-seed data).
const userGroups = useMemo(() => {
const groups: Record<string, UserRow[]> = {};
for (const u of users ?? []) {
const bucket = u.department || "Unassigned";
if (!groups[bucket]) groups[bucket] = [];
groups[bucket].push(u);
const bucketOf = (u: UserRow): string => {
switch (groupBy) {
case "team":
return primaryTeam(u)?.name ?? "No team";
case "role":
return u.role;
case "pod":
return u.homePod?.name ?? "No pod";
case "department":
return u.department || "Unassigned";
}
};
for (const u of filteredUsers) {
const b = bucketOf(u);
if (!groups[b]) groups[b] = [];
groups[b].push(u);
}
// Sort members inside each group, then sort groups alphabetically.
for (const k of Object.keys(groups)) {
groups[k].sort((a, b) => {
switch (sortKey) {
case "role":
return (a.role ?? "").localeCompare(b.role ?? "");
case "team": {
const at = primaryTeam(a)?.name ?? "";
const bt = primaryTeam(b)?.name ?? "";
return at.localeCompare(bt);
}
case "name":
default:
return (a.name ?? a.email).localeCompare(b.name ?? b.email);
}
});
}
return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0]));
}, [users]);
}, [filteredUsers, groupBy, sortKey]);
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
@ -202,6 +286,101 @@ export default function ResourcesPage() {
</div>
</div>
{/* Filter bar */}
<div className="flex flex-wrap items-center gap-2 rounded-lg border bg-[var(--card)] p-3">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Role
</span>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All Roles</SelectItem>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="PRODUCER">Producer</SelectItem>
<SelectItem value="ARTIST">Artist</SelectItem>
<SelectItem value="CLIENT_VIEWER">Client Viewer</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Team
</span>
<Select value={teamFilter} onValueChange={setTeamFilter}>
<SelectTrigger className="h-8 w-[160px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All Teams</SelectItem>
{teamOptions.map((t) => (
<SelectItem key={t.slug} value={t.slug}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Group by
</span>
<Select value={groupBy} onValueChange={(v) => setGroupBy(v as GroupBy)}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="role">Role</SelectItem>
<SelectItem value="pod">Pod</SelectItem>
<SelectItem value="department">Department</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-bold uppercase tracking-wider text-[var(--muted-foreground)]">
Sort
</span>
<Select
value={sortKey}
onValueChange={(v) => setSortKey(v as "name" | "role" | "team")}
>
<SelectTrigger className="h-8 w-[120px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="name">Name</SelectItem>
<SelectItem value="role">Role</SelectItem>
<SelectItem value="team">Team</SelectItem>
</SelectContent>
</Select>
</div>
{(roleFilter !== "__all__" || teamFilter !== "__all__") && (
<Button
size="sm"
variant="ghost"
className="h-8"
onClick={() => {
setRoleFilter("__all__");
setTeamFilter("__all__");
}}
>
<X className="mr-1 h-3 w-3" />
Clear
</Button>
)}
<div className="ml-auto text-xs text-[var(--muted-foreground)]">
{filteredUsers.length} of {users?.length ?? 0} people
</div>
</div>
{/* Grid */}
<div className="flex-1 overflow-auto rounded-lg border bg-[var(--card)]">
<table className="w-full min-w-[1000px] border-collapse text-xs">
@ -264,6 +443,15 @@ export default function ResourcesPage() {
Settings Team.
</td>
</tr>
) : filteredUsers.length === 0 ? (
<tr>
<td
colSpan={7}
className="py-12 text-center text-[var(--muted-foreground)]"
>
No people match the current role/team filter.
</td>
</tr>
) : (
userGroups.map(([group, members]) => {
const isCollapsed = collapsed[group];

View file

@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { serverError } from "@/lib/api-utils";
import { requireAuth, visibilityContextFromSession } from "@/lib/rbac/require-auth";
import { listAllDeliverables } from "@/lib/services/deliverable-service";
// GET /api/deliverables
//
// Cross-project flat list for the /deliverables page. Service-layer
// visibility scoping applies — non-ADMIN users only see deliverables
// whose parent project is in a ClientTeam they belong to.
export async function GET() {
const { session, error } = await requireAuth("DELIVERABLE_VIEW");
if (error) return error;
try {
const ctx = visibilityContextFromSession(session);
const rows = await listAllDeliverables(session.user.organizationId, ctx);
return NextResponse.json(rows);
} catch (e) {
return serverError(e);
}
}

View file

@ -6,6 +6,7 @@ import {
LayoutDashboard,
FolderKanban,
ClipboardList,
ListChecks,
Bell,
Users,
Users2,
@ -29,6 +30,7 @@ import { signOutAction } from "@/lib/auth-actions";
const navItems = [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/projects", label: "Projects", icon: FolderKanban },
{ href: "/deliverables", label: "Deliverables", icon: ListChecks },
{ href: "/my-work", label: "My Work", icon: ClipboardList },
{ href: "/workload", label: "Workload", icon: Users },
{ href: "/resources", label: "Resources", icon: Users2 },

View file

@ -22,6 +22,39 @@ export function useDeliverables(projectId: string) {
});
}
// Cross-project flat list for the /deliverables page.
export function useAllDeliverables() {
return useQuery<AllDeliverableRow[]>({
queryKey: ["deliverables", "all"],
queryFn: () => fetchJson(`/api/deliverables`),
});
}
export interface AllDeliverableRow {
id: string;
name: string;
status: string;
priority: string;
dueDate: string | null;
createdAt: string;
project: {
id: string;
name: string;
projectCode: string | null;
omgJobNumber: string | null;
status: string;
clientTeam: { id: string; name: string; slug: string } | null;
};
stages: Array<{
id: string;
status: string;
stageDefinitionId: string | null;
template: { order: number } | null;
stageDefinition: { slug: string; name: string; order: number } | null;
assignments: Array<{ user: { id: string; name: string | null; email: string } }>;
}>;
}
export function useDeliverable(projectId: string, deliverableId: string) {
return useQuery({
queryKey: ["deliverable", projectId, deliverableId],

View file

@ -158,6 +158,60 @@ export async function listDeliverables(projectId: string, ctx: VisibilityContext
});
}
/**
* Flat cross-project list for the /deliverables view.
*
* Returns every deliverable visible to `ctx` within the given org, joined
* with enough project + stage + assignee context that the frontend can
* render a table without a second round-trip.
*
* "Current stage" for each deliverable is derived client-side as the
* highest-order stage still in flight (not APPROVED/DELIVERED/SKIPPED),
* same logic as the Projects grid's pipelineProgress column kept in
* the client so the service stays a thin data fetch.
*/
export async function listAllDeliverables(
organizationId: string,
ctx: VisibilityContext
) {
return prisma.deliverable.findMany({
where: {
AND: [
{ organizationId },
await visibleDeliverablesWhere(ctx),
],
},
include: {
project: {
select: {
id: true,
name: true,
projectCode: true,
omgJobNumber: true,
status: true,
clientTeam: { select: { id: true, name: true, slug: true } },
},
},
stages: {
select: {
id: true,
status: true,
stageDefinitionId: true,
template: { select: { order: true } },
stageDefinition: { select: { slug: true, name: true, order: true } },
assignments: {
select: {
user: { select: { id: true, name: true, email: true } },
},
},
},
orderBy: { template: { order: "asc" } },
},
},
orderBy: [{ dueDate: "asc" }, { createdAt: "desc" }],
});
}
export async function getDeliverable(id: string, ctx: VisibilityContext) {
return prisma.deliverable.findFirst({
where: {

View file

@ -11,7 +11,15 @@ export async function listUsers(organizationId: string) {
image: true,
role: true,
department: true,
maxCapacity: true,
createdAt: true,
homePod: { select: { id: true, name: true, slug: true } },
clientTeams: {
select: {
isPrimary: true,
clientTeam: { select: { id: true, name: true, slug: true } },
},
},
_count: { select: { assignments: true } },
},
orderBy: { name: "asc" },