feat(pipeline): search the render queue by shot or client code
Deploy / deploy (push) Successful in 3m16s
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:
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user