Add event dashboard stats and tolerate repeated bulk approvals

This commit is contained in:
2026-09-11 21:35:16 -04:00
parent 275e78ac2b
commit 6e2e9f22a6
5 changed files with 57 additions and 9 deletions
@@ -24,7 +24,11 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
setReport(allResults); setReport(allResults);
setSelected(allResults.filter(row => row.status === "failed" || row.status === "skipped").map(row => row.photoId)); setSelected(allResults.filter(row => row.status === "failed" || row.status === "skipped").map(row => row.photoId));
setPending(null); setPending(null);
toast(`${result.affected} photos changed; ${allResults.length - result.affected} skipped or failed`); const unchanged = allResults.filter(row => row.status === "unchanged").length;
const issues = allResults.filter(row => row.status === "failed" || row.status === "skipped").length;
const message = `${result.affected} updated${unchanged ? ` · ${unchanged} already set` : ""}${issues ? ` · ${issues} need attention` : ""}`;
if (issues) toast.warning(message); else toast.success(message);
void utils.manager.stats.invalidate({ eventId });
await utils.manager.photos.invalidate({ eventId }); await utils.manager.photos.invalidate({ eventId });
}, onError: error => toast.error(error.message), }, onError: error => toast.error(error.message),
}); });
@@ -32,15 +36,17 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
const actions: Action[] = [...(canModerate ? ["public", "hidden", ...(canPrivate ? ["private"] : []), "rejected"] as Action[] : []), ...(canDelete ? ["delete" as const] : [])]; const actions: Action[] = [...(canModerate ? ["public", "hidden", ...(canPrivate ? ["private"] : []), "rejected"] as Action[] : []), ...(canDelete ? ["delete" as const] : [])];
const icons = { public: CheckIcon, hidden: EyeOffIcon, private: LockIcon, rejected: XIcon, delete: Trash2Icon }; const icons = { public: CheckIcon, hidden: EyeOffIcon, private: LockIcon, rejected: XIcon, delete: Trash2Icon };
return <div className="mb-5 flex flex-col gap-3"> return <div className="mb-5 flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center sm:justify-between">
<span className="text-sm text-muted-foreground" aria-live="polite">{selected.length} selected · up to 100 at a time</span> <span className="text-sm text-muted-foreground" aria-live="polite">{selected.length} selected · up to 100 at a time</span>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" disabled={apply.isPending} onClick={() => setSelected([...new Set([...selected, ...rows.map(row => row.id)])].slice(0, 100))}>Select this page</Button> <Button variant="outline" disabled={apply.isPending} onClick={() => setSelected([...new Set([...selected, ...rows.map(row => row.id)])].slice(0, 100))}>Select this page</Button>
{canModerate ? <Button variant="outline" disabled={apply.isPending} onClick={() => setSelected([...new Set([...selected, ...rows.filter(row => row.visibility === "pending").map(row => row.id)])].slice(0, 100))}>Select awaiting review on page</Button> : null} {canModerate ? <Button variant="outline" disabled={apply.isPending} onClick={() => setSelected([...new Set([...selected, ...rows.filter(row => row.visibility === "pending").map(row => row.id)])].slice(0, 100))}>Select pending</Button> : null}
<Button variant="ghost" disabled={!selected.length || apply.isPending} onClick={() => setSelected([])}>Clear selection</Button> <Button variant="ghost" disabled={!selected.length || apply.isPending} onClick={() => setSelected([])}>Clear selection</Button>
</div>
</div> </div>
{selected.length ? <div className="flex flex-wrap gap-2">{actions.map(action => { {selected.length ? <div className="grid grid-cols-2 gap-2 sm:flex sm:flex-wrap" role="group" aria-label="Selected photo actions">{actions.map(action => {
const Icon = icons[action]; const Icon = icons[action];
return <Button key={action} variant={action === "delete" ? "destructive" : "outline"} disabled={apply.isPending} onClick={() => setPending({ eventId, photoIds: [...selected], action, requestId: crypto.randomUUID() })}><Icon data-icon="inline-start" />{labels[action]} selected</Button>; return <Button key={action} variant={action === "delete" ? "destructive" : action === "public" ? "default" : "outline"} disabled={apply.isPending} onClick={() => setPending({ eventId, photoIds: [...selected], action, requestId: crypto.randomUUID() })}><Icon data-icon="inline-start" />{labels[action]}</Button>;
})}</div> : null} })}</div> : null}
{report.length ? <details><summary className="cursor-pointer text-sm">Last bulk action · {report.length} results</summary><ul className="max-h-48 overflow-auto text-sm">{report.map(row => <li key={row.photoId}>{row.photoId.slice(0, 8)} · {row.status}{row.reason ? `${row.reason}` : ""}</li>)}</ul></details> : null} {report.length ? <details><summary className="cursor-pointer text-sm">Last bulk action · {report.length} results</summary><ul className="max-h-48 overflow-auto text-sm">{report.map(row => <li key={row.photoId}>{row.photoId.slice(0, 8)} · {row.status}{row.reason ? `${row.reason}` : ""}</li>)}</ul></details> : null}
<Dialog open={!!pending} onOpenChange={open => { if (!open && !apply.isPending) setPending(null); }}> <Dialog open={!!pending} onOpenChange={open => { if (!open && !apply.isPending) setPending(null); }}>
@@ -48,7 +54,7 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
{pending?.action === "delete" ? "Deletion is permanent and removes originals and generated images." : "Only the selected photos will change. New uploads will not be included."} {pending?.action === "delete" ? "Deletion is permanent and removes originals and generated images." : "Only the selected photos will change. New uploads will not be included."}
</DialogDescription></DialogHeader> </DialogDescription></DialogHeader>
{preview.isLoading ? <p>Checking selection</p> : preview.isError ? <p role="alert">{preview.error.message}</p> : <> {preview.isLoading ? <p>Checking selection</p> : preview.isError ? <p role="alert">{preview.error.message}</p> : <>
<p>{preview.data?.affected ?? 0} eligible · {(preview.data?.results.length ?? 0) - (preview.data?.affected ?? 0)} skipped</p> <p>{preview.data?.affected ?? 0} to update · {preview.data?.results.filter(row => row.status === "unchanged").length ?? 0} already set · {preview.data?.results.filter(row => row.status === "skipped" || row.status === "failed").length ?? 0} unavailable</p>
<div className="grid max-h-48 grid-cols-4 gap-2 overflow-auto">{pending?.photoIds.map(id => { <div className="grid max-h-48 grid-cols-4 gap-2 overflow-auto">{pending?.photoIds.map(id => {
const photo = rows.find(row => row.id === id); const photo = rows.find(row => row.id === id);
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@@ -57,7 +63,7 @@ export function BulkPhotoActions({ eventId, rows, selected, setSelected, canMode
{preview.data?.results.filter(row => row.reason).map(row => <p className="text-sm text-muted-foreground" key={row.photoId}>{row.photoId.slice(0, 8)}: {row.reason}</p>)} {preview.data?.results.filter(row => row.reason).map(row => <p className="text-sm text-muted-foreground" key={row.photoId}>{row.photoId.slice(0, 8)}: {row.reason}</p>)}
</>} </>}
<DialogFooter><Button variant="outline" disabled={apply.isPending} onClick={() => setPending(null)}>Cancel</Button> <DialogFooter><Button variant="outline" disabled={apply.isPending} onClick={() => setPending(null)}>Cancel</Button>
<Button variant={pending?.action === "delete" ? "destructive" : "default"} disabled={apply.isPending || preview.isFetching || !preview.data?.affected || preview.isError} onClick={() => { <Button variant={pending?.action === "delete" ? "destructive" : "default"} disabled={apply.isPending || preview.isFetching || !preview.data?.results.some(row => row.status === "eligible" || row.status === "unchanged") || preview.isError} onClick={() => {
if (pending && preview.data) apply.mutate({ ...pending, confirm: true }); if (pending && preview.data) apply.mutate({ ...pending, confirm: true });
}}>{apply.isPending ? "Applying…" : `Confirm ${pending ? labels[pending.action].toLowerCase() : "changes"}`}</Button></DialogFooter> }}>{apply.isPending ? "Applying…" : `Confirm ${pending ? labels[pending.action].toLowerCase() : "changes"}`}</Button></DialogFooter>
</DialogContent> </DialogContent>
@@ -0,0 +1,21 @@
"use client";
import { api } from "@/trpc/react";
import { Card, CardHeader, CardDescription, CardContent } from "@/components/ui/card";
const metrics = [
["photos", "Photos"], ["approved", "Gallery-ready"],
["awaiting_review", "Awaiting review"], ["failed", "Processing failed"],
["guests", "Guests"], ["notes", "Notes"],
] as const;
export function EventStats({ eventId }: { eventId: string }) {
const stats = api.manager.stats.useQuery({ eventId }, { refetchInterval: 15000, refetchIntervalInBackground: false });
if (stats.isError) return <p role="status" className="text-sm text-muted-foreground">Event stats couldnt load. Refresh the page to try again.</p>;
return <section aria-label="Event statistics" className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
{metrics.map(([key, label]) => <Card key={key} className="gap-2 py-4">
<CardHeader className="px-4"><CardDescription>{label}</CardDescription></CardHeader>
<CardContent className="px-4"><p className="text-2xl font-semibold tabular-nums">{stats.data ? Number(stats.data[key] ?? 0).toLocaleString() : "—"}</p></CardContent>
</Card>)}
</section>;
}
@@ -15,6 +15,7 @@ import { EventSchedule } from "./event-schedule";
import { eventStatusLabel } from "@/lib/event-status"; import { eventStatusLabel } from "@/lib/event-status";
import { effectiveEvent } from "@/lib/event-lifecycle"; import { effectiveEvent } from "@/lib/event-lifecycle";
import { EventExports } from "./event-exports"; import { EventExports } from "./event-exports";
import { EventStats } from "./event-stats";
export default async function EventDashboardPage({ export default async function EventDashboardPage({
params, params,
@@ -124,6 +125,7 @@ export default async function EventDashboardPage({
return ( return (
<EventWorkspace <EventWorkspace
heading={ heading={
<>
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="flex min-w-0 flex-col gap-2"> <div className="flex min-w-0 flex-col gap-2">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
@@ -139,11 +141,13 @@ export default async function EventDashboardPage({
<p className="truncate text-sm text-muted-foreground">{event.guestUrl.replace(/^https?:\/\//, "")}</p> <p className="truncate text-sm text-muted-foreground">{event.guestUrl.replace(/^https?:\/\//, "")}</p>
{event.location ? <p className="text-sm text-muted-foreground">{event.location}</p> : null} {event.location ? <p className="text-sm text-muted-foreground">{event.location}</p> : null}
</div> </div>
<div className="flex shrink-0 flex-wrap items-center gap-2" role="group" aria-label="Event actions"> <div className="grid grid-cols-1 gap-2 sm:flex sm:shrink-0 sm:flex-wrap sm:items-center" role="group" aria-label="Event actions">
<CopyGuestLink url={event.guestUrl} /> <CopyGuestLink url={event.guestUrl} />
<Button asChild variant="outline"><Link href={`/dashboard/events/${event.id}/sign`}><QrCodeIcon data-icon="inline-start" />Guest sign</Link></Button> <Button asChild variant="outline"><Link href={`/dashboard/events/${event.id}/sign`}><QrCodeIcon data-icon="inline-start" />Guest sign</Link></Button>
</div> </div>
</div> </div>
{event.permissions.includes("overview.read") ? <EventStats eventId={event.id} /> : null}
</>
} }
tabs={tabs} tabs={tabs}
/> />
+2 -1
View File
@@ -20,7 +20,7 @@ export async function bulkPhotos(userId: string, input: z.infer<typeof bulkPhoto
if (!input.requestId) throw new Error("Request ID required"); if (!input.requestId) throw new Error("Request ID required");
await runOnce(identity, { action: input.action, photoIds: [...input.photoIds].sort() }, async () => true); await runOnce(identity, { action: input.action, photoIds: [...input.photoIds].sort() }, async () => true);
} }
const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "skipped" | "failed"; reason?: string }[] = []; const results: { photoId: string; status: "eligible" | "updated" | "deleted" | "unchanged" | "skipped" | "failed"; reason?: string }[] = [];
// Each photo is its own transaction: failures never hide earlier successful work. // Each photo is its own transaction: failures never hide earlier successful work.
for (const photoId of input.photoIds) { for (const photoId of input.photoIds) {
try { try {
@@ -29,6 +29,7 @@ export async function bulkPhotos(userId: string, input: z.infer<typeof bulkPhoto
const [photo] = await tx.select().from(photos).where(predicate).for("update"); const [photo] = await tx.select().from(photos).where(predicate).for("update");
const skip = (reason: string) => ({ photoId, status: "skipped" as const, reason }); const skip = (reason: string) => ({ photoId, status: "skipped" as const, reason });
if (!photo || (photo.visibility === "private" && !canPrivate)) return skip("Unavailable or not permitted"); if (!photo || (photo.visibility === "private" && !canPrivate)) return skip("Unavailable or not permitted");
if (input.action === photo.visibility) return { photoId, status: "unchanged" as const };
if (input.action === "delete" && !["ready", "failed"].includes(photo.processingStatus)) return skip("Wait for upload and processing to finish"); if (input.action === "delete" && !["ready", "failed"].includes(photo.processingStatus)) return skip("Wait for upload and processing to finish");
if (input.action !== "delete" && !canTransitionVisibility(photo.visibility, input.action)) return skip("Already set or transition not allowed"); if (input.action !== "delete" && !canTransitionVisibility(photo.visibility, input.action)) return skip("Already set or transition not allowed");
if (!apply) return { photoId, status: "eligible" as const }; if (!apply) return { photoId, status: "eligible" as const };
@@ -58,6 +58,21 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s
await expect(moderator.bulkPhotos({ ...bulk, confirm: false as true })).rejects.toThrow(); await expect(moderator.bulkPhotos({ ...bulk, confirm: false as true })).rejects.toThrow();
expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2);
expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2); expect((await moderator.bulkPhotos({ ...bulk, confirm: true })).affected).toBe(2);
const repeated = await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), confirm: true });
expect(repeated.affected).toBe(0);
expect(repeated.results.filter(row => row.status === "unchanged")).toHaveLength(2);
expect(repeated.results.find(row => row.photoId === privatePhoto!.id)?.status).toBe("skipped");
await db.update(photos).set({ visibility: "pending" }).where(and(eq(photos.eventId, event!.id), eq(photos.id, result[0]!.id)));
const mixedPreview = await moderator.previewBulkPhotos(bulk);
expect(mixedPreview.affected).toBe(1);
expect(mixedPreview.results.filter(row => row.status === "unchanged")).toHaveLength(1);
const mixed = await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), confirm: true });
expect(mixed.affected).toBe(1);
expect(mixed.results.filter(row => row.status === "unchanged")).toHaveLength(1);
const stats = await manager.stats({ eventId: event!.id });
expect(Number(stats!.photos)).toBe(3);
expect(Number(stats!.approved)).toBe(1);
expect(Number(stats!.guests)).toBe(1);
await expect(moderator.bulkPhotos({ ...bulk, action: "hidden", confirm: true })).rejects.toThrow("different inputs"); await expect(moderator.bulkPhotos({ ...bulk, action: "hidden", confirm: true })).rejects.toThrow("different inputs");
// A replay cannot undo a later, intentional change. // A replay cannot undo a later, intentional change.
await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), action: "hidden", confirm: true }); await moderator.bulkPhotos({ ...bulk, requestId: crypto.randomUUID(), action: "hidden", confirm: true });
@@ -66,6 +81,7 @@ test.skipIf(process.env.POLISH_INTEGRATION !== "1")("group ownership and event-s
await expect(moderator.bulkPhotos({ ...bulk, action: "private", confirm: true })).rejects.toThrow(); await expect(moderator.bulkPhotos({ ...bulk, action: "private", confirm: true })).rejects.toThrow();
const [otherEvent] = await db.insert(events).values({ groupId, title: "Other event", slug: `${id}-other` }).returning(); const [otherEvent] = await db.insert(events).values({ groupId, title: "Other event", slug: `${id}-other` }).returning();
await db.insert(eventMemberships).values({ eventId: otherEvent!.id, userId: people[0]!.id, role: "owner" }); await db.insert(eventMemberships).values({ eventId: otherEvent!.id, userId: people[0]!.id, role: "owner" });
expect(Number((await manager.stats({ eventId: otherEvent!.id }))!.photos)).toBe(0);
const crossEvent = await manager.previewBulkPhotos({ ...bulk, eventId: otherEvent!.id }); const crossEvent = await manager.previewBulkPhotos({ ...bulk, eventId: otherEvent!.id });
expect(crossEvent.affected).toBe(0); expect(crossEvent.affected).toBe(0);
const deletion = await manager.previewBulkPhotos({ ...bulk, action: "delete" }); const deletion = await manager.previewBulkPhotos({ ...bulk, action: "delete" });