diff --git a/ROADMAP.md b/ROADMAP.md index 042abd3..2c5cd10 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,8 @@ The review tool lives at its own dedicated page (`/projects/[projectId]/delivera - Annotations linked to comments (transactional create), undo/redo stack - Screenshot paste: Cmd+V pastes clipboard image as draggable/resizable callout - "Review" button on stage cards in deliverable detail page +- Review sessions: session builder, presenter mode, summary grid, decision recording +- `ReviewSession` + `ReviewSessionItem` models in schema **New dependency for all stages:** `sharp` (server-side PNG alpha compositing + image processing) @@ -343,7 +345,7 @@ enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED } --- -#### A6 — Review Sessions & Playlists `[ ]` +#### A6 — Review Sessions & Playlists `[x]` Curate a batch of deliverables into a structured review session. Walk through them sequentially in presenter mode with per-item decisions. The formal review workflow for HP stakeholder reviews. @@ -385,13 +387,31 @@ enum ReviewSessionStatus { DRAFT IN_PROGRESS COMPLETED } enum ReviewDecision { APPROVED CHANGES_REQUESTED REJECTED } ``` +**Infrastructure built so far:** +- Review sessions list page at `/reviews` with create/delete, status filtering +- Session detail page at `/reviews/[sessionId]` with builder (list) and summary (grid) views +- Session builder with auto-fill from project (filter by stage status), reorder, remove items +- Presenter mode with full-screen walkthrough, image viewer, annotation overlay (read-only), keyboard shortcuts (A=approve, C=changes, arrows=navigate, Esc=exit), fullscreen toggle +- Session summary with thumbnail grid, decision badges, stats strip +- `ReviewSession` + `ReviewSessionItem` models in Prisma schema (decision stored as string, no enum coupling) +- Full service layer, API routes (CRUD + add-items/remove/reorder/decide/generate), TanStack Query hooks +- "Reviews" added to sidebar navigation +- `AnnotationLayer` extended with `readOnly` prop for presenter mode +- No shareable links (deferred to B3) +- No pipeline advancement on approve (manual "client approved" comes later) + **Key files:** - `src/app/(app)/reviews/page.tsx` — Session list -- `src/app/(app)/reviews/[sessionId]/page.tsx` — Session presenter view -- `src/components/review/session-builder.tsx` — Create/edit session +- `src/app/(app)/reviews/[sessionId]/page.tsx` — Session detail (builder/summary/presenter) +- `src/components/review/create-session-dialog.tsx` — Create session dialog +- `src/components/review/session-builder.tsx` — Item list with auto-fill - `src/components/review/session-presenter.tsx` — Full-screen walkthrough - `src/components/review/session-summary.tsx` — Thumbnail grid with decisions -- `src/lib/services/review-session-service.ts` +- `src/lib/services/review-session-service.ts` — All business logic +- `src/lib/validators/review-session.ts` — Zod schemas +- `src/hooks/use-review-sessions.ts` — TanStack Query hooks +- `src/app/api/reviews/route.ts` — List/create API +- `src/app/api/reviews/[sessionId]/route.ts` — CRUD + actions API **Dependencies:** Requires A1 + A3 @@ -854,7 +874,7 @@ Note: `Dockerfile` and `docker-compose.yml` already exist in the repo root — r |---|---| | Annotation | A3 | | FeedbackItem | A5 | -| ReviewSession, ReviewSessionItem | A6 | +| ~~ReviewSession, ReviewSessionItem~~ | ~~A6~~ ✅ | | ApprovalChain, ApprovalStep, ApprovalRecord | D2 | | ProjectTemplate, ProjectTemplateDeliverable | D3 | | AssetSpec, AssetValidationResult | E1 | diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e2a1c72..e3f8de6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -149,6 +149,8 @@ model User { feedbackResolved FeedbackItem[] @relation("FeedbackResolver") feedbackVerified FeedbackItem[] @relation("FeedbackVerifier") colorProbes ColorProbe[] + reviewSessionsCreated ReviewSession[] @relation("ReviewSessionCreator") + reviewSessionDecisions ReviewSessionItem[] @relation("ReviewSessionDecider") @@map("users") } @@ -399,6 +401,7 @@ model DeliverableStage { revisions Revision[] comments Comment[] feedbackItems FeedbackItem[] + reviewSessionItems ReviewSessionItem[] @@unique([deliverableId, templateId]) @@index([deliverableId]) @@ -446,6 +449,7 @@ model Revision { annotations Annotation[] feedbackItems FeedbackItem[] colorProbes ColorProbe[] + reviewSessionItems ReviewSessionItem[] @@index([deliverableStageId]) @@map("revisions") @@ -804,3 +808,50 @@ model FeedbackItem { // FeedbackSeverity removed — replaced by isActionItem boolean // Action items = things the artist must fix (default for annotations) // Info callouts = context/reference that doesn't need action + +// ─── Review Sessions (A6) ──────────────────────────────── + +enum ReviewSessionStatus { + DRAFT + IN_PROGRESS + COMPLETED +} + +model ReviewSession { + id String @id @default(cuid()) + name String + description String? + status ReviewSessionStatus @default(DRAFT) + + createdById String + createdBy User @relation("ReviewSessionCreator", fields: [createdById], references: [id]) + organizationId String + + items ReviewSessionItem[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([organizationId]) + @@index([status]) + @@map("review_sessions") +} + +model ReviewSessionItem { + id String @id @default(cuid()) + sessionId String + session ReviewSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + deliverableStageId String + deliverableStage DeliverableStage @relation(fields: [deliverableStageId], references: [id]) + revisionId String? + revision Revision? @relation(fields: [revisionId], references: [id]) + sortOrder Int + decision String? // "APPROVED" | "CHANGES_REQUESTED" — stored as string to avoid enum coupling with D2 + decisionNote String? + decidedById String? + decidedBy User? @relation("ReviewSessionDecider", fields: [decidedById], references: [id]) + decidedAt DateTime? + + @@index([sessionId]) + @@map("review_session_items") +} diff --git a/src/app/(app)/reviews/[sessionId]/page.tsx b/src/app/(app)/reviews/[sessionId]/page.tsx new file mode 100644 index 0000000..4df483a --- /dev/null +++ b/src/app/(app)/reviews/[sessionId]/page.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; +import { format } from "date-fns"; +import { + ArrowLeft, + Play, + Pencil, + Grid3x3, + List, + CheckCircle2, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + useReviewSession, + useUpdateReviewSession, +} from "@/hooks/use-review-sessions"; +import { SessionBuilder } from "@/components/review/session-builder"; +import { SessionPresenter } from "@/components/review/session-presenter"; +import { SessionSummary } from "@/components/review/session-summary"; + +const STATUS_STYLES: Record = { + DRAFT: { + label: "Draft", + className: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]", + }, + IN_PROGRESS: { + label: "In Progress", + className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]", + }, + COMPLETED: { + label: "Completed", + className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]", + }, +}; + +export default function ReviewSessionPage() { + const { sessionId } = useParams<{ sessionId: string }>(); + const router = useRouter(); + const searchParams = useSearchParams(); + const initialMode = searchParams.get("mode"); + + const [view, setView] = useState<"builder" | "summary" | "presenter">( + initialMode === "present" ? "presenter" : "builder" + ); + const [isEditingName, setIsEditingName] = useState(false); + const [editName, setEditName] = useState(""); + + const { data: session, isLoading } = useReviewSession(sessionId); + const updateMutation = useUpdateReviewSession(sessionId); + + const items = (session?.items as any[]) ?? []; + const statusConfig = STATUS_STYLES[session?.status] ?? STATUS_STYLES.DRAFT; + + // ── Name editing ──────────────────────────────────────────────────────── + + const handleStartEdit = useCallback(() => { + setEditName(session?.name ?? ""); + setIsEditingName(true); + }, [session?.name]); + + const handleSaveName = useCallback(() => { + if (!editName.trim()) return; + updateMutation.mutate( + { name: editName.trim() }, + { + onSuccess: () => { + setIsEditingName(false); + toast.success("Name updated"); + }, + } + ); + }, [editName, updateMutation]); + + // ── Status transitions ────────────────────────────────────────────────── + + const handleStartSession = useCallback(() => { + updateMutation.mutate( + { status: "IN_PROGRESS" }, + { + onSuccess: () => { + toast.success("Session started"); + setView("presenter"); + }, + } + ); + }, [updateMutation]); + + const handleCompleteSession = useCallback(() => { + updateMutation.mutate( + { status: "COMPLETED" }, + { + onSuccess: () => { + toast.success("Session completed"); + setView("summary"); + }, + } + ); + }, [updateMutation]); + + const handleReopenSession = useCallback(() => { + updateMutation.mutate( + { status: "IN_PROGRESS" }, + { + onSuccess: () => toast.success("Session reopened"), + } + ); + }, [updateMutation]); + + // ── Presenter exit ────────────────────────────────────────────────────── + + const handleExitPresenter = useCallback(() => { + setView("builder"); + // Remove ?mode=present from URL + router.replace(`/reviews/${sessionId}`, { scroll: false }); + }, [router, sessionId]); + + // ── Loading ───────────────────────────────────────────────────────────── + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (!session) { + return ( +
+ Session not found. +
+ ); + } + + // ── Presenter mode (full height) ─────────────────────────────────────── + + if (view === "presenter") { + return ( +
+ +
+ ); + } + + // ── Builder / Summary view ────────────────────────────────────────────── + + return ( +
+ {/* ── Top bar ──────────────────────────────────────────── */} +
+ {/* Left: back + name */} +
+ + + Sessions + + + + {isEditingName ? ( + setEditName(e.target.value)} + onBlur={handleSaveName} + onKeyDown={(e) => { + if (e.key === "Enter") handleSaveName(); + if (e.key === "Escape") setIsEditingName(false); + }} + className="h-7 w-60 text-sm font-semibold" + autoFocus + /> + ) : ( + + )} + + + {statusConfig.label} + + + {session.createdBy && ( + + by {session.createdBy.name} ·{" "} + {format(new Date(session.createdAt), "MMM d")} + + )} +
+ + {/* Right: view toggle + actions */} +
+ {/* View toggle */} +
+ + +
+ + + + {/* Status actions */} + {session.status === "DRAFT" && items.length > 0 && ( + + )} + + {session.status === "IN_PROGRESS" && ( + <> + + + + )} + + {session.status === "COMPLETED" && ( + + )} +
+
+ + {/* ── Content ──────────────────────────────────────────── */} +
+ {view === "builder" && ( + + )} + + {view === "summary" && ( +
+ { + setView("presenter"); + // The presenter will handle its own index state + }} + /> +
+ )} +
+
+ ); +} diff --git a/src/app/(app)/reviews/page.tsx b/src/app/(app)/reviews/page.tsx new file mode 100644 index 0000000..541862d --- /dev/null +++ b/src/app/(app)/reviews/page.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { format } from "date-fns"; +import { + Plus, + Play, + FileCheck, + Pencil, + Trash2, + Presentation, + Inbox, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { + useReviewSessions, + useDeleteReviewSession, +} from "@/hooks/use-review-sessions"; +import { CreateSessionDialog } from "@/components/review/create-session-dialog"; + +const STATUS_STYLES: Record = { + DRAFT: { + label: "Draft", + className: "bg-[var(--muted)]/50 text-[var(--muted-foreground)]", + }, + IN_PROGRESS: { + label: "In Progress", + className: "bg-[var(--status-in-review)]/10 text-[var(--status-in-review)]", + }, + COMPLETED: { + label: "Completed", + className: "bg-[var(--status-approved)]/10 text-[var(--status-approved)]", + }, +}; + +export default function ReviewSessionsPage() { + const [statusFilter, setStatusFilter] = useState(); + const [createOpen, setCreateOpen] = useState(false); + const [deleteId, setDeleteId] = useState(null); + + const { data: sessions, isLoading } = useReviewSessions(statusFilter); + const deleteMutation = useDeleteReviewSession(); + + const handleDelete = () => { + if (!deleteId) return; + deleteMutation.mutate(deleteId, { + onSuccess: () => { + toast.success("Session deleted"); + setDeleteId(null); + }, + onError: (err) => toast.error("Delete failed", { description: err.message }), + }); + }; + + return ( +
+ {/* ── Header ──────────────────────────────────────────── */} +
+
+

+ Review Sessions +

+

+ Batch review deliverables in a structured walkthrough +

+
+ +
+ + {/* ── Status filter tabs ──────────────────────────────── */} +
+ {[ + { value: undefined, label: "All" }, + { value: "DRAFT", label: "Draft" }, + { value: "IN_PROGRESS", label: "In Progress" }, + { value: "COMPLETED", label: "Completed" }, + ].map((tab) => ( + + ))} +
+ + {/* ── Session list ────────────────────────────────────── */} + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ )} + + {!isLoading && (!sessions || sessions.length === 0) && ( +
+ +

+ No review sessions yet +

+ +
+ )} + + {sessions && sessions.length > 0 && ( +
+ {sessions.map((session: any) => { + const statusConfig = STATUS_STYLES[session.status] ?? STATUS_STYLES.DRAFT; + const itemCount = session._count?.items ?? 0; + const decidedCount = session.items?.filter( + (i: any) => i.decision != null + ).length ?? 0; + + return ( + + {/* Icon */} +
+ +
+ + {/* Info */} +
+
+ + {session.name} + + + {statusConfig.label} + +
+
+ {itemCount} items + {itemCount > 0 && ( + + {decidedCount}/{itemCount} decided + + )} + + by {session.createdBy?.name ?? "Unknown"} + + + {format(new Date(session.updatedAt), "MMM d, yyyy")} + +
+
+ + {/* Actions */} +
e.preventDefault()} + > + {session.status === "DRAFT" && ( + + + + + + + Edit + + )} + {(session.status === "DRAFT" || session.status === "IN_PROGRESS") && ( + + + + + + + Present + + )} + {session.status === "COMPLETED" && ( + + + + + + + View Summary + + )} + + + + + Delete + +
+ + ); + })} +
+ )} + + {/* ── Create dialog ───────────────────────────────────── */} + + + {/* ── Delete confirmation ─────────────────────────────── */} + setDeleteId(null)}> + + + Delete review session? + + This will permanently delete the session and all its items. This + action cannot be undone. + + + + Cancel + + Delete + + + + +
+ ); +} diff --git a/src/app/api/reviews/[sessionId]/route.ts b/src/app/api/reviews/[sessionId]/route.ts new file mode 100644 index 0000000..c971e18 --- /dev/null +++ b/src/app/api/reviews/[sessionId]/route.ts @@ -0,0 +1,142 @@ +import { NextResponse } from "next/server"; +import { + getAuthSession, + badRequest, + notFound, + serverError, +} from "@/lib/api-utils"; +import { + updateReviewSessionSchema, + addSessionItemsSchema, + reorderSessionItemsSchema, + recordDecisionSchema, + generateSessionItemsSchema, +} from "@/lib/validators/review-session"; +import { + getReviewSession, + updateReviewSession, + deleteReviewSession, + addSessionItems, + removeSessionItem, + reorderSessionItems, + recordDecision, + clearDecision, + generateSessionItems, +} from "@/lib/services/review-session-service"; + +type Params = { params: Promise<{ sessionId: string }> }; + +// GET /api/reviews/:sessionId +export async function GET(_request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { sessionId } = await params; + const session = await getReviewSession(sessionId); + if (!session) return notFound("Review session not found"); + return NextResponse.json(session); + } catch (e) { + return serverError(e); + } +} + +// PATCH /api/reviews/:sessionId +// Handles multiple actions via `action` field: +// - (default) update session metadata +// - "add-items" — add items to session +// - "remove-item" — remove an item +// - "reorder" — reorder items +// - "decide" — record decision on item +// - "clear-decision" — clear a decision +// - "generate" — generate items from project filters +export async function PATCH(request: Request, { params }: Params) { + const { session: authSession, error } = await getAuthSession(); + if (error) return error; + + try { + const { sessionId } = await params; + const body = await request.json(); + const action = body.action as string | undefined; + + if (action === "add-items") { + const parsed = addSessionItemsSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const items = await addSessionItems(sessionId, parsed.data); + // Return full session after modification + const updated = await getReviewSession(sessionId); + return NextResponse.json(updated); + } + + if (action === "remove-item") { + const itemId = body.itemId as string; + if (!itemId) return badRequest("itemId is required"); + await removeSessionItem(itemId); + const updated = await getReviewSession(sessionId); + return NextResponse.json(updated); + } + + if (action === "reorder") { + const parsed = reorderSessionItemsSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + await reorderSessionItems(sessionId, parsed.data); + const updated = await getReviewSession(sessionId); + return NextResponse.json(updated); + } + + if (action === "decide") { + const parsed = recordDecisionSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + await recordDecision(authSession!.user.id, parsed.data); + const updated = await getReviewSession(sessionId); + return NextResponse.json(updated); + } + + if (action === "clear-decision") { + const itemId = body.itemId as string; + if (!itemId) return badRequest("itemId is required"); + await clearDecision(itemId); + const updated = await getReviewSession(sessionId); + return NextResponse.json(updated); + } + + if (action === "generate") { + const parsed = generateSessionItemsSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const candidates = await generateSessionItems(parsed.data); + return NextResponse.json(candidates); + } + + // Default: update session metadata + const parsed = updateReviewSessionSchema.safeParse(body); + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + const updated = await updateReviewSession(sessionId, parsed.data); + return NextResponse.json(updated); + } catch (e) { + return serverError(e); + } +} + +// DELETE /api/reviews/:sessionId +export async function DELETE(_request: Request, { params }: Params) { + const { error } = await getAuthSession(); + if (error) return error; + + try { + const { sessionId } = await params; + await deleteReviewSession(sessionId); + return NextResponse.json({ ok: true }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/app/api/reviews/route.ts b/src/app/api/reviews/route.ts new file mode 100644 index 0000000..94a9e10 --- /dev/null +++ b/src/app/api/reviews/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import { getAuthSession, badRequest, serverError } from "@/lib/api-utils"; +import { createReviewSessionSchema } from "@/lib/validators/review-session"; +import { + listReviewSessions, + createReviewSession, +} from "@/lib/services/review-session-service"; + +// GET /api/reviews +// Query params: ?status=DRAFT|IN_PROGRESS|COMPLETED +export async function GET(request: Request) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const url = new URL(request.url); + const status = url.searchParams.get("status") ?? undefined; + + const sessions = await listReviewSessions( + session!.user.organizationId as string, + { status } + ); + return NextResponse.json(sessions); + } catch (e) { + return serverError(e); + } +} + +// POST /api/reviews +export async function POST(request: Request) { + const { session, error } = await getAuthSession(); + if (error) return error; + + try { + const body = await request.json(); + const parsed = createReviewSessionSchema.safeParse(body); + + if (!parsed.success) { + return badRequest(parsed.error.issues.map((i) => i.message).join(", ")); + } + + const reviewSession = await createReviewSession( + session!.user.organizationId as string, + session!.user.id, + parsed.data + ); + return NextResponse.json(reviewSession, { status: 201 }); + } catch (e) { + return serverError(e); + } +} diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 04376ea..b1f8366 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -15,6 +15,7 @@ import { Menu, CalendarDays, FileBarChart, + Presentation, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -30,6 +31,7 @@ const navItems = [ { href: "/workload", label: "Workload", icon: Users }, { href: "/timeline", label: "Timeline", icon: GanttChart }, { href: "/calendar", label: "Calendar", icon: CalendarDays }, + { href: "/reviews", label: "Reviews", icon: Presentation }, { href: "/reports", label: "Reports", icon: FileBarChart }, { href: "/notifications", label: "Notifications", icon: Bell }, ]; diff --git a/src/components/review/annotation-layer.tsx b/src/components/review/annotation-layer.tsx index 54a67e1..44ac4aa 100644 --- a/src/components/review/annotation-layer.tsx +++ b/src/components/review/annotation-layer.tsx @@ -21,6 +21,7 @@ export interface AnnotationLayerProps { containerWidth: number; containerHeight: number; imageDimensions: { width: number; height: number } | null; + readOnly?: boolean; } // ── Component ────────────────────────────────────────── @@ -34,12 +35,14 @@ export function AnnotationLayer({ containerWidth, containerHeight, imageDimensions, + readOnly = false, }: AnnotationLayerProps) { const ann = useAnnotationState(revisionId, stageId); - // Clipboard paste handler for screenshots + // Clipboard paste handler for screenshots (disabled in readOnly mode) useEffect(() => { const handlePaste = async (e: ClipboardEvent) => { + if (readOnly) return; if (!revisionId || !stageId) return; const items = e.clipboardData?.items; @@ -81,6 +84,7 @@ export function AnnotationLayer({ return ( <> {/* ── Annotation toolbar (floating inside viewport, top-center) ── */} + {!readOnly && (
+ )} {/* ── SVG overlay ────────────────────────────────── */} ann.handleMouseDown(e, panX, panY, zoom)} - onMouseMove={(e) => ann.handleMouseMove(e, panX, panY, zoom)} - onMouseUp={ann.handleMouseUp} + onMouseDown={readOnly ? undefined : (e) => ann.handleMouseDown(e, panX, panY, zoom)} + onMouseMove={readOnly ? undefined : (e) => ann.handleMouseMove(e, panX, panY, zoom)} + onMouseUp={readOnly ? undefined : ann.handleMouseUp} > {ann.visible && ( @@ -146,7 +152,7 @@ export function AnnotationLayer({ })} {/* Drawing preview */} - {ann.drawingPreview && ( + {!readOnly && ann.drawingPreview && ( @@ -156,7 +162,7 @@ export function AnnotationLayer({ {/* ── Floating text input ────────────────────────── */} - {ann.textInput && ( + {!readOnly && ann.textInput && (
void; +} + +export function CreateSessionDialog({ + open, + onOpenChange, +}: CreateSessionDialogProps) { + const router = useRouter(); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const createMutation = useCreateReviewSession(); + + const handleCreate = () => { + if (!name.trim()) { + toast.error("Session name is required"); + return; + } + + createMutation.mutate( + { name: name.trim(), description: description.trim() || undefined }, + { + onSuccess: (session: any) => { + toast.success("Session created"); + onOpenChange(false); + setName(""); + setDescription(""); + router.push(`/reviews/${session.id}`); + }, + onError: (err) => + toast.error("Failed to create session", { description: err.message }), + } + ); + }; + + return ( + + + + + New Review Session + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Q1 Catalog Images Review" + className="mt-1" + autoFocus + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> +
+
+ +