From ad65f6fe2d6426115c2b57c219a89e694e581311 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Thu, 26 Feb 2026 16:31:28 +0000 Subject: [PATCH] =?UTF-8?q?Phase=205:=20Frontend=20Wizard=20&=20Editor=20?= =?UTF-8?q?=E2=80=94=205-step=20generation=20wizard,=20review=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New 5-step wizard flow at /generate/ (Upload → Configure → Outline → Generate → Edit) - wizardSlice with localStorage persistence for cross-session state - Upload page: drag & drop files, brief text input, file type badges - Configure page: client/deck selectors, slide count slider, tone, language, instructions - Outline review page: split-view with source content + outline editor, template selection - Progress page: SSE streaming + polling fallback for real-time job progress - Review workflow: status badge (Draft/In Review/Approved) with popover transitions - Backend review endpoints: PUT status, POST comment, GET review info with audit logging - Wizard API helpers: clients, master decks, file upload/decompose, job status/cancel Co-Authored-By: Claude Opus 4.6 --- backend/api/main.py | 2 + backend/api/v1/ppt/endpoints/review.py | 131 ++++++++ .../components/ReviewWorkflow.tsx | 203 ++++++++++++ .../dashboard/components/PresentationGrid.tsx | 2 +- .../generate/configure/page.tsx | 302 +++++++++++++++++ .../generate/layout.tsx | 100 ++++++ .../generate/outline/page.tsx | 310 ++++++++++++++++++ .../generate/progress/page.tsx | 238 ++++++++++++++ .../generate/upload/page.tsx | 273 +++++++++++++++ .../presentation/components/Header.tsx | 4 + .../services/api/wizard.ts | 143 ++++++++ frontend/store/slices/wizardSlice.ts | 171 ++++++++++ frontend/store/store.ts | 2 + 13 files changed, 1880 insertions(+), 1 deletion(-) create mode 100644 backend/api/v1/ppt/endpoints/review.py create mode 100644 frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx create mode 100644 frontend/app/(presentation-generator)/generate/configure/page.tsx create mode 100644 frontend/app/(presentation-generator)/generate/layout.tsx create mode 100644 frontend/app/(presentation-generator)/generate/outline/page.tsx create mode 100644 frontend/app/(presentation-generator)/generate/progress/page.tsx create mode 100644 frontend/app/(presentation-generator)/generate/upload/page.tsx create mode 100644 frontend/app/(presentation-generator)/services/api/wizard.ts create mode 100644 frontend/store/slices/wizardSlice.ts diff --git a/backend/api/main.py b/backend/api/main.py index 7f2dbcb..7c221d7 100644 --- a/backend/api/main.py +++ b/backend/api/main.py @@ -14,6 +14,7 @@ from api.v1.admin.audit_router import AUDIT_ROUTER from api.v1.admin.brand_config_router import BRAND_CONFIG_ROUTER from api.v1.admin.master_decks_router import MASTER_DECKS_ROUTER from api.v1.ppt.endpoints.jobs import JOBS_ROUTER +from api.v1.ppt.endpoints.review import REVIEW_ROUTER from api.middlewares.audit_middleware import AuditMiddleware @@ -33,6 +34,7 @@ app.include_router(AUTH_ROUTER) app.include_router(ADMIN_ROUTER) app.include_router(API_V1_PPT_ROUTER) app.include_router(JOBS_ROUTER) +app.include_router(REVIEW_ROUTER) app.include_router(API_V1_WEBHOOK_ROUTER) app.include_router(API_V1_MOCK_ROUTER) diff --git a/backend/api/v1/ppt/endpoints/review.py b/backend/api/v1/ppt/endpoints/review.py new file mode 100644 index 0000000..b69b1eb --- /dev/null +++ b/backend/api/v1/ppt/endpoints/review.py @@ -0,0 +1,131 @@ +"""Presentation review workflow endpoints.""" +import uuid +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from models.sql.presentation import PresentationModel +from models.sql.user import UserModel +from services import audit_service +from services.database import get_async_session +from utils.auth_dependencies import get_current_user + +REVIEW_ROUTER = APIRouter(prefix="/api/v1/ppt", tags=["Review"]) + +VALID_STATUSES = {"draft", "in_review", "approved"} +VALID_TRANSITIONS = { + "draft": {"in_review"}, + "in_review": {"draft", "approved"}, + "approved": {"draft"}, +} + + +class StatusUpdateRequest(BaseModel): + status: str + comment: Optional[str] = None + + +class CommentRequest(BaseModel): + comment: str + + +@REVIEW_ROUTER.put("/presentation/{presentation_id}/status") +async def update_status( + presentation_id: uuid.UUID, + body: StatusUpdateRequest, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_async_session), +): + """Change presentation review status (draft -> in_review -> approved).""" + if body.status not in VALID_STATUSES: + raise HTTPException( + status_code=400, + detail=f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}", + ) + + presentation = await session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + + current = presentation.status or "draft" + if body.status not in VALID_TRANSITIONS.get(current, set()): + raise HTTPException( + status_code=400, + detail=f"Cannot transition from '{current}' to '{body.status}'", + ) + + old_status = current + presentation.status = body.status + if body.comment: + presentation.review_comment = body.comment + + await session.commit() + await session.refresh(presentation) + + # Audit log (fire-and-forget) + audit_service.log( + user_id=current_user.id, + action="status_change", + resource_type="presentation", + resource_id=presentation.id, + client_id=presentation.client_id, + details={ + "old_status": old_status, + "new_status": body.status, + "comment": body.comment, + }, + ) + + return { + "id": str(presentation.id), + "status": presentation.status, + "review_comment": presentation.review_comment, + } + + +@REVIEW_ROUTER.post("/presentation/{presentation_id}/comment") +async def add_comment( + presentation_id: uuid.UUID, + body: CommentRequest, + current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_async_session), +): + """Add a review comment to a presentation.""" + presentation = await session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + + presentation.review_comment = body.comment + await session.commit() + + audit_service.log( + user_id=current_user.id, + action="comment", + resource_type="presentation", + resource_id=presentation.id, + client_id=presentation.client_id, + details={"comment": body.comment}, + ) + + return {"ok": True} + + +@REVIEW_ROUTER.get("/presentation/{presentation_id}/review") +async def get_review_info( + presentation_id: uuid.UUID, + _current_user: UserModel = Depends(get_current_user), + session: AsyncSession = Depends(get_async_session), +): + """Get review status and comment for a presentation.""" + presentation = await session.get(PresentationModel, presentation_id) + if not presentation: + raise HTTPException(status_code=404, detail="Presentation not found") + + return { + "id": str(presentation.id), + "status": presentation.status or "draft", + "review_comment": presentation.review_comment, + "title": presentation.title, + } diff --git a/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx b/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx new file mode 100644 index 0000000..ba26253 --- /dev/null +++ b/frontend/app/(presentation-generator)/components/ReviewWorkflow.tsx @@ -0,0 +1,203 @@ +"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 = { + 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 = { + draft: ["in_review"], + in_review: ["approved", "draft"], + approved: ["draft"], +}; + +interface ReviewWorkflowProps { + presentationId: string; +} + +export default function ReviewWorkflow({ presentationId }: ReviewWorkflowProps) { + const [info, setInfo] = useState(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 { + // Silently fail — review info is supplementary + } 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 ( + + + + + +
+

Review Status

+ + {/* Current status */} +
+ + + {config.label} + +
+ + {/* Review comment */} + {info.review_comment && ( +
+ Last comment: {info.review_comment} +
+ )} + + {/* Transition buttons */} + {transitions.length > 0 && ( +
+

Change status to:

+ {transitions.map((status) => { + const targetConfig = STATUS_CONFIG[status]; + const TargetIcon = targetConfig.icon; + return ( + + ); + })} +
+ )} + + {/* Comment input */} +
+