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:
parent
985f8effbc
commit
405da7d2f8
7 changed files with 783 additions and 6 deletions
470
src/app/(app)/deliverables/page.tsx
Normal file
470
src/app/(app)/deliverables/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
22
src/app/api/deliverables/route.ts
Normal file
22
src/app/api/deliverables/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue