feat(shot-status): status badges filter the shot list
Deploy / deploy (push) Successful in 3m28s

Clicking a summary badge shows only shots with that status; several can be
combined. Selection drops shots the filter hides so bulk actions never reach
them. Filter resets on project change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-10-08 03:46:43 +02:00
parent 842a8fe036
commit 06a4122ebb
@@ -1130,6 +1130,8 @@ export function ShotStatusClient({
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [groupMode, setGroupMode] = useState<"flat" | "scene" | "group">("flat");
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Statuses picked from the summary badges; empty shows every shot.
const [statusFilter, setStatusFilter] = useState<Set<string>>(new Set());
// Which sections were open is restored before the scroll position: until
// they are, the page is shorter and the saved offset would not fit.
@@ -1141,6 +1143,7 @@ export function ShotStatusClient({
useEffect(() => {
if (!selectedProjectId) return;
setExpandRestored(false);
setStatusFilter(new Set());
const mode = (readStored(`shotStatus:${selectedProjectId}:groupMode`) ?? "flat") as "flat" | "scene" | "group";
setGroupMode(mode);
setExpandedEpisodes(new Set(readStoredList(`shotStatus:${selectedProjectId}:episodeExpand`) ?? []));
@@ -1235,6 +1238,22 @@ export function ShotStatusClient({
const clearSelection = () => setSelectedIds(new Set());
const toggleStatusFilter = (status: string) => {
const next = new Set(statusFilter);
next.has(status) ? next.delete(status) : next.add(status);
setStatusFilter(next);
// Bulk actions must never reach shots the filter has hidden.
if (next.size > 0) {
setSelectedIds((prev) => {
const kept = new Set<string>();
for (const s of shots) if (prev.has(s.id) && next.has(s.status)) kept.add(s.id);
return kept;
});
}
};
const visibleShots = statusFilter.size > 0 ? shots.filter((s) => statusFilter.has(s.status)) : shots;
const handleBulkDueDate = async (date: string, includeTasks: boolean) => {
try {
const result = await bulkUpdateDueDates([...selectedIds], date, includeTasks);
@@ -1320,7 +1339,7 @@ export function ShotStatusClient({
const episodeGroups: [string, ShotRow[]][] = isEpisodic
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
for (const shot of visibleShots) {
const key = shot.episode ?? "(No Episode)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
@@ -1332,7 +1351,7 @@ export function ShotStatusClient({
const sceneGroups: [string, ShotRow[]][] = !isEpisodic && groupMode === "scene"
? (() => {
const map = new Map<string, ShotRow[]>();
for (const shot of shots) {
for (const shot of visibleShots) {
const key = shot.scene || "(No Scene)";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(shot);
@@ -1344,7 +1363,7 @@ export function ShotStatusClient({
const shotGroupGroups: [string, string, ShotRow[]][] = !isEpisodic && groupMode === "group"
? (() => {
const map = new Map<string, [string, ShotRow[]]>();
for (const shot of shots) {
for (const shot of visibleShots) {
const id = shot.shotGroup?.id ?? "";
const name = shot.shotGroup?.name ?? "(No Group)";
if (!map.has(id)) map.set(id, [name, []]);
@@ -1437,16 +1456,45 @@ export function ShotStatusClient({
<div className="flex flex-wrap items-center gap-2">
{selectionCount === 0 ? (
<>
<span className="text-xs text-zinc-500 self-center">{shots.length} shots total</span>
<span className="text-xs text-zinc-500 self-center">
{statusFilter.size > 0 ? `${visibleShots.length} of ${shots.length} shots` : `${shots.length} shots total`}
</span>
{Object.entries(STATUS_CONFIG).map(([key, cfg]) => {
const count = statusCounts[key] ?? 0;
if (!count) return null;
const active = statusFilter.has(key);
return (
<Badge key={key} className={cn("gap-1 text-xs", cfg.color)}>
{count} {cfg.label}
</Badge>
<button
key={key}
type="button"
onClick={() => toggleStatusFilter(key)}
aria-pressed={active}
title={active ? `Stop filtering by ${cfg.label}` : `Show only ${cfg.label}`}
className="rounded-full"
>
<Badge
className={cn(
"gap-1 text-xs cursor-pointer",
cfg.color,
statusFilter.size > 0 && !active && "opacity-40 hover:opacity-70",
active && "ring-1 ring-current"
)}
>
{count} {cfg.label}
</Badge>
</button>
);
})}
{statusFilter.size > 0 && (
<button
type="button"
onClick={() => setStatusFilter(new Set())}
className="flex items-center gap-1 text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
>
<X className="h-3 w-3" />
Clear filter
</button>
)}
</>
) : (
<div className="flex items-center gap-3 rounded-lg border border-blue-500/30 bg-blue-500/10 px-4 py-2">
@@ -1669,7 +1717,7 @@ export function ShotStatusClient({
) : (
<div className="overflow-x-auto">
<ShotTable
shots={shots}
shots={visibleShots}
canManage={canManage}
projectId={selectedProjectId!}
selectedIds={selectedIds}