feat: add review session components and hooks

- Implemented SessionPresenter and SessionSummary components for managing review sessions.
- Created AlertDialog component for modal dialogs.
- Developed hooks for managing review sessions, including fetching, creating, updating, and deleting sessions.
- Added service functions for review session operations in the backend.
- Introduced validation schemas for review session inputs using Zod.
This commit is contained in:
Leivur R. Djurhuus 2026-03-14 21:30:35 -05:00 committed by Leivur Djurhuus
parent db82eb4fed
commit 6f5cbc2f1f
16 changed files with 2834 additions and 14 deletions

View file

@ -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 |

View file

@ -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")
}

View file

@ -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<string, { label: string; className: string }> = {
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 (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
<Skeleton className="h-12 w-full" />
<Skeleton className="mt-2 flex-1" />
</div>
);
}
if (!session) {
return (
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
Session not found.
</div>
);
}
// ── Presenter mode (full height) ───────────────────────────────────────
if (view === "presenter") {
return (
<div className="h-[calc(100vh-3.5rem)]">
<SessionPresenter
sessionId={sessionId}
items={items}
sessionName={session.name}
onExit={handleExitPresenter}
/>
</div>
);
}
// ── Builder / Summary view ──────────────────────────────────────────────
return (
<div className="flex h-[calc(100vh-3.5rem)] flex-col">
{/* ── Top bar ──────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
{/* Left: back + name */}
<div className="flex items-center gap-3">
<Link
href="/reviews"
className="flex items-center gap-1 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
<ArrowLeft className="h-3.5 w-3.5" />
Sessions
</Link>
<Separator orientation="vertical" className="h-5" />
{isEditingName ? (
<Input
value={editName}
onChange={(e) => 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
/>
) : (
<button
onClick={handleStartEdit}
className="flex items-center gap-1.5 font-heading text-sm font-semibold hover:text-[var(--primary)]"
>
{session.name}
<Pencil className="h-2.5 w-2.5 text-[var(--muted-foreground)]" />
</button>
)}
<Badge
variant="secondary"
className={cn("text-[10px] uppercase", statusConfig.className)}
>
{statusConfig.label}
</Badge>
{session.createdBy && (
<span className="text-[10px] text-[var(--muted-foreground)]">
by {session.createdBy.name} &middot;{" "}
{format(new Date(session.createdAt), "MMM d")}
</span>
)}
</div>
{/* Right: view toggle + actions */}
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="flex rounded-md border">
<Button
size="sm"
variant={view === "builder" ? "default" : "ghost"}
className="h-7 rounded-r-none text-xs"
onClick={() => setView("builder")}
>
<List className="mr-1 h-3 w-3" />
List
</Button>
<Button
size="sm"
variant={view === "summary" ? "default" : "ghost"}
className="h-7 rounded-l-none text-xs"
onClick={() => setView("summary")}
>
<Grid3x3 className="mr-1 h-3 w-3" />
Grid
</Button>
</div>
<Separator orientation="vertical" className="h-5" />
{/* Status actions */}
{session.status === "DRAFT" && items.length > 0 && (
<Button
size="sm"
className="h-7 text-xs"
onClick={handleStartSession}
>
<Play className="mr-1 h-3 w-3" />
Start Review
</Button>
)}
{session.status === "IN_PROGRESS" && (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setView("presenter")}
>
<Play className="mr-1 h-3 w-3" />
Present
</Button>
<Button
size="sm"
className="h-7 text-xs"
onClick={handleCompleteSession}
>
<CheckCircle2 className="mr-1 h-3 w-3" />
Complete
</Button>
</>
)}
{session.status === "COMPLETED" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={handleReopenSession}
>
Reopen
</Button>
)}
</div>
</div>
{/* ── Content ──────────────────────────────────────────── */}
<div className="flex-1 overflow-y-auto">
{view === "builder" && (
<SessionBuilder
sessionId={sessionId}
items={items}
isLoading={isLoading}
/>
)}
{view === "summary" && (
<div className="p-4">
<SessionSummary
items={items}
onItemClick={(idx) => {
setView("presenter");
// The presenter will handle its own index state
}}
/>
</div>
)}
</div>
</div>
);
}

View file

