diff --git a/src/app/(app)/deliverables/page.tsx b/src/app/(app)/deliverables/page.tsx new file mode 100644 index 0000000..243aeda --- /dev/null +++ b/src/app/(app)/deliverables/page.tsx @@ -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 = { + 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("__all__"); + const [statusFilter, setStatusFilter] = useState("__all__"); + const [priorityFilter, setPriorityFilter] = useState("__all__"); + const [stageFilter, setStageFilter] = useState("__all__"); + const [teamFilter, setTeamFilter] = useState("__all__"); + const [sortKey, setSortKey] = useState("dueDate"); + const [sortDir, setSortDir] = useState("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(); + 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(); + 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(); + 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 ( +
+ {/* Header */} +
+ +
+

Deliverables

+

+ Every deliverable across every project you have access to. +

+
+
+ + {/* Filter bar */} +
+
+ + setQuery(e.target.value)} + className="h-9 pl-8 text-sm" + /> +
+ + ({ value: p.id, label: p.name }))} + /> + ({ value: s.slug, label: s.name }))} + /> + ({ value: t.slug, label: t.name }))} + /> + ({ value: s, label: s.replace(/_/g, " ") }))} + /> + ({ value: p, label: p }))} + /> + + {anyFilterActive && ( + + )} + +
+ {isLoading + ? "Loading…" + : `${sorted.length} of ${rows.length} deliverable${rows.length === 1 ? "" : "s"}`} +
+
+ + {/* Table */} +
+ + + + + + + {isLoading ? ( + Array.from({ length: 8 }).map((_, i) => ( + + {Array.from({ length: 8 }).map((_, j) => ( + + ))} + + )) + ) : sorted.length === 0 ? ( + + + + ) : ( + 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 ( + + + + + + + + + + + ); + }) + )} + +
+ + + + + + + +
+ +
+ {rows.length === 0 + ? "No deliverables yet — create a project and add deliverables." + : "No deliverables match your filters."} +
+ + {r.project.omgJobNumber ?? "—"} + + + + {r.project.name} + {r.project.clientTeam && ( + + · {r.project.clientTeam.name} + + )} + + + + {r.name} + + + {cs ? ( + {cs.name} + ) : ( + + )} + + {assignee ? ( + {assignee} + ) : ( + Unassigned + )} + + {due ? format(due, "MMM d") : } + + + {r.priority} + + + + {r.status.replace(/_/g, " ")} + +
+
+
+ ); +} + +// ── Helpers ─────────────────────────────────────────────────── + +function Th({ label, className }: { label: string; className?: string }) { + return ( + + {label} + + ); +} + +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 ( + onClick(sortKey)} + > + {label} + {isActive && {dir === "asc" ? "▲" : "▼"}} + + ); +} + +function FilterSelect({ + value, + onChange, + placeholder, + options, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; + options: { value: string; label: string }[]; +}) { + return ( + + ); +} diff --git a/src/app/(app)/resources/page.tsx b/src/app/(app)/resources/page.tsx index 40747c7..2ae17b3 100644 --- a/src/app/(app)/resources/page.tsx +++ b/src/app/(app)/resources/page.tsx @@ -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("__all__"); + const [teamFilter, setTeamFilter] = useState("__all__"); + const [groupBy, setGroupBy] = useState("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(); + 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 = {}; - 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>({}); @@ -202,6 +286,101 @@ export default function ResourcesPage() { + {/* Filter bar */} +
+
+ + Role + + +
+ +
+ + Team + + +
+ +
+ + Group by + + +
+ +
+ + Sort + + +
+ + {(roleFilter !== "__all__" || teamFilter !== "__all__") && ( + + )} + +
+ {filteredUsers.length} of {users?.length ?? 0} people +
+
+ {/* Grid */}
@@ -264,6 +443,15 @@ export default function ResourcesPage() { Settings → Team. + ) : filteredUsers.length === 0 ? ( + + + ) : ( userGroups.map(([group, members]) => { const isCollapsed = collapsed[group]; diff --git a/src/app/api/deliverables/route.ts b/src/app/api/deliverables/route.ts new file mode 100644 index 0000000..0127470 --- /dev/null +++ b/src/app/api/deliverables/route.ts @@ -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); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 19cf1cf..634d9ed 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -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 }, diff --git a/src/hooks/use-deliverables.ts b/src/hooks/use-deliverables.ts index bac0151..610d922 100644 --- a/src/hooks/use-deliverables.ts +++ b/src/hooks/use-deliverables.ts @@ -22,6 +22,39 @@ export function useDeliverables(projectId: string) { }); } +// Cross-project flat list for the /deliverables page. +export function useAllDeliverables() { + return useQuery({ + 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], diff --git a/src/lib/services/deliverable-service.ts b/src/lib/services/deliverable-service.ts index 35fe5ac..ac5e203 100644 --- a/src/lib/services/deliverable-service.ts +++ b/src/lib/services/deliverable-service.ts @@ -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: { diff --git a/src/lib/services/user-service.ts b/src/lib/services/user-service.ts index 87f0832..693775d 100644 --- a/src/lib/services/user-service.ts +++ b/src/lib/services/user-service.ts @@ -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" },
+ No people match the current role/team filter. +