Add event dashboard stats and tolerate repeated bulk approvals
This commit is contained in:
@@ -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>
|
||||||
{selected.length ? <div className="flex flex-wrap gap-2">{actions.map(action => {
|
</div>
|
||||||
|
{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 couldn’t 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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user