feat(pipeline): search the render queue by shot or client code
Deploy / deploy (push) Successful in 3m16s

A search box above the exports table matches any part of the shot code or
client code, case-insensitively, so "UNG_114" lists a whole episode. It is
applied server-side (the table is paged there), debounced, and scopes the
summary counts too; combines with the project and status filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-09-25 04:51:04 +02:00
parent c2509512ff
commit 5e29c3b88d
3 changed files with 79 additions and 20 deletions
@@ -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<string>("all");
const [statusFilter, setStatusFilter] = useState<string>(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)
})}
</div>
<Select value={statusFilter} onValueChange={changeStatus}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
<SelectItem key={s} value={s}>
{statusLabel(s)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2 flex-wrap">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
<Input
value={searchInput}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setSearchInput("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={changeStatus}>
<SelectTrigger className="w-56">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
<SelectItem key={s} value={s}>
{statusLabel(s)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
<table className="w-full text-sm">
@@ -281,9 +320,11 @@ export function PipelineQueueClient({ projects, initialStatus = "all" }: Props)
) : exports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
{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()}`}
</td>
</tr>
) : (
+2 -1
View File
@@ -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)
+17
View File
@@ -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 } : {}),