@ -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<string, { label: string; className: string }> = {
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<string | undefined>();
const [createOpen, setCreateOpen] = useState(false);
const [deleteId, setDeleteId] = useState<string | null>(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 (
<div className="mx-auto max-w-5xl px-6 py-6">
{/* ── Header ──────────────────────────────────────────── */}
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="font-heading text-lg font-bold tracking-tight">
Review Sessions
</h1>
<p className="mt-0.5 text-xs text-[var(--muted-foreground)]">
Batch review deliverables in a structured walkthrough
</p>
</div>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Session
</Button>
</div>
{/* ── Status filter tabs ──────────────────────────────── */}
<div className="mb-4 flex gap-1">
{[
{ value: undefined, label: "All" },
{ value: "DRAFT", label: "Draft" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "COMPLETED", label: "Completed" },
].map((tab) => (
<Button
key={tab.label}
size="sm"
variant={statusFilter === tab.value ? "default" : "ghost"}
className="h-7 text-xs"
onClick={() => setStatusFilter(tab.value)}
>
{tab.label}
</Button>
))}
</div>
{/* ── Session list ────────────────────────────────────── */}
{isLoading && (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full rounded-lg" />
))}
</div>
)}
{!isLoading && (!sessions || sessions.length === 0) && (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed py-16">
<Inbox className="h-10 w-10 text-[var(--muted-foreground)]/30" />
<p className="text-sm text-[var(--muted-foreground)]">
No review sessions yet
</p>
<Button
size="sm"
variant="outline"
onClick={() => setCreateOpen(true)}
>
<Plus className="mr-1.5 h-3.5 w-3.5" />
Create your first session
</Button>
</div>
)}
{sessions && sessions.length > 0 && (
<div className="space-y-2">
{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 (
<Link
key={session.id}
href={`/reviews/${session.id}`}
className="group flex items-center gap-4 rounded-lg border bg-[var(--card)] px-4 py-3 transition-colors hover:bg-[var(--background)]"
>
{/* Icon */}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-[var(--primary)]/10">
<Presentation className="h-4 w-4 text-[var(--primary)]" />
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">
{session.name}
</span>
<Badge
variant="secondary"
className={cn("text-[10px] uppercase", statusConfig.className)}
>
{statusConfig.label}
</Badge>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--muted-foreground)]">
<span>{itemCount} items</span>
{itemCount > 0 && (
<span>
{decidedCount}/{itemCount} decided
</span>
)}
<span>
by {session.createdBy?.name ?? "Unknown"}
</span>
<span>
{format(new Date(session.updatedAt), "MMM d, yyyy")}
</span>
</div>
</div>
{/* Actions */}
<div
className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100"
onClick={(e) => e.preventDefault()}
>
{session.status === "DRAFT" && (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/reviews/${session.id}`}>
<Button size="icon" variant="ghost" className="h-7 w-7">
<Pencil className="h-3 w-3" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent className="text-xs">Edit</TooltipContent>
</Tooltip>
)}
{(session.status === "DRAFT" || session.status === "IN_PROGRESS") && (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/reviews/${session.id}?mode=present`}>
<Button size="icon" variant="ghost" className="h-7 w-7">
<Play className="h-3 w-3" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent className="text-xs">Present</TooltipContent>
</Tooltip>
)}
{session.status === "COMPLETED" && (
<Tooltip>
<TooltipTrigger asChild>
<Link href={`/reviews/${session.id}`}>
<Button size="icon" variant="ghost" className="h-7 w-7">
<FileCheck className="h-3 w-3" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent className="text-xs">View Summary</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 text-red-500 hover:text-red-600"
onClick={() => setDeleteId(session.id)}
>
<Trash2 className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">Delete</TooltipContent>
</Tooltip>
</div>
</Link>
);
})}
</div>
)}
{/* ── Create dialog ───────────────────────────────────── */}
<CreateSessionDialog open={createOpen} onOpenChange={setCreateOpen} />
{/* ── Delete confirmation ─────────────────────────────── */}
<AlertDialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete review session?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the session and all its items. This
action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-red-600 hover:bg-red-700"
onClick={handleDelete}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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 },
];

View file

