- Fix PPTX/PDF export: Puppeteer URL port mismatch (80 → 3000) - Fix backend export_utils to use NEXT_INTERNAL_URL env var - Add Chromium to frontend Dockerfile for Docker-based export - Fix slide edit socket hang up with asyncio.wait_for() timeouts - Add FastAPI StaticFiles mounts for /static and /app_data - Add Next.js rewrite for /static/ to proxy to backend - Show template thumbnail in master decks admin page - Add error logging to ReviewWorkflow component - Add Docker env vars for web service (APP_DATA_DIRECTORY, app_data volume) - Add project README in English Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
203 lines
6.8 KiB
TypeScript
203 lines
6.8 KiB
TypeScript
"use client";
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
FileEdit,
|
|
Eye,
|
|
CheckCircle2,
|
|
ChevronDown,
|
|
Send,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import { getHeader } from "../services/api/header";
|
|
import { ApiResponseHandler } from "../services/api/api-error-handler";
|
|
|
|
interface ReviewInfo {
|
|
id: string;
|
|
status: string;
|
|
review_comment: string | null;
|
|
title: string | null;
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: React.ElementType }> = {
|
|
draft: { label: "Draft", color: "text-yellow-700", bg: "bg-yellow-50 border-yellow-200", icon: FileEdit },
|
|
in_review: { label: "In Review", color: "text-blue-700", bg: "bg-blue-50 border-blue-200", icon: Eye },
|
|
approved: { label: "Approved", color: "text-green-700", bg: "bg-green-50 border-green-200", icon: CheckCircle2 },
|
|
};
|
|
|
|
const TRANSITIONS: Record<string, string[]> = {
|
|
draft: ["in_review"],
|
|
in_review: ["approved", "draft"],
|
|
approved: ["draft"],
|
|
};
|
|
|
|
interface ReviewWorkflowProps {
|
|
presentationId: string;
|
|
}
|
|
|
|
export default function ReviewWorkflow({ presentationId }: ReviewWorkflowProps) {
|
|
const [info, setInfo] = useState<ReviewInfo | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isUpdating, setIsUpdating] = useState(false);
|
|
const [comment, setComment] = useState("");
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchReviewInfo();
|
|
}, [presentationId]);
|
|
|
|
const fetchReviewInfo = async () => {
|
|
try {
|
|
const response = await fetch(`/api/v1/ppt/presentation/${presentationId}/review`, {
|
|
headers: getHeader(),
|
|
});
|
|
const data = await ApiResponseHandler.handleResponse(response, "Failed to fetch review info");
|
|
setInfo(data);
|
|
} catch (err) {
|
|
console.error("ReviewWorkflow: failed to fetch review info", err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (newStatus: string) => {
|
|
setIsUpdating(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/ppt/presentation/${presentationId}/status`, {
|
|
method: "PUT",
|
|
headers: getHeader(),
|
|
body: JSON.stringify({ status: newStatus, comment: comment || null }),
|
|
});
|
|
const data = await ApiResponseHandler.handleResponse(response, "Failed to update status");
|
|
setInfo((prev) => prev ? { ...prev, status: data.status, review_comment: data.review_comment } : prev);
|
|
setComment("");
|
|
setPopoverOpen(false);
|
|
toast.success(`Status changed to ${STATUS_CONFIG[newStatus]?.label || newStatus}`);
|
|
} catch (error: any) {
|
|
toast.error(error.message || "Failed to update status");
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
};
|
|
|
|
const handleAddComment = async () => {
|
|
if (!comment.trim()) return;
|
|
setIsUpdating(true);
|
|
try {
|
|
const response = await fetch(`/api/v1/ppt/presentation/${presentationId}/comment`, {
|
|
method: "POST",
|
|
headers: getHeader(),
|
|
body: JSON.stringify({ comment }),
|
|
});
|
|
await ApiResponseHandler.handleResponse(response, "Failed to add comment");
|
|
setInfo((prev) => prev ? { ...prev, review_comment: comment } : prev);
|
|
setComment("");
|
|
toast.success("Comment added");
|
|
} catch (error: any) {
|
|
toast.error(error.message || "Failed to add comment");
|
|
} finally {
|
|
setIsUpdating(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading || !info) return null;
|
|
|
|
const currentStatus = info.status || "draft";
|
|
const config = STATUS_CONFIG[currentStatus] || STATUS_CONFIG.draft;
|
|
const transitions = TRANSITIONS[currentStatus] || [];
|
|
const StatusIcon = config.icon;
|
|
|
|
return (
|
|
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
|
|
<PopoverTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors",
|
|
config.bg,
|
|
config.color
|
|
)}
|
|
>
|
|
<StatusIcon className="w-3.5 h-3.5" />
|
|
{config.label}
|
|
<ChevronDown className="w-3 h-3" />
|
|
</button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-72" align="end">
|
|
<div className="space-y-3">
|
|
<h4 className="text-sm font-semibold">Review Status</h4>
|
|
|
|
{/* Current status */}
|
|
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-lg border", config.bg)}>
|
|
<StatusIcon className={cn("w-4 h-4", config.color)} />
|
|
<span className={cn("text-sm font-medium", config.color)}>
|
|
{config.label}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Review comment */}
|
|
{info.review_comment && (
|
|
<div className="text-xs text-gray-500 bg-gray-50 rounded p-2">
|
|
<span className="font-medium">Last comment:</span> {info.review_comment}
|
|
</div>
|
|
)}
|
|
|
|
{/* Transition buttons */}
|
|
{transitions.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<p className="text-xs text-gray-500">Change status to:</p>
|
|
{transitions.map((status) => {
|
|
const targetConfig = STATUS_CONFIG[status];
|
|
const TargetIcon = targetConfig.icon;
|
|
return (
|
|
<Button
|
|
key={status}
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full justify-start gap-2"
|
|
disabled={isUpdating}
|
|
onClick={() => handleStatusChange(status)}
|
|
>
|
|
{isUpdating ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<TargetIcon className={cn("w-3.5 h-3.5", targetConfig.color)} />
|
|
)}
|
|
{targetConfig.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Comment input */}
|
|
<div className="space-y-2 pt-1 border-t">
|
|
<Textarea
|
|
placeholder="Add a review comment..."
|
|
value={comment}
|
|
onChange={(e) => setComment(e.target.value)}
|
|
className="min-h-[60px] text-sm resize-none"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="w-full"
|
|
disabled={!comment.trim() || isUpdating}
|
|
onClick={handleAddComment}
|
|
>
|
|
<Send className="w-3.5 h-3.5 mr-1" />
|
|
Add Comment
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
}
|