diff --git a/app/(dashboard)/pipeline/PipelineQueueClient.tsx b/app/(dashboard)/pipeline/PipelineQueueClient.tsx index fef1e75..09cfb69 100644 --- a/app/(dashboard)/pipeline/PipelineQueueClient.tsx +++ b/app/(dashboard)/pipeline/PipelineQueueClient.tsx @@ -1,11 +1,12 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import Link from "next/link"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useSession } from "next-auth/react"; -import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight } from "lucide-react"; +import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight, Search, X } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -67,14 +68,29 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) const [projectId, setProjectId] = useState("all"); const [statusFilter, setStatusFilter] = useState(initialStatus); const [page, setPage] = useState(1); + const [searchInput, setSearchInput] = useState(""); + const [search, setSearch] = useState(""); const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? ""); + // Searched on the server (the table is paged there), so typing is debounced + // rather than firing a request per keystroke. + useEffect(() => { + const next = searchInput.trim(); + if (next === search) return; + const t = setTimeout(() => { + setSearch(next); + setPage(1); + }, 300); + return () => clearTimeout(t); + }, [searchInput, search]); + const { data, isLoading, refetch, isFetching } = useQuery({ - queryKey: ["pipeline-queue", projectId, statusFilter, page], + queryKey: ["pipeline-queue", projectId, statusFilter, search, page], queryFn: async () => { const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(page) }); if (projectId !== "all") params.set("projectId", projectId); if (statusFilter !== "all") params.set("status", statusFilter); + if (search) params.set("q", search); const res = await fetch(`/api/render/queue?${params}`); if (!res.ok) throw new Error("Failed to load queue"); return res.json() as Promise<{ @@ -243,19 +259,42 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) })} - +
+
+ + setSearchInput(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && setSearchInput("")} + placeholder="Search shots, e.g. UNG_114" + aria-label="Search exports by shot code" + className="pl-8 pr-8" + /> + {searchInput && ( + + )} +
+ +
@@ -281,9 +320,11 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) ) : exports.length === 0 ? ( ) : ( diff --git a/app/api/render/queue/route.ts b/app/api/render/queue/route.ts index b40b875..adb6129 100644 --- a/app/api/render/queue/route.ts +++ b/app/api/render/queue/route.ts @@ -7,7 +7,7 @@ import { listExports } from "@/lib/render-pipeline/exports"; // ── GET /api/render/queue (E21) ────────────────────────────────────────────── // // Exports list for the Render Queue page. Optional filters: projectId, -// episode, status; standard pagination. Defaults to all statuses so the queue +// episode, status, q (shot/client code search); standard pagination. Defaults to all statuses so the queue // page can show history too — the UI filters live states client-side. export async function GET(req: NextRequest) { @@ -19,6 +19,7 @@ export async function GET(req: NextRequest) { projectId: searchParams.get("projectId") ?? undefined, episode: searchParams.get("episode") ?? undefined, shotId: searchParams.get("shotId") ?? undefined, + search: searchParams.get("q")?.slice(0, 100) || undefined, status: status && (Object.values(ExportStatus) as string[]).includes(status) ? (status as ExportStatus) diff --git a/lib/render-pipeline/exports.ts b/lib/render-pipeline/exports.ts index d4f08bd..416820a 100644 --- a/lib/render-pipeline/exports.ts +++ b/lib/render-pipeline/exports.ts @@ -522,6 +522,8 @@ export interface ListExportsFilter { episode?: string; status?: ExportStatus; shotId?: string; + /** Free-text match on shot code or client code ("UNG_114" → the whole episode). */ + search?: string; page?: number; limit?: number; } @@ -535,6 +537,21 @@ export async function listExports(filter: ListExportsFilter) { ...(filter.shotId ? { shotId: filter.shotId } : {}), ...(filter.episode ? { shot: { episode: filter.episode } } : {}), }; + // Part of the scope rather than the status filter, so the summary counts + // describe what was searched for. + const search = filter.search?.trim(); + if (search) { + scope.AND = [ + { + shot: { + OR: [ + { shotCode: { contains: search, mode: "insensitive" } }, + { clientCode: { contains: search, mode: "insensitive" } }, + ], + }, + }, + ]; + } const where: Prisma.ExportWhereInput = { ...scope, ...(filter.status ? { status: filter.status } : {}),
- {statusFilter === "all" - ? "No exports yet — queue one from the After Effects panel" - : `No exports are ${statusLabel(statusFilter).toLowerCase()}`} + {search + ? `No exports match “${search}”` + : statusFilter === "all" + ? "No exports yet — queue one from the After Effects panel" + : `No exports are ${statusLabel(statusFilter).toLowerCase()}`}