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:
parent
db82eb4fed
commit
6f5cbc2f1f
16 changed files with 2834 additions and 14 deletions
30
ROADMAP.md
30
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 |
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
311
src/app/(app)/reviews/[sessionId]/page.tsx
Normal file
311
src/app/(app)/reviews/[sessionId]/page.tsx
Normal 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} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
275
src/app/(app)/reviews/page.tsx
Normal file
275
src/app/(app)/reviews/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
142
src/app/api/reviews/[sessionId]/route.ts
Normal file
142
src/app/api/reviews/[sessionId]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
src/app/api/reviews/route.ts
Normal file
51
src/app/api/reviews/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
104
src/components/review/create-session-dialog.tsx
Normal file
104
src/components/review/create-session-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
478
src/components/review/session-builder.tsx
Normal file
478
src/components/review/session-builder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
569
src/components/review/session-presenter.tsx
Normal file
569
src/components/review/session-presenter.tsx
Normal 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, ←→ = 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>
|
||||
);
|
||||
}
|
||||
160
src/components/review/session-summary.tsx
Normal file
160
src/components/review/session-summary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
src/components/ui/alert-dialog.tsx
Normal file
157
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
178
src/hooks/use-review-sessions.ts
Normal file
178
src/hooks/use-review-sessions.ts
Normal 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 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
264
src/lib/services/review-session-service.ts
Normal file
264
src/lib/services/review-session-service.ts
Normal 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}`,
|
||||
}));
|
||||
}
|
||||
52
src/lib/validators/review-session.ts
Normal file
52
src/lib/validators/review-session.ts
Normal 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>;
|
||||
Loading…
Add table
Reference in a new issue