@ -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 && (
<div className="absolute left-1/2 top-2 z-30 -translate-x-1/2">
<div className="flex items-center rounded-md border bg-[var(--card)]/90 px-2 py-1 shadow-lg backdrop-blur-sm">
<AnnotationTools
@ -99,6 +103,7 @@ export function AnnotationLayer({
/>
</div>
</div>
)}
{/* ── SVG overlay ────────────────────────────────── */}
<svg
@ -107,13 +112,14 @@ export function AnnotationLayer({
width={containerWidth}
height={containerHeight}
style={{
cursor: cursorStyle,
pointerEvents:
ann.activeTool === "select" && !ann.selectedId ? "none" : "auto",
cursor: readOnly ? "default" : cursorStyle,
pointerEvents: readOnly
? "none"
: ann.activeTool === "select" && !ann.selectedId ? "none" : "auto",
}}
onMouseDown={(e) => 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 && (
<g transform={`translate(${panX}, ${panY}) scale(${zoom})`}>
@ -146,7 +152,7 @@ export function AnnotationLayer({
})}
{/* Drawing preview */}
{ann.drawingPreview && (
{!readOnly && ann.drawingPreview && (
<g opacity={0.8}>
<AnnotationRenderer annotation={ann.drawingPreview} />
</g>
@ -156,7 +162,7 @@ export function AnnotationLayer({
</svg>
{/* ── Floating text input ────────────────────────── */}
{ann.textInput && (
{!readOnly && ann.textInput && (
<div
className="absolute z-50"
style={{

View file

@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { useCreateReviewSession } from "@/hooks/use-review-sessions";
interface CreateSessionDialogProps {
open: boolean;
onOpenChange: (open: boolean) => 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-sm font-semibold">
New Review Session
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Session Name
</label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Q1 Catalog Images Review"
className="mt-1"
autoFocus
onKeyDown={(e) => e.key === "Enter" && handleCreate()}
/>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Description
</label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Optional notes about this session..."
className="mt-1 resize-none"
rows={2}
/>
</div>
</div>
<DialogFooter>
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
size="sm"
onClick={handleCreate}
disabled={createMutation.isPending || !name.trim()}
>
{createMutation.isPending ? "Creating..." : "Create Session"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,478 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
GripVertical,
Trash2,
Plus,
Wand2,
Image as ImageIcon,
ChevronDown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { useProjects } from "@/hooks/use-projects";
import {
useAddSessionItems,
useRemoveSessionItem,
useReorderSessionItems,
useGenerateSessionItems,
} from "@/hooks/use-review-sessions";
// ── Types ─────────────────────────────────────────────────────────────────
interface SessionItem {
id: string;
sortOrder: number;
decision: string | null;
decisionNote: string | null;
deliverableStage: {
id: string;
status: string;
template: { id: string; name: string; slug: string; order: number };
deliverable: {
id: string;
name: string;
priority: string;
project: { id: string; name: string; projectCode: string };
};
revisions: { id: string; roundNumber: number; attachments: any }[];
assignments: { user: { id: string; name: string; image: string | null } }[];
};
}
interface SessionBuilderProps {
sessionId: string;
items: SessionItem[];
isLoading?: boolean;
}
// ── Stage status options for the generate filter ──────────────────────────
const STAGE_STATUSES = [
{ value: "IN_REVIEW", label: "In Review" },
{ value: "CHANGES_REQUESTED", label: "Changes Requested" },
{ value: "IN_PROGRESS", label: "In Progress" },
{ value: "APPROVED", label: "Approved" },
{ value: "DELIVERED", label: "Delivered" },
];
// ── Component ─────────────────────────────────────────────────────────────
export function SessionBuilder({
sessionId,
items,
isLoading,
}: SessionBuilderProps) {
const [generateOpen, setGenerateOpen] = useState(false);
const [genProjectId, setGenProjectId] = useState("");
const [genStatus, setGenStatus] = useState("");
const [genCandidates, setGenCandidates] = useState<any[] | null>(null);
const addItemsMutation = useAddSessionItems(sessionId);
const removeItemMutation = useRemoveSessionItem(sessionId);
const reorderMutation = useReorderSessionItems(sessionId);
const generateMutation = useGenerateSessionItems(sessionId);
const { data: projectsData } = useProjects();
const projects = (projectsData as any[]) ?? [];
// ── Drag-and-drop reorder (simplified: move up/down buttons) ────────────
const moveItem = useCallback(
(index: number, direction: -1 | 1) => {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= items.length) return;
const reordered = [...items];
const [moved] = reordered.splice(index, 1);
reordered.splice(newIndex, 0, moved);
reorderMutation.mutate(reordered.map((i) => i.id));
},
[items, reorderMutation]
);
const handleRemoveItem = useCallback(
(itemId: string) => {
removeItemMutation.mutate(itemId, {
onError: (err) =>
toast.error("Failed to remove item", { description: err.message }),
});
},
[removeItemMutation]
);
// ── Generate from filters ───────────────────────────────────────────────
const handleGenerate = useCallback(() => {
if (!genProjectId) {
toast.error("Select a project");
return;
}
generateMutation.mutate(
{
projectId: genProjectId,
stageStatus: genStatus || undefined,
},
{
onSuccess: (data: any) => {
setGenCandidates(data);
},
onError: (err) =>
toast.error("Failed to generate", { description: err.message }),
}
);
}, [genProjectId, genStatus, generateMutation]);
const handleAddGenerated = useCallback(() => {
if (!genCandidates || genCandidates.length === 0) return;
// Filter out stages already in the session
const existingStageIds = new Set(
items.map((i) => i.deliverableStage.id)
);
const newItems = genCandidates.filter(
(c) => !existingStageIds.has(c.deliverableStageId)
);
if (newItems.length === 0) {
toast.info("All matching items are already in the session");
setGenerateOpen(false);
return;
}
addItemsMutation.mutate(
newItems.map((c) => ({
deliverableStageId: c.deliverableStageId,
revisionId: c.revisionId,
})),
{
onSuccess: () => {
toast.success(`Added ${newItems.length} items`);
setGenerateOpen(false);
setGenCandidates(null);
setGenProjectId("");
setGenStatus("");
},
onError: (err) =>
toast.error("Failed to add items", { description: err.message }),
}
);
}, [genCandidates, items, addItemsMutation]);
// ── Thumbnail helper ────────────────────────────────────────────────────
const getThumbnail = (item: SessionItem) => {
const rev = item.deliverableStage.revisions?.[0];
if (!rev?.attachments) return null;
const att = rev.attachments as any;
const img = att.currentImage ?? att.referenceImage;
return img?.url ?? null;
};
// ── Render ──────────────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="space-y-2 px-4 py-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-14 w-full rounded-lg" />
))}
</div>
);
}
return (
<div className="flex flex-col">
{/* ── Toolbar ────────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Session Items ({items.length})
</span>
<div className="flex items-center gap-1.5">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
onClick={() => setGenerateOpen(true)}
>
<Wand2 className="mr-1 h-3 w-3" />
Auto-Fill
</Button>
</div>
</div>
{/* ── Item list ──────────────────────────────────────────── */}
{items.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<Plus className="h-8 w-8 text-[var(--muted-foreground)]/30" />
<p className="text-xs text-[var(--muted-foreground)]">
No items in this session yet
</p>
<p className="max-w-xs text-[10px] text-[var(--muted-foreground)]/60">
Use Auto-Fill to add deliverable stages from a project, or add them
individually from the deliverable review page.
</p>
<Button
size="sm"
variant="outline"
className="mt-2 h-7 text-xs"
onClick={() => setGenerateOpen(true)}
>
<Wand2 className="mr-1.5 h-3 w-3" />
Auto-Fill from Project
</Button>
</div>
)}
<div className="flex-1 overflow-y-auto">
{items.map((item, index) => {
const thumb = getThumbnail(item);
const stage = item.deliverableStage;
const deliverable = stage.deliverable;
const artists = stage.assignments?.map((a) => a.user.name).join(", ");
return (
<div
key={item.id}
className={cn(
"group flex items-center gap-3 border-b px-4 py-2.5 transition-colors hover:bg-[var(--background)]/50",
item.decision === "APPROVED" &&
"bg-[var(--status-approved)]/5",
item.decision === "CHANGES_REQUESTED" &&
"bg-[var(--status-in-review)]/5"
)}
>
{/* Drag handle / order number */}
<div className="flex w-6 shrink-0 flex-col items-center gap-0.5">
<span className="text-[10px] font-mono text-[var(--muted-foreground)]">
{index + 1}
</span>
<GripVertical className="h-3 w-3 text-[var(--muted-foreground)]/40" />
</div>
{/* Thumbnail */}
<div className="h-10 w-14 shrink-0 overflow-hidden rounded border bg-[var(--muted)]/20">
{thumb ? (
<img
src={thumb}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-3.5 w-3.5 text-[var(--muted-foreground)]/30" />
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-xs font-medium">
{deliverable.name}
</span>
<span className="text-[10px] text-[var(--muted-foreground)]">
{stage.template.name}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2">
<span className="text-[10px] text-[var(--muted-foreground)]">
{deliverable.project.projectCode}
</span>
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
{artists && (
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
{artists}
</span>
)}
</div>
</div>
{/* Decision badge */}
{item.decision && (
<Badge
variant="secondary"
className={cn(
"shrink-0 text-[9px] uppercase",
item.decision === "APPROVED"
? "bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
: "bg-amber-500/10 text-amber-600"
)}
>
{item.decision === "APPROVED" ? "Approved" : "Changes"}
</Badge>
)}
{/* Move / Remove actions */}
<div className="flex items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
disabled={index === 0}
onClick={() => moveItem(index, -1)}
aria-label="Move up"
>
<ChevronDown className="h-3 w-3 rotate-180" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6"
disabled={index === items.length - 1}
onClick={() => moveItem(index, 1)}
aria-label="Move down"
>
<ChevronDown className="h-3 w-3" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-6 w-6 text-red-500 hover:text-red-600"
onClick={() => handleRemoveItem(item.id)}
aria-label="Remove"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
})}
</div>
{/* ── Auto-fill dialog ───────────────────────────────────── */}
<Dialog open={generateOpen} onOpenChange={setGenerateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="font-heading text-sm font-semibold">
Auto-Fill from Project
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Project
</label>
<Select value={genProjectId} onValueChange={setGenProjectId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select a project..." />
</SelectTrigger>
<SelectContent>
{projects.map((p: any) => (
<SelectItem key={p.id} value={p.id}>
{p.projectCode} {p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
Stage Status (optional)
</label>
<Select value={genStatus} onValueChange={setGenStatus}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Any status</SelectItem>
{STAGE_STATUSES.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!genCandidates && (
<Button
size="sm"
className="w-full"
onClick={handleGenerate}
disabled={generateMutation.isPending || !genProjectId}
>
{generateMutation.isPending ? "Searching..." : "Find Matching Stages"}
</Button>
)}
{genCandidates && (
<div className="space-y-2">
<Separator />
<p className="text-xs text-[var(--muted-foreground)]">
Found {genCandidates.length} matching{" "}
{genCandidates.length === 1 ? "stage" : "stages"}
</p>
<div className="max-h-48 space-y-1 overflow-y-auto">
{genCandidates.map((c: any) => (
<div
key={c.deliverableStageId}
className="rounded border px-2 py-1.5 text-xs"
>
{c.label}
</div>
))}
</div>
{genCandidates.length === 0 && (
<p className="text-xs text-[var(--muted-foreground)]">
No stages match the selected filters.
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button
variant="ghost"
size="sm"
onClick={() => {
setGenerateOpen(false);
setGenCandidates(null);
}}
>
Cancel
</Button>
{genCandidates && genCandidates.length > 0 && (
<Button
size="sm"
onClick={handleAddGenerated}
disabled={addItemsMutation.isPending}
>
{addItemsMutation.isPending
? "Adding..."
: `Add ${genCandidates.length} Items`}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,569 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Check,
RotateCcw,
MessageSquare,
X,
Maximize2,
Minimize2,
Image as ImageIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Separator } from "@/components/ui/separator";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { ImageViewer, type ImageViewerState } from "@/components/review/image-viewer";
import { AnnotationLayer } from "@/components/review/annotation-layer";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
useRecordDecision,
useClearDecision,
} from "@/hooks/use-review-sessions";
// ── Types ─────────────────────────────────────────────────────────────────
interface SessionItem {
id: string;
sortOrder: number;
decision: string | null;
decisionNote: string | null;
decidedBy: { id: string; name: string; image: string | null } | null;
decidedAt: string | null;
revisionId: string | null;
deliverableStage: {
id: string;
status: string;
template: { id: string; name: string; slug: string; order: number };
deliverable: {
id: string;
name: string;
priority: string;
project: { id: string; name: string; projectCode: string };
};
revisions: {
id: string;
roundNumber: number;
status: string;
attachments: any;
}[];
assignments: {
user: { id: string; name: string; image: string | null };
}[];
};
}
interface SessionPresenterProps {
sessionId: string;
items: SessionItem[];
sessionName: string;
onExit: () => void;
}
// ── Component ─────────────────────────────────────────────────────────────
export function SessionPresenter({
sessionId,
items,
sessionName,
onExit,
}: SessionPresenterProps) {
const [currentIndex, setCurrentIndex] = useState(0);
const [decisionNote, setDecisionNote] = useState("");
const [showNote, setShowNote] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const recordDecision = useRecordDecision(sessionId);
const clearDecision = useClearDecision(sessionId);
const currentItem = items[currentIndex] ?? null;
// ── Image URL ───────────────────────────────────────────────────────────
const imageUrl = useMemo(() => {
if (!currentItem) return null;
const rev = currentItem.deliverableStage.revisions?.[0];
if (!rev?.attachments) return null;
const att = rev.attachments as any;
return att.currentImage?.url ?? att.referenceImage?.url ?? null;
}, [currentItem]);
const revisionId = useMemo(() => {
if (!currentItem) return null;
return (
currentItem.revisionId ??
currentItem.deliverableStage.revisions?.[0]?.id ??
null
);
}, [currentItem]);
// ── Progress ────────────────────────────────────────────────────────────
const progress = useMemo(() => {
const decided = items.filter((i) => i.decision != null).length;
return { decided, total: items.length };
}, [items]);
// ── Navigation ──────────────────────────────────────────────────────────
const goTo = useCallback(
(index: number) => {
if (index >= 0 && index < items.length) {
setCurrentIndex(index);
setDecisionNote("");
setShowNote(false);
}
},
[items.length]
);
const goPrev = useCallback(() => goTo(currentIndex - 1), [currentIndex, goTo]);
const goNext = useCallback(() => goTo(currentIndex + 1), [currentIndex, goTo]);
// ── Decisions ───────────────────────────────────────────────────────────
const handleDecision = useCallback(
(decision: "APPROVED" | "CHANGES_REQUESTED") => {
if (!currentItem) return;
recordDecision.mutate(
{
itemId: currentItem.id,
decision,
decisionNote: decisionNote.trim() || undefined,
},
{
onSuccess: () => {
toast.success(
decision === "APPROVED" ? "Approved" : "Changes requested"
);
setDecisionNote("");
setShowNote(false);
// Auto-advance to next undecided item
const nextUndecided = items.findIndex(
(item, idx) => idx > currentIndex && item.decision == null
);
if (nextUndecided !== -1) {
goTo(nextUndecided);
} else if (currentIndex < items.length - 1) {
goNext();
}
},
onError: (err) =>
toast.error("Failed to record decision", {
description: err.message,
}),
}
);
},
[currentItem, currentIndex, decisionNote, recordDecision, items, goTo, goNext]
);
const handleClearDecision = useCallback(() => {
if (!currentItem) return;
clearDecision.mutate(currentItem.id, {
onSuccess: () => toast.success("Decision cleared"),
onError: (err) =>
toast.error("Failed", { description: err.message }),
});
}, [currentItem, clearDecision]);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement
)
return;
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
goPrev();
break;
case "ArrowRight":
e.preventDefault();
goNext();
break;
case "a":
e.preventDefault();
handleDecision("APPROVED");
break;
case "c":
e.preventDefault();
handleDecision("CHANGES_REQUESTED");
break;
case "Escape":
e.preventDefault();
onExit();
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [goPrev, goNext, handleDecision, onExit]);
// ── Fullscreen ──────────────────────────────────────────────────────────
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {});
setIsFullscreen(true);
} else {
document.exitFullscreen().catch(() => {});
setIsFullscreen(false);
}
}, []);
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener("fullscreenchange", handler);
return () => document.removeEventListener("fullscreenchange", handler);
}, []);
if (!currentItem) {
return (
<div className="flex h-full items-center justify-center text-[var(--muted-foreground)]">
No items to present.
</div>
);
}
const stage = currentItem.deliverableStage;
const deliverable = stage.deliverable;
const latestRev = stage.revisions?.[0];
return (
<div className="flex h-full flex-col bg-[var(--background)]">
{/* ── Top bar ──────────────────────────────────────────── */}
<div className="flex items-center justify-between border-b px-4 py-2">
{/* Left: session info + navigation */}
<div className="flex items-center gap-3">
<Button
size="sm"
variant="ghost"
className="h-7 text-xs"
onClick={onExit}
>
<X className="mr-1 h-3 w-3" />
Exit
</Button>
<Separator orientation="vertical" className="h-5" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{sessionName}
</span>
</div>
{/* Center: item navigation */}
<div className="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={currentIndex <= 0}
onClick={goPrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="font-mono text-xs text-[var(--muted-foreground)]">
{currentIndex + 1} / {items.length}
</span>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
disabled={currentIndex >= items.length - 1}
onClick={goNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-5" />
{/* Progress */}
<div className="flex items-center gap-1.5">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full rounded-full bg-[var(--primary)] transition-all"
style={{
width: `${progress.total > 0 ? (progress.decided / progress.total) * 100 : 0}%`,
}}
/>
</div>
<span className="text-[10px] text-[var(--muted-foreground)]">
{progress.decided}/{progress.total}
</span>
</div>
</div>
{/* Right: fullscreen toggle */}
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7"
onClick={toggleFullscreen}
>
{isFullscreen ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent className="text-xs">
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
</TooltipContent>
</Tooltip>
</div>
</div>
{/* ── Main content: viewer + info panel ────────────────── */}
<div className="flex flex-1 overflow-hidden">
{/* Image viewer */}
<div className="flex min-w-0 flex-1 flex-col">
{imageUrl ? (
<ImageViewer
src={imageUrl}
className="flex-1"
renderOverlay={(vs: ImageViewerState) => (
<AnnotationLayer
revisionId={revisionId}
stageId={stage.id}
zoom={vs.zoom}
panX={vs.panX}
panY={vs.panY}
containerWidth={vs.containerWidth}
containerHeight={vs.containerHeight}
imageDimensions={vs.imageDimensions}
readOnly
/>
)}
/>
) : (
<div className="flex flex-1 items-center justify-center bg-[var(--muted)]/10">
<div className="flex flex-col items-center gap-2 text-[var(--muted-foreground)]/40">
<ImageIcon className="h-12 w-12" />
<span className="text-xs">No image uploaded</span>
</div>
</div>
)}
</div>
{/* ── Right info panel ──────────────────────────────── */}
<div className="flex w-[320px] shrink-0 flex-col border-l bg-[var(--card)]">
{/* Item details */}
<div className="border-b px-4 py-3">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
{deliverable.project.projectCode}
</span>
</div>
<h2 className="mt-1 font-heading text-sm font-semibold">
{deliverable.name}
</h2>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-xs text-[var(--muted-foreground)]">
{stage.template.name}
</span>
<StageStatusBadge status={stage.status} className="text-[9px] px-1 py-0" />
</div>
{latestRev && (
<div className="mt-1 text-[10px] text-[var(--muted-foreground)]">
Round {latestRev.roundNumber} {latestRev.status.replace("_", " ")}
</div>
)}
{stage.assignments?.length > 0 && (
<div className="mt-2 flex items-center gap-1">
<span className="text-[10px] text-[var(--muted-foreground)]">
Assigned:
</span>
{stage.assignments.map((a) => (
<Badge
key={a.user.id}
variant="outline"
className="text-[10px] px-1.5 py-0"
>
{a.user.name}
</Badge>
))}
</div>
)}
</div>
{/* Decision area */}
<div className="flex flex-1 flex-col justify-end">
{/* Current decision display */}
{currentItem.decision && (
<div
className={cn(
"mx-4 mb-3 rounded-lg px-3 py-2",
currentItem.decision === "APPROVED"
? "bg-[var(--status-approved)]/10"
: "bg-amber-500/10"
)}
>
<div className="flex items-center justify-between">
<Badge
variant="secondary"
className={cn(
"text-[10px] uppercase",
currentItem.decision === "APPROVED"
? "bg-[var(--status-approved)]/20 text-[var(--status-approved)]"
: "bg-amber-500/20 text-amber-600"
)}
>
{currentItem.decision === "APPROVED"
? "Approved"
: "Changes Requested"}
</Badge>
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px]"
onClick={handleClearDecision}
>
Clear
</Button>
</div>
{currentItem.decisionNote && (
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
{currentItem.decisionNote}
</p>
)}
{currentItem.decidedBy && (
<p className="mt-1 text-[10px] text-[var(--muted-foreground)]">
by {currentItem.decidedBy.name}
</p>
)}
</div>
)}
{/* Decision buttons */}
<div className="border-t px-4 py-3">
{showNote && (
<div className="mb-2">
<Textarea
value={decisionNote}
onChange={(e) => setDecisionNote(e.target.value)}
placeholder="Add a note (optional)..."
className="resize-none text-xs"
rows={2}
autoFocus
/>
</div>
)}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-8 flex-1 text-xs text-amber-600 hover:bg-amber-500/10 hover:text-amber-700"
onClick={() => handleDecision("CHANGES_REQUESTED")}
disabled={recordDecision.isPending}
>
<RotateCcw className="mr-1.5 h-3 w-3" />
Changes
</Button>
<Button
size="sm"
className="h-8 flex-1 bg-[var(--status-approved)] text-xs text-white hover:bg-[var(--status-approved)]/90"
onClick={() => handleDecision("APPROVED")}
disabled={recordDecision.isPending}
>
<Check className="mr-1.5 h-3.5 w-3.5" />
Approve
</Button>
</div>
<div className="mt-1.5 flex justify-center">
<Button
size="sm"
variant="ghost"
className="h-6 text-[10px] text-[var(--muted-foreground)]"
onClick={() => setShowNote(!showNote)}
>
<MessageSquare className="mr-1 h-2.5 w-2.5" />
{showNote ? "Hide note" : "Add note"}
</Button>
</div>
<p className="mt-2 text-center text-[9px] text-[var(--muted-foreground)]/60">
Keyboard: A = approve, C = changes, &larr;&rarr; = navigate, Esc = exit
</p>
</div>
</div>
</div>
</div>
{/* ── Bottom thumbnail strip ───────────────────────────── */}
<div className="flex items-center gap-1 overflow-x-auto border-t px-3 py-1.5">
{items.map((item, idx) => {
const rev = item.deliverableStage.revisions?.[0];
const att = rev?.attachments as any;
const thumbUrl =
att?.currentImage?.url ?? att?.referenceImage?.url ?? null;
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<button
onClick={() => goTo(idx)}
className={cn(
"relative h-10 w-14 shrink-0 overflow-hidden rounded border transition-all",
idx === currentIndex
? "border-[var(--primary)] ring-1 ring-[var(--primary)]"
: "border-transparent opacity-60 hover:opacity-100",
item.decision === "APPROVED" &&
idx !== currentIndex &&
"border-[var(--status-approved)]/50",
item.decision === "CHANGES_REQUESTED" &&
idx !== currentIndex &&
"border-amber-500/50"
)}
>
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-[var(--muted)]/20">
<ImageIcon className="h-3 w-3 text-[var(--muted-foreground)]/30" />
</div>
)}
{/* Decision dot */}
{item.decision && (
<div
className={cn(
"absolute right-0.5 top-0.5 h-2 w-2 rounded-full",
item.decision === "APPROVED"
? "bg-[var(--status-approved)]"
: "bg-amber-500"
)}
/>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs">
{item.deliverableStage.deliverable.name} {" "}
{item.deliverableStage.template.name}
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,160 @@
"use client";
import { useMemo } from "react";
import {
Check,
RotateCcw,
Clock,
Image as ImageIcon,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { StageStatusBadge } from "@/components/stages/stage-status-badge";
import { cn } from "@/lib/utils";
// ── Types ─────────────────────────────────────────────────────────────────
interface SessionItem {
id: string;
sortOrder: number;
decision: string | null;
decisionNote: string | null;
decidedBy: { id: string; name: string; image: string | null } | null;
deliverableStage: {
id: string;
status: string;
template: { id: string; name: string; order: number };
deliverable: {
id: string;
name: string;
priority: string;
project: { id: string; name: string; projectCode: string };
};
revisions: { id: string; roundNumber: number; attachments: any }[];
assignments: { user: { id: string; name: string; image: string | null } }[];
};
}
interface SessionSummaryProps {
items: SessionItem[];
onItemClick?: (index: number) => void;
}
// ── Component ─────────────────────────────────────────────────────────────
export function SessionSummary({ items, onItemClick }: SessionSummaryProps) {
const stats = useMemo(() => {
const approved = items.filter((i) => i.decision === "APPROVED").length;
const changes = items.filter(
(i) => i.decision === "CHANGES_REQUESTED"
).length;
const pending = items.filter((i) => i.decision == null).length;
return { approved, changes, pending, total: items.length };
}, [items]);
return (
<div>
{/* ── Stats strip ──────────────────────────────────────── */}
<div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--status-approved)]/10">
<Check className="h-3 w-3 text-[var(--status-approved)]" />
</div>
<span className="text-xs font-medium">
{stats.approved} Approved
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/10">
<RotateCcw className="h-3 w-3 text-amber-600" />
</div>
<span className="text-xs font-medium">
{stats.changes} Changes
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-[var(--muted)]/50">
<Clock className="h-3 w-3 text-[var(--muted-foreground)]" />
</div>
<span className="text-xs font-medium">
{stats.pending} Pending
</span>
</div>
</div>
{/* ── Thumbnail grid ───────────────────────────────────── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{items.map((item, idx) => {
const rev = item.deliverableStage.revisions?.[0];
const att = rev?.attachments as any;
const thumbUrl =
att?.currentImage?.url ?? att?.referenceImage?.url ?? null;
const stage = item.deliverableStage;
return (
<button
key={item.id}
onClick={() => onItemClick?.(idx)}
className={cn(
"group relative overflow-hidden rounded-lg border bg-[var(--card)] text-left transition-all hover:shadow-md",
item.decision === "APPROVED" &&
"border-[var(--status-approved)]/40",
item.decision === "CHANGES_REQUESTED" &&
"border-amber-500/40",
item.decision == null && "border-[var(--border)]"
)}
>
{/* Thumbnail */}
<div className="aspect-[4/3] overflow-hidden bg-[var(--muted)]/10">
{thumbUrl ? (
<img
src={thumbUrl}
alt=""
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
) : (
<div className="flex h-full w-full items-center justify-center">
<ImageIcon className="h-6 w-6 text-[var(--muted-foreground)]/20" />
</div>
)}
</div>
{/* Decision overlay badge */}
{item.decision && (
<div className="absolute right-1 top-1">
<Badge
variant="secondary"
className={cn(
"text-[9px] uppercase shadow-sm",
item.decision === "APPROVED"
? "bg-[var(--status-approved)] text-white"
: "bg-amber-500 text-white"
)}
>
{item.decision === "APPROVED" ? (
<Check className="mr-0.5 h-2.5 w-2.5" />
) : (
<RotateCcw className="mr-0.5 h-2.5 w-2.5" />
)}
{item.decision === "APPROVED" ? "OK" : "Changes"}
</Badge>
</div>
)}
{/* Info */}
<div className="px-2 py-1.5">
<p className="truncate text-[11px] font-medium">
{stage.deliverable.name}
</p>
<div className="mt-0.5 flex items-center gap-1">
<span className="truncate text-[10px] text-[var(--muted-foreground)]">
{stage.template.name}
</span>
</div>
</div>
</button>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,157 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border p-6 shadow-[var(--shadow-lg)] duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

View file

@ -0,0 +1,178 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type {
CreateReviewSessionInput,
UpdateReviewSessionInput,
RecordDecisionInput,
} from "@/lib/validators/review-session";
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, init);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
// ─── Queries ────────────────────────────────────────────
export function useReviewSessions(status?: string) {
const params = new URLSearchParams();
if (status) params.set("status", status);
return useQuery({
queryKey: ["review-sessions", status ?? "all"],
queryFn: () =>
fetchJson<any[]>(
`/api/reviews${params.toString() ? `?${params}` : ""}`
),
});
}
export function useReviewSession(sessionId: string) {
return useQuery({
queryKey: ["review-session", sessionId],
queryFn: () => fetchJson<any>(`/api/reviews/${sessionId}`),
enabled: !!sessionId,
});
}
// ─── Mutations ──────────────────────────────────────────
export function useCreateReviewSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateReviewSessionInput) =>
fetchJson("/api/reviews", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-sessions"] });
},
});
}
export function useUpdateReviewSession(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateReviewSessionInput) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
queryClient.invalidateQueries({ queryKey: ["review-sessions"] });
},
});
}
export function useDeleteReviewSession() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (sessionId: string) =>
fetchJson(`/api/reviews/${sessionId}`, { method: "DELETE" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-sessions"] });
},
});
}
export function useAddSessionItems(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (items: { deliverableStageId: string; revisionId?: string }[]) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "add-items", items }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
},
});
}
export function useRemoveSessionItem(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "remove-item", itemId }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
},
});
}
export function useReorderSessionItems(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemIds: string[]) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "reorder", itemIds }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
},
});
}
export function useRecordDecision(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: RecordDecisionInput) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "decide", ...data }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
},
});
}
export function useClearDecision(sessionId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (itemId: string) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "clear-decision", itemId }),
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["review-session", sessionId] });
},
});
}
export function useGenerateSessionItems(sessionId: string) {
return useMutation({
mutationFn: (data: { projectId: string; stageStatus?: string; stageTemplateId?: string }) =>
fetchJson(`/api/reviews/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "generate", ...data }),
}),
});
}

View file

@ -0,0 +1,264 @@
import { prisma } from "@/lib/prisma";
import type {
CreateReviewSessionInput,
UpdateReviewSessionInput,
AddSessionItemsInput,
ReorderSessionItemsInput,
RecordDecisionInput,
GenerateSessionItemsInput,
} from "@/lib/validators/review-session";
const SESSION_INCLUDE = {
createdBy: { select: { id: true, name: true, email: true, image: true } },
items: {
orderBy: { sortOrder: "asc" as const },
include: {
deliverableStage: {
include: {
template: { select: { id: true, name: true, slug: true, order: true } },
deliverable: {
select: {
id: true,
name: true,
priority: true,
project: { select: { id: true, name: true, projectCode: true } },
},
},
revisions: {
orderBy: { roundNumber: "desc" as const },
take: 1,
select: {
id: true,
roundNumber: true,
status: true,
attachments: true,
},
},
assignments: {
include: {
user: { select: { id: true, name: true, image: true } },
},
},
},
},
revision: {
select: {
id: true,
roundNumber: true,
status: true,
attachments: true,
},
},
decidedBy: { select: { id: true, name: true, image: true } },
},
},
} as const;
const SESSION_LIST_INCLUDE = {
createdBy: { select: { id: true, name: true, image: true } },
_count: { select: { items: true } },
items: {
select: { decision: true },
},
} as const;
/**
* List all review sessions for an organization.
*/
export async function listReviewSessions(
organizationId: string,
filters?: { status?: string }
) {
const where: any = { organizationId };
if (filters?.status) where.status = filters.status;
return prisma.reviewSession.findMany({
where,
include: SESSION_LIST_INCLUDE,
orderBy: { updatedAt: "desc" },
});
}
/**
* Get a single review session with all items and related data.
*/
export async function getReviewSession(sessionId: string) {
return prisma.reviewSession.findUnique({
where: { id: sessionId },
include: SESSION_INCLUDE,
});
}
/**
* Create a new review session.
*/
export async function createReviewSession(
organizationId: string,
userId: string,
input: CreateReviewSessionInput
) {
return prisma.reviewSession.create({
data: {
name: input.name,
description: input.description ?? null,
status: "DRAFT",
createdById: userId,
organizationId,
},
include: SESSION_INCLUDE,
});
}
/**
* Update a review session (name, description, status).
*/
export async function updateReviewSession(
sessionId: string,
input: UpdateReviewSessionInput
) {
return prisma.reviewSession.update({
where: { id: sessionId },
data: {
...(input.name !== undefined && { name: input.name }),
...(input.description !== undefined && { description: input.description }),
...(input.status !== undefined && { status: input.status as any }),
},
include: SESSION_INCLUDE,
});
}
/**
* Delete a review session.
*/
export async function deleteReviewSession(sessionId: string) {
await prisma.reviewSession.delete({ where: { id: sessionId } });
return { ok: true };
}
/**
* Add items to a review session.
*/
export async function addSessionItems(
sessionId: string,
input: AddSessionItemsInput
) {
// Get current max sort order
const maxSort = await prisma.reviewSessionItem.aggregate({
where: { sessionId },
_max: { sortOrder: true },
});
let nextSort = (maxSort._max.sortOrder ?? 0) + 1;
const items = await prisma.$transaction(
input.items.map((item) =>
prisma.reviewSessionItem.create({
data: {
sessionId,
deliverableStageId: item.deliverableStageId,
revisionId: item.revisionId ?? null,
sortOrder: nextSort++,
},
})
)
);
return items;
}
/**
* Remove an item from a review session.
*/
export async function removeSessionItem(itemId: string) {
await prisma.reviewSessionItem.delete({ where: { id: itemId } });
return { ok: true };
}
/**
* Reorder items in a review session.
*/
export async function reorderSessionItems(
sessionId: string,
input: ReorderSessionItemsInput
) {
await prisma.$transaction(
input.itemIds.map((id, index) =>
prisma.reviewSessionItem.update({
where: { id },
data: { sortOrder: index + 1 },
})
)
);
return { ok: true };
}
/**
* Record a decision on a session item.
*/
export async function recordDecision(
userId: string,
input: RecordDecisionInput
) {
return prisma.reviewSessionItem.update({
where: { id: input.itemId },
data: {
decision: input.decision,
decisionNote: input.decisionNote ?? null,
decidedById: userId,
decidedAt: new Date(),
},
include: {
decidedBy: { select: { id: true, name: true, image: true } },
},
});
}
/**
* Clear a decision from a session item.
*/
export async function clearDecision(itemId: string) {
return prisma.reviewSessionItem.update({
where: { id: itemId },
data: {
decision: null,
decisionNote: null,
decidedById: null,
decidedAt: null,
},
});
}
/**
* Generate session items from a project filtered by stage status and/or template.
* Returns the deliverable stage IDs that match.
*/
export async function generateSessionItems(input: GenerateSessionItemsInput) {
const where: any = {
deliverable: { projectId: input.projectId },
};
if (input.stageStatus) where.status = input.stageStatus;
if (input.stageTemplateId) where.templateId = input.stageTemplateId;
const stages = await prisma.deliverableStage.findMany({
where,
select: {
id: true,
template: { select: { name: true, order: true } },
deliverable: { select: { name: true } },
revisions: {
orderBy: { roundNumber: "desc" },
take: 1,
select: { id: true },
},
},
orderBy: [
{ deliverable: { name: "asc" } },
{ template: { order: "asc" } },
],
});
return stages.map((s) => ({
deliverableStageId: s.id,
revisionId: s.revisions[0]?.id ?? undefined,
label: `${s.deliverable.name}${s.template.name}`,
}));
}

View file

@ -0,0 +1,52 @@
import { z } from "zod/v4";
export const createReviewSessionSchema = z.object({
name: z.string().min(1, "Session name is required"),
description: z.string().optional(),
});
export type CreateReviewSessionInput = z.infer<typeof createReviewSessionSchema>;
export const updateReviewSessionSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().nullable().optional(),
status: z.enum(["DRAFT", "IN_PROGRESS", "COMPLETED"]).optional(),
});
export type UpdateReviewSessionInput = z.infer<typeof updateReviewSessionSchema>;
export const addSessionItemsSchema = z.object({
items: z.array(
z.object({
deliverableStageId: z.string().min(1),
revisionId: z.string().optional(),
})
).min(1, "At least one item is required"),
});
export type AddSessionItemsInput = z.infer<typeof addSessionItemsSchema>;
export const reorderSessionItemsSchema = z.object({
itemIds: z.array(z.string().min(1)).min(1),
});
export type ReorderSessionItemsInput = z.infer<typeof reorderSessionItemsSchema>;
export const recordDecisionSchema = z.object({
itemId: z.string().min(1, "Item ID is required"),
decision: z.enum(["APPROVED", "CHANGES_REQUESTED"]),
decisionNote: z.string().optional(),
});
export type RecordDecisionInput = z.infer<typeof recordDecisionSchema>;
export const generateSessionItemsSchema = z.object({
projectId: z.string().min(1, "Project ID is required"),
stageStatus: z.enum([
"BLOCKED", "NOT_STARTED", "IN_PROGRESS", "IN_REVIEW",
"CHANGES_REQUESTED", "APPROVED", "DELIVERED", "SKIPPED",
]).optional(),
stageTemplateId: z.string().optional(),
});
export type GenerateSessionItemsInput = z.infer<typeof generateSessionItemsSchema>;