refactor: simplify feedback from 4-level severity to action item / info callout
Replace FeedbackSeverity enum (Critical/Major/Minor/Suggestion) with a simple isActionItem boolean. Annotations default to action items (things the artist must fix). Any item can be toggled to an info callout (context that doesn't need action). Progress bar and carry-forward only count action items. Screenshot paste limited to 5MB with user notification. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
05061baf26
commit
db82eb4fed
11 changed files with 212 additions and 217 deletions
29
ROADMAP.md
29
ROADMAP.md
|
|
@ -247,7 +247,7 @@ enum AnnotationType { RECTANGLE ELLIPSE ARROW FREEHAND TEXT PIN SCREENSHOT
|
|||
|
||||
---
|
||||
|
||||
#### A4 — Revision History Timeline `[ ]`
|
||||
#### A4 — Revision History Timeline `[x]`
|
||||
|
||||
A collapsible sidebar panel in the review page showing the full version history for a deliverable stage. The connective tissue between annotations, comparison, and feedback — provides the longitudinal view across all rounds.
|
||||
|
||||
|
|
@ -277,28 +277,26 @@ A collapsible sidebar panel in the review page showing the full version history
|
|||
|
||||
---
|
||||
|
||||
#### A5 — Feedback Checklist (Artist Action Items) `[ ]`
|
||||
#### A5 — Feedback Checklist (Artist Action Items) `[x]`
|
||||
|
||||
Every annotation and actionable comment becomes a structured to-do item on a checklist for the assigned artist. Closes the feedback-to-fix loop.
|
||||
|
||||
**The feedback loop:**
|
||||
1. Reviewer draws annotation or posts actionable comment
|
||||
2. System auto-creates a FeedbackItem linked to the annotation/comment
|
||||
3. Artist sees checklist — organized by severity, with direct links to annotation on the image
|
||||
3. Artist sees checklist — action items with direct links to annotations on the image
|
||||
4. Artist works through items — checks each off with optional resolution note
|
||||
5. Artist submits new revision — unchecked items carry forward with a warning
|
||||
5. Artist submits new revision — unchecked action items carry forward with a warning
|
||||
6. Reviewer verifies — can confirm resolution or reopen
|
||||
|
||||
**Where the checklist appears (3 locations):**
|
||||
1. **Review page — Feedback Panel** (primary): full checklist with severity indicators, thumbnail crops of annotated regions, resolve/reopen actions, progress bar
|
||||
2. **My Work page** — feedback badge per assignment ("5 open items"), expandable inline checklist, deep-link to review page
|
||||
3. **Stage card on deliverable page** — compact badge ("4/7 resolved"), color-coded by severity
|
||||
**Two item types (simplified from 4-level severity):**
|
||||
- **Action Item** (default) — something the artist needs to fix. Has checkbox, can be resolved/verified.
|
||||
- **Info Callout** — context or reference that doesn't require action (e.g., "FYI the client prefers warmer tones"). No checkbox. Can be toggled from action item and vice versa.
|
||||
|
||||
**Severity levels:**
|
||||
- **Critical** — must fix, blocks approval
|
||||
- **Major** — should fix, significant quality issue
|
||||
- **Minor** — nice to fix, small quality issue
|
||||
- **Suggestion** — optional improvement
|
||||
**Where the checklist appears (3 locations):**
|
||||
1. **Review page — Feedback Panel** (primary): full checklist with action items first, then info callouts. Progress bar counts only action items. Filter by type and status.
|
||||
2. **My Work page** — feedback badge per assignment ("5 open items")
|
||||
3. **Stage card on deliverable page** — compact badge ("4/7 resolved") for action items
|
||||
|
||||
**New data model:**
|
||||
```prisma
|
||||
|
|
@ -313,7 +311,7 @@ model FeedbackItem {
|
|||
commentId String?
|
||||
comment Comment? @relation(...)
|
||||
summary String
|
||||
severity FeedbackSeverity @default(MAJOR)
|
||||
isActionItem Boolean @default(true)
|
||||
status FeedbackStatus @default(OPEN)
|
||||
sortOrder Int @default(0)
|
||||
assignedToId String?
|
||||
|
|
@ -329,8 +327,7 @@ model FeedbackItem {
|
|||
@@map("feedback_items")
|
||||
}
|
||||
|
||||
enum FeedbackSeverity { CRITICAL MAJOR MINOR SUGGESTION }
|
||||
enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED }
|
||||
enum FeedbackStatus { OPEN IN_PROGRESS RESOLVED VERIFIED REOPENED }
|
||||
```
|
||||
|
||||
**Key files:**
|
||||
|
|
|
|||
|
|
@ -774,8 +774,7 @@ model FeedbackItem {
|
|||
commentId String?
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: SetNull)
|
||||
summary String
|
||||
isActionItem Boolean @default(true)
|
||||
severity FeedbackSeverity @default(MAJOR)
|
||||
isActionItem Boolean @default(true) // true = action item (must fix), false = info callout
|
||||
status FeedbackStatus @default(OPEN)
|
||||
sortOrder Int @default(0)
|
||||
assignedToId String?
|
||||
|
|
@ -802,9 +801,6 @@ model FeedbackItem {
|
|||
@@map("feedback_items")
|
||||
}
|
||||
|
||||
enum FeedbackSeverity {
|
||||
CRITICAL
|
||||
MAJOR
|
||||
MINOR
|
||||
SUGGESTION
|
||||
}
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type Params = { params: Promise<{ stageId: string }> };
|
||||
|
||||
// GET /api/stages/:stageId/feedback
|
||||
// Query params: ?revisionId=&status=&severity=&summary=true
|
||||
// Query params: ?revisionId=&status=&isActionItem=true|false&summary=true
|
||||
export async function GET(request: Request, { params }: Params) {
|
||||
const { error } = await getAuthSession();
|
||||
if (error) return error;
|
||||
|
|
@ -20,7 +20,9 @@ export async function GET(request: Request, { params }: Params) {
|
|||
const url = new URL(request.url);
|
||||
const revisionId = url.searchParams.get("revisionId") ?? undefined;
|
||||
const status = url.searchParams.get("status") ?? undefined;
|
||||
const severity = url.searchParams.get("severity") ?? undefined;
|
||||
const isActionItemParam = url.searchParams.get("isActionItem");
|
||||
const isActionItem =
|
||||
isActionItemParam === "true" ? true : isActionItemParam === "false" ? false : undefined;
|
||||
const summaryOnly = url.searchParams.get("summary") === "true";
|
||||
|
||||
if (summaryOnly) {
|
||||
|
|
@ -31,7 +33,7 @@ export async function GET(request: Request, { params }: Params) {
|
|||
const items = await listFeedbackItems(stageId, {
|
||||
revisionId,
|
||||
status,
|
||||
severity,
|
||||
isActionItem,
|
||||
});
|
||||
return NextResponse.json(items);
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -5,22 +5,14 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
ClipboardList,
|
||||
Filter,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useFeedbackItems,
|
||||
useUpdateFeedback,
|
||||
useResolveFeedback,
|
||||
useVerifyFeedback,
|
||||
useReopenFeedback,
|
||||
|
|
@ -40,11 +32,9 @@ interface FeedbackChecklistProps {
|
|||
}) => void;
|
||||
}
|
||||
|
||||
type SeverityFilter = "ALL" | "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION";
|
||||
type TypeFilter = "ALL" | "ACTION" | "INFO";
|
||||
type StatusFilter = "ALL" | "OPEN" | "RESOLVED";
|
||||
|
||||
const SEVERITY_ORDER = ["CRITICAL", "MAJOR", "MINOR", "SUGGESTION"] as const;
|
||||
|
||||
export function FeedbackChecklist({
|
||||
stageId,
|
||||
revisionId,
|
||||
|
|
@ -52,16 +42,18 @@ export function FeedbackChecklist({
|
|||
onAnnotationClick,
|
||||
}: FeedbackChecklistProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>("ALL");
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("ALL");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("ALL");
|
||||
|
||||
const { data: items = [], isLoading } = useFeedbackItems(stageId, revisionId);
|
||||
const updateMutation = useUpdateFeedback(stageId);
|
||||
const resolveMutation = useResolveFeedback(stageId);
|
||||
const verifyMutation = useVerifyFeedback(stageId);
|
||||
const reopenMutation = useReopenFeedback(stageId);
|
||||
const deleteMutation = useDeleteFeedback(stageId);
|
||||
|
||||
const isPending =
|
||||
updateMutation.isPending ||
|
||||
resolveMutation.isPending ||
|
||||
verifyMutation.isPending ||
|
||||
reopenMutation.isPending ||
|
||||
|
|
@ -71,8 +63,10 @@ export function FeedbackChecklist({
|
|||
const filteredItems = useMemo(() => {
|
||||
let result = [...items];
|
||||
|
||||
if (severityFilter !== "ALL") {
|
||||
result = result.filter((i: any) => i.severity === severityFilter);
|
||||
if (typeFilter === "ACTION") {
|
||||
result = result.filter((i: any) => i.isActionItem);
|
||||
} else if (typeFilter === "INFO") {
|
||||
result = result.filter((i: any) => !i.isActionItem);
|
||||
}
|
||||
|
||||
if (statusFilter === "OPEN") {
|
||||
|
|
@ -89,23 +83,25 @@ export function FeedbackChecklist({
|
|||
}
|
||||
|
||||
return result;
|
||||
}, [items, severityFilter, statusFilter]);
|
||||
}, [items, typeFilter, statusFilter]);
|
||||
|
||||
// Group by severity
|
||||
const groupedItems = useMemo(() => {
|
||||
const groups: Record<string, any[]> = {};
|
||||
for (const sev of SEVERITY_ORDER) {
|
||||
const group = filteredItems.filter((i: any) => i.severity === sev);
|
||||
if (group.length > 0) groups[sev] = group;
|
||||
}
|
||||
return groups;
|
||||
}, [filteredItems]);
|
||||
// Separate action items and info callouts
|
||||
const actionItems = useMemo(
|
||||
() => filteredItems.filter((i: any) => i.isActionItem),
|
||||
[filteredItems]
|
||||
);
|
||||
const infoItems = useMemo(
|
||||
() => filteredItems.filter((i: any) => !i.isActionItem),
|
||||
[filteredItems]
|
||||
);
|
||||
|
||||
// Stats
|
||||
const totalCount = items.length;
|
||||
const resolvedCount = items.filter(
|
||||
// Stats (only count action items for progress)
|
||||
const allActionItems = items.filter((i: any) => i.isActionItem);
|
||||
const totalCount = allActionItems.length;
|
||||
const resolvedCount = allActionItems.filter(
|
||||
(i: any) => i.status === "RESOLVED" || i.status === "VERIFIED"
|
||||
).length;
|
||||
const infoCount = items.filter((i: any) => !i.isActionItem).length;
|
||||
|
||||
const handleResolve = (itemId: string, resolutionNote?: string) => {
|
||||
resolveMutation.mutate(
|
||||
|
|
@ -134,7 +130,14 @@ export function FeedbackChecklist({
|
|||
});
|
||||
};
|
||||
|
||||
const hasActiveFilters = severityFilter !== "ALL" || statusFilter !== "ALL";
|
||||
const handleToggleType = (itemId: string, isActionItem: boolean) => {
|
||||
updateMutation.mutate(
|
||||
{ itemId, data: { isActionItem } },
|
||||
{
|
||||
onError: (err) => toast.error("Failed to update", { description: err.message }),
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
|
|
@ -152,7 +155,7 @@ export function FeedbackChecklist({
|
|||
<div className="flex items-center gap-2">
|
||||
<ClipboardList className="h-3.5 w-3.5 text-[var(--primary)]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Feedback Checklist
|
||||
Feedback
|
||||
</span>
|
||||
{totalCount > 0 && (
|
||||
<Badge
|
||||
|
|
@ -167,6 +170,14 @@ export function FeedbackChecklist({
|
|||
{resolvedCount}/{totalCount}
|
||||
</Badge>
|
||||
)}
|
||||
{infoCount > 0 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1.5 text-[9px] tabular-nums text-[var(--muted-foreground)]"
|
||||
>
|
||||
{infoCount} info
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{collapsed ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-[var(--muted-foreground)]" />
|
||||
|
|
@ -177,7 +188,7 @@ export function FeedbackChecklist({
|
|||
|
||||
{!collapsed && (
|
||||
<div className="px-3 pb-3">
|
||||
{/* Progress bar */}
|
||||
{/* Progress bar (action items only) */}
|
||||
{totalCount > 0 && (
|
||||
<FeedbackProgressBar
|
||||
resolved={resolvedCount}
|
||||
|
|
@ -187,7 +198,7 @@ export function FeedbackChecklist({
|
|||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{totalCount > 0 && (
|
||||
{items.length > 0 && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
{/* Status filter */}
|
||||
<div className="flex rounded-md border">
|
||||
|
|
@ -207,62 +218,35 @@ export function FeedbackChecklist({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Severity filter */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
{/* Type filter */}
|
||||
<div className="flex rounded-md border">
|
||||
{(["ALL", "ACTION", "INFO"] as TypeFilter[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
className={cn(
|
||||
"h-5 gap-1 px-1.5 text-[10px]",
|
||||
severityFilter !== "ALL" && "border-[var(--primary)] text-[var(--primary)]"
|
||||
"px-2 py-0.5 text-[10px] font-medium transition-colors",
|
||||
typeFilter === t
|
||||
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]"
|
||||
)}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
>
|
||||
<Filter className="h-2.5 w-2.5" />
|
||||
{severityFilter === "ALL" ? "Severity" : severityFilter}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-32 p-1" align="start">
|
||||
{(["ALL", ...SEVERITY_ORDER] as SeverityFilter[]).map(
|
||||
(sev) => (
|
||||
<button
|
||||
key={sev}
|
||||
className={cn(
|
||||
"flex w-full items-center rounded px-2 py-1 text-xs hover:bg-[var(--muted)]",
|
||||
severityFilter === sev && "bg-[var(--muted)] font-medium"
|
||||
)}
|
||||
onClick={() => setSeverityFilter(sev)}
|
||||
>
|
||||
{sev === "ALL" ? "All severities" : sev}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
className="text-[10px] text-[var(--muted-foreground)] underline"
|
||||
onClick={() => {
|
||||
setSeverityFilter("ALL");
|
||||
setStatusFilter("ALL");
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
{t === "ALL" ? "All" : t === "ACTION" ? "Actions" : "Info"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Item list grouped by severity */}
|
||||
{/* Item list */}
|
||||
{isLoading ? (
|
||||
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
|
||||
Loading feedback items...
|
||||
</div>
|
||||
) : totalCount === 0 ? (
|
||||
) : items.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
|
||||
No feedback items yet. Annotations will automatically create
|
||||
checklist items.
|
||||
action items.
|
||||
</div>
|
||||
) : filteredItems.length === 0 ? (
|
||||
<div className="py-4 text-center text-xs text-[var(--muted-foreground)]">
|
||||
|
|
@ -270,13 +254,14 @@ export function FeedbackChecklist({
|
|||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(groupedItems).map(([severity, group]) => (
|
||||
<div key={severity}>
|
||||
{/* Action items first */}
|
||||
{actionItems.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
|
||||
{severity} ({group.length})
|
||||
Action Items ({actionItems.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{group.map((item: any) => (
|
||||
{actionItems.map((item: any) => (
|
||||
<FeedbackItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
|
|
@ -284,13 +269,38 @@ export function FeedbackChecklist({
|
|||
onVerify={handleVerify}
|
||||
onReopen={handleReopen}
|
||||
onDelete={handleDelete}
|
||||
onToggleType={handleToggleType}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
|
||||
{/* Info callouts */}
|
||||
{infoItems.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-[9px] font-bold uppercase tracking-widest text-[var(--muted-foreground)]">
|
||||
Info Callouts ({infoItems.length})
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{infoItems.map((item: any) => (
|
||||
<FeedbackItemCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
onResolve={handleResolve}
|
||||
onVerify={handleVerify}
|
||||
onReopen={handleReopen}
|
||||
onDelete={handleDelete}
|
||||
onToggleType={handleToggleType}
|
||||
onAnnotationClick={onAnnotationClick}
|
||||
isPending={isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import {
|
|||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Info,
|
||||
MapPin,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
ArrowRight,
|
||||
CircleDot,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -25,7 +27,7 @@ import { cn } from "@/lib/utils";
|
|||
interface FeedbackItemData {
|
||||
id: string;
|
||||
summary: string;
|
||||
severity: "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION";
|
||||
isActionItem: boolean;
|
||||
status: "OPEN" | "IN_PROGRESS" | "RESOLVED" | "VERIFIED" | "REOPENED";
|
||||
resolutionNote?: string | null;
|
||||
annotation?: {
|
||||
|
|
@ -47,6 +49,7 @@ interface FeedbackItemCardProps {
|
|||
onVerify: (itemId: string) => void;
|
||||
onReopen: (itemId: string) => void;
|
||||
onDelete: (itemId: string) => void;
|
||||
onToggleType?: (itemId: string, isActionItem: boolean) => void;
|
||||
onAnnotationClick?: (annotation: {
|
||||
id: string;
|
||||
imageX: number;
|
||||
|
|
@ -55,25 +58,6 @@ interface FeedbackItemCardProps {
|
|||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const SEVERITY_STYLES: Record<string, { badge: string; border: string }> = {
|
||||
CRITICAL: {
|
||||
badge: "bg-red-500/10 text-red-600 border-red-500/30",
|
||||
border: "border-l-red-500",
|
||||
},
|
||||
MAJOR: {
|
||||
badge: "bg-orange-500/10 text-orange-600 border-orange-500/30",
|
||||
border: "border-l-orange-500",
|
||||
},
|
||||
MINOR: {
|
||||
badge: "bg-yellow-500/10 text-yellow-600 border-yellow-500/30",
|
||||
border: "border-l-yellow-500",
|
||||
},
|
||||
SUGGESTION: {
|
||||
badge: "bg-blue-500/10 text-blue-600 border-blue-500/30",
|
||||
border: "border-l-blue-500",
|
||||
},
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
OPEN: "Open",
|
||||
IN_PROGRESS: "In Progress",
|
||||
|
|
@ -88,6 +72,7 @@ export function FeedbackItemCard({
|
|||
onVerify,
|
||||
onReopen,
|
||||
onDelete,
|
||||
onToggleType,
|
||||
onAnnotationClick,
|
||||
isPending,
|
||||
}: FeedbackItemCardProps) {
|
||||
|
|
@ -95,7 +80,6 @@ export function FeedbackItemCard({
|
|||
const [resolutionNote, setResolutionNote] = useState("");
|
||||
|
||||
const isResolved = item.status === "RESOLVED" || item.status === "VERIFIED";
|
||||
const styles = SEVERITY_STYLES[item.severity];
|
||||
|
||||
const handleResolve = () => {
|
||||
onResolve(item.id, resolutionNote || undefined);
|
||||
|
|
@ -107,37 +91,48 @@ export function FeedbackItemCard({
|
|||
<div
|
||||
className={cn(
|
||||
"group relative rounded-lg border border-l-[3px] p-2.5 transition-colors",
|
||||
styles.border,
|
||||
item.isActionItem
|
||||
? "border-l-[var(--accent)]"
|
||||
: "border-l-[var(--muted-foreground)]",
|
||||
isResolved && "opacity-60"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Checkbox */}
|
||||
<Checkbox
|
||||
checked={isResolved}
|
||||
disabled={isPending || item.status === "VERIFIED"}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
if (item.summary.length < 50) {
|
||||
onResolve(item.id);
|
||||
{/* Checkbox (only for action items) */}
|
||||
{item.isActionItem ? (
|
||||
<Checkbox
|
||||
checked={isResolved}
|
||||
disabled={isPending || item.status === "VERIFIED"}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
if (item.summary.length < 50) {
|
||||
onResolve(item.id);
|
||||
} else {
|
||||
setExpanded(true);
|
||||
}
|
||||
} else {
|
||||
setExpanded(true);
|
||||
onReopen(item.id);
|
||||
}
|
||||
} else {
|
||||
onReopen(item.id);
|
||||
}
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
) : (
|
||||
<Info className="mt-0.5 h-4 w-4 shrink-0 text-[var(--muted-foreground)]" />
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("h-4 px-1 text-[9px] font-bold", styles.badge)}
|
||||
className={cn(
|
||||
"h-4 px-1 text-[9px] font-bold",
|
||||
item.isActionItem
|
||||
? "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]"
|
||||
: "border-[var(--muted-foreground)]/30 bg-[var(--muted)]/50 text-[var(--muted-foreground)]"
|
||||
)}
|
||||
>
|
||||
{item.severity}
|
||||
{item.isActionItem ? "Action" : "Info"}
|
||||
</Badge>
|
||||
|
||||
{item.status !== "OPEN" && !isResolved && (
|
||||
|
|
@ -189,7 +184,7 @@ export function FeedbackItemCard({
|
|||
)}
|
||||
|
||||
{/* Expanded resolve form */}
|
||||
{expanded && !isResolved && (
|
||||
{expanded && !isResolved && item.isActionItem && (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<Textarea
|
||||
placeholder="Resolution note (optional)..."
|
||||
|
|
@ -238,7 +233,31 @@ export function FeedbackItemCard({
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isResolved && !expanded && (
|
||||
{/* Toggle action item / info */}
|
||||
{onToggleType && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={isPending}
|
||||
onClick={() => onToggleType(item.id, !item.isActionItem)}
|
||||
>
|
||||
{item.isActionItem ? (
|
||||
<Info className="h-3 w-3" />
|
||||
) : (
|
||||
<CircleDot className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{item.isActionItem ? "Change to info callout" : "Change to action item"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{item.isActionItem && !isResolved && !expanded && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -8,13 +8,6 @@ interface FeedbackProgressBarProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
CRITICAL: "bg-red-500",
|
||||
MAJOR: "bg-orange-500",
|
||||
MINOR: "bg-yellow-500",
|
||||
SUGGESTION: "bg-blue-500",
|
||||
};
|
||||
|
||||
export function FeedbackProgressBar({
|
||||
resolved,
|
||||
total,
|
||||
|
|
@ -27,7 +20,7 @@ export function FeedbackProgressBar({
|
|||
<div className={cn("space-y-1", className)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--muted-foreground)]">
|
||||
Feedback Progress
|
||||
Action Items
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -14,13 +14,6 @@ interface FeedbackIndicatorProps {
|
|||
className?: string;
|
||||
}
|
||||
|
||||
const SEVERITY_COLORS: Record<string, string> = {
|
||||
CRITICAL: "bg-red-500/10 text-red-600 border-red-500/30",
|
||||
MAJOR: "bg-orange-500/10 text-orange-600 border-orange-500/30",
|
||||
MINOR: "bg-yellow-500/10 text-yellow-600 border-yellow-500/30",
|
||||
SUGGESTION: "bg-blue-500/10 text-blue-600 border-blue-500/30",
|
||||
};
|
||||
|
||||
export function FeedbackIndicator({
|
||||
stageId,
|
||||
className,
|
||||
|
|
@ -30,9 +23,6 @@ export function FeedbackIndicator({
|
|||
if (!summary || summary.total === 0) return null;
|
||||
|
||||
const allDone = summary.resolved === summary.total;
|
||||
const severityStyle = summary.worstSeverity
|
||||
? SEVERITY_COLORS[summary.worstSeverity]
|
||||
: "";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
|
|
@ -43,7 +33,7 @@ export function FeedbackIndicator({
|
|||
"h-4 gap-0.5 px-1 text-[9px] font-bold tabular-nums",
|
||||
allDone
|
||||
? "border-[var(--status-approved)]/30 bg-[var(--status-approved)]/10 text-[var(--status-approved)]"
|
||||
: severityStyle,
|
||||
: "border-[var(--accent)]/30 bg-[var(--accent)]/10 text-[var(--accent)]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -53,12 +43,9 @@ export function FeedbackIndicator({
|
|||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{allDone
|
||||
? "All feedback resolved"
|
||||
: `${summary.open} open feedback item${summary.open !== 1 ? "s" : ""}${
|
||||
summary.worstSeverity
|
||||
? ` (worst: ${summary.worstSeverity.toLowerCase()})`
|
||||
: ""
|
||||
}`}
|
||||
? "All action items resolved"
|
||||
: `${summary.open} open action item${summary.open !== 1 ? "s" : ""}`}
|
||||
{summary.infoCount > 0 && ` · ${summary.infoCount} info callout${summary.infoCount !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -349,6 +349,14 @@ export function useAnnotationState(
|
|||
) => {
|
||||
if (!revisionId || !stageId) return;
|
||||
|
||||
const MAX_SCREENSHOT_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
if (file.size > MAX_SCREENSHOT_SIZE) {
|
||||
toast.error("Screenshot too large", {
|
||||
description: `Maximum size is 5MB. Your screenshot is ${(file.size / 1024 / 1024).toFixed(1)}MB. Try cropping or reducing the capture area.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("type", "current");
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function useFeedbackSummary(stageId: string) {
|
|||
total: number;
|
||||
resolved: number;
|
||||
open: number;
|
||||
worstSeverity: string | null;
|
||||
infoCount: number;
|
||||
}>(`/api/stages/${stageId}/feedback?summary=true`),
|
||||
enabled: !!stageId,
|
||||
});
|
||||
|
|
@ -77,7 +77,7 @@ export function useCreateFeedback(stageId: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Update a feedback item (summary, severity, status, assignment, sort).
|
||||
* Update a feedback item (summary, isActionItem, status, assignment, sort).
|
||||
*/
|
||||
export function useUpdateFeedback(stageId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
|
|
|
|||
|
|
@ -15,22 +15,22 @@ const FEEDBACK_INCLUDE = {
|
|||
} as const;
|
||||
|
||||
/**
|
||||
* List feedback items for a stage, optionally filtered by revision/status.
|
||||
* List feedback items for a stage, optionally filtered by revision/status/type.
|
||||
*/
|
||||
export async function listFeedbackItems(
|
||||
stageId: string,
|
||||
filters?: { revisionId?: string; status?: string; severity?: string }
|
||||
filters?: { revisionId?: string; status?: string; isActionItem?: boolean }
|
||||
) {
|
||||
const where: any = { deliverableStageId: stageId };
|
||||
|
||||
if (filters?.revisionId) where.revisionId = filters.revisionId;
|
||||
if (filters?.status) where.status = filters.status;
|
||||
if (filters?.severity) where.severity = filters.severity;
|
||||
if (filters?.isActionItem !== undefined) where.isActionItem = filters.isActionItem;
|
||||
|
||||
return prisma.feedbackItem.findMany({
|
||||
where,
|
||||
include: FEEDBACK_INCLUDE,
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
orderBy: [{ isActionItem: "desc" }, { sortOrder: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -45,14 +45,13 @@ export async function getFeedbackItem(itemId: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Create a feedback item manually (e.g., from the checklist panel).
|
||||
* Create a feedback item manually.
|
||||
*/
|
||||
export async function createFeedbackItem(
|
||||
stageId: string,
|
||||
userId: string,
|
||||
input: CreateFeedbackInput
|
||||
) {
|
||||
// Get current max sort order for this revision
|
||||
const maxSort = await prisma.feedbackItem.aggregate({
|
||||
where: { revisionId: input.revisionId },
|
||||
_max: { sortOrder: true },
|
||||
|
|
@ -65,7 +64,7 @@ export async function createFeedbackItem(
|
|||
annotationId: input.annotationId ?? null,
|
||||
commentId: input.commentId ?? null,
|
||||
summary: input.summary,
|
||||
severity: (input.severity as any) ?? "MAJOR",
|
||||
isActionItem: input.isActionItem ?? true,
|
||||
status: "OPEN",
|
||||
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
||||
assignedToId: input.assignedToId ?? null,
|
||||
|
|
@ -77,7 +76,7 @@ export async function createFeedbackItem(
|
|||
|
||||
/**
|
||||
* Auto-create a feedback item when an annotation is created.
|
||||
* Called from the annotation creation flow.
|
||||
* All annotations are action items by default.
|
||||
*/
|
||||
export async function createFeedbackFromAnnotation(
|
||||
stageId: string,
|
||||
|
|
@ -86,9 +85,8 @@ export async function createFeedbackFromAnnotation(
|
|||
commentId: string,
|
||||
commentText: string,
|
||||
userId: string,
|
||||
severity: "CRITICAL" | "MAJOR" | "MINOR" | "SUGGESTION" = "MAJOR"
|
||||
isActionItem: boolean = true
|
||||
) {
|
||||
// Derive summary from comment text (first 200 chars)
|
||||
const summary =
|
||||
commentText.length > 200
|
||||
? commentText.slice(0, 197) + "..."
|
||||
|
|
@ -106,7 +104,7 @@ export async function createFeedbackFromAnnotation(
|
|||
annotationId,
|
||||
commentId,
|
||||
summary,
|
||||
severity,
|
||||
isActionItem,
|
||||
status: "OPEN",
|
||||
sortOrder: (maxSort._max.sortOrder ?? 0) + 1,
|
||||
createdById: userId,
|
||||
|
|
@ -116,7 +114,7 @@ export async function createFeedbackFromAnnotation(
|
|||
}
|
||||
|
||||
/**
|
||||
* Update a feedback item (summary, severity, status, assignment, sort).
|
||||
* Update a feedback item.
|
||||
*/
|
||||
export async function updateFeedbackItem(
|
||||
itemId: string,
|
||||
|
|
@ -126,7 +124,7 @@ export async function updateFeedbackItem(
|
|||
where: { id: itemId },
|
||||
data: {
|
||||
...(input.summary !== undefined && { summary: input.summary }),
|
||||
...(input.severity !== undefined && { severity: input.severity as any }),
|
||||
...(input.isActionItem !== undefined && { isActionItem: input.isActionItem }),
|
||||
...(input.status !== undefined && { status: input.status as any }),
|
||||
...(input.assignedToId !== undefined && {
|
||||
assignedToId: input.assignedToId,
|
||||
|
|
@ -158,7 +156,7 @@ export async function resolveFeedbackItem(
|
|||
}
|
||||
|
||||
/**
|
||||
* Verify a resolved feedback item (reviewer confirms the fix).
|
||||
* Verify a resolved feedback item.
|
||||
*/
|
||||
export async function verifyFeedbackItem(itemId: string, userId: string) {
|
||||
return prisma.feedbackItem.update({
|
||||
|
|
@ -173,7 +171,7 @@ export async function verifyFeedbackItem(itemId: string, userId: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Reopen a feedback item (from any status back to OPEN).
|
||||
* Reopen a feedback item.
|
||||
*/
|
||||
export async function reopenFeedbackItem(itemId: string) {
|
||||
return prisma.feedbackItem.update({
|
||||
|
|
@ -199,8 +197,7 @@ export async function deleteFeedbackItem(itemId: string) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Carry forward unresolved CRITICAL and MAJOR items from a previous revision
|
||||
* to a new revision. Returns the newly created carried-forward items.
|
||||
* Carry forward unresolved action items from a previous revision to a new one.
|
||||
*/
|
||||
export async function carryForwardFeedback(
|
||||
stageId: string,
|
||||
|
|
@ -212,7 +209,7 @@ export async function carryForwardFeedback(
|
|||
where: {
|
||||
revisionId: previousRevisionId,
|
||||
status: { in: ["OPEN", "IN_PROGRESS", "REOPENED"] },
|
||||
severity: { in: ["CRITICAL", "MAJOR"] },
|
||||
isActionItem: true, // only carry forward action items, not info callouts
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -227,7 +224,7 @@ export async function carryForwardFeedback(
|
|||
annotationId: item.annotationId,
|
||||
commentId: item.commentId,
|
||||
summary: item.summary,
|
||||
severity: item.severity,
|
||||
isActionItem: true,
|
||||
status: "OPEN",
|
||||
sortOrder: idx + 1,
|
||||
assignedToId: item.assignedToId,
|
||||
|
|
@ -248,23 +245,16 @@ export async function carryForwardFeedback(
|
|||
export async function getFeedbackSummary(stageId: string) {
|
||||
const items = await prisma.feedbackItem.findMany({
|
||||
where: { deliverableStageId: stageId },
|
||||
select: { status: true, severity: true },
|
||||
select: { status: true, isActionItem: true },
|
||||
});
|
||||
|
||||
const total = items.length;
|
||||
const resolved = items.filter(
|
||||
const actionItems = items.filter((i) => i.isActionItem);
|
||||
const total = actionItems.length;
|
||||
const resolved = actionItems.filter(
|
||||
(i) => i.status === "RESOLVED" || i.status === "VERIFIED"
|
||||
).length;
|
||||
const open = total - resolved;
|
||||
const infoCount = items.filter((i) => !i.isActionItem).length;
|
||||
|
||||
// Worst unresolved severity
|
||||
const unresolvedSeverities = items
|
||||
.filter((i) => i.status !== "RESOLVED" && i.status !== "VERIFIED")
|
||||
.map((i) => i.severity);
|
||||
|
||||
const severityOrder = ["CRITICAL", "MAJOR", "MINOR", "SUGGESTION"] as const;
|
||||
const worstSeverity =
|
||||
severityOrder.find((s) => unresolvedSeverities.includes(s)) ?? null;
|
||||
|
||||
return { total, resolved, open, worstSeverity };
|
||||
return { total, resolved, open, infoCount };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,5 @@
|
|||
import { z } from "zod/v4";
|
||||
|
||||
const feedbackSeverityEnum = z.enum([
|
||||
"CRITICAL",
|
||||
"MAJOR",
|
||||
"MINOR",
|
||||
"SUGGESTION",
|
||||
]);
|
||||
|
||||
const feedbackStatusEnum = z.enum([
|
||||
"OPEN",
|
||||
"IN_PROGRESS",
|
||||
|
|
@ -20,7 +13,7 @@ export const createFeedbackSchema = z.object({
|
|||
annotationId: z.string().optional(),
|
||||
commentId: z.string().optional(),
|
||||
summary: z.string().min(1, "Summary is required"),
|
||||
severity: feedbackSeverityEnum.optional(),
|
||||
isActionItem: z.boolean().optional(), // default true
|
||||
assignedToId: z.string().optional(),
|
||||
});
|
||||
|
||||
|
|
@ -28,7 +21,7 @@ export type CreateFeedbackInput = z.infer<typeof createFeedbackSchema>;
|
|||
|
||||
export const updateFeedbackSchema = z.object({
|
||||
summary: z.string().min(1).optional(),
|
||||
severity: feedbackSeverityEnum.optional(),
|
||||
isActionItem: z.boolean().optional(),
|
||||
status: feedbackStatusEnum.optional(),
|
||||
assignedToId: z.string().nullable().optional(),
|
||||
sortOrder: z.number().int().optional(),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue