feat: implement final approval gate workflow and checklist

This commit is contained in:
2026-05-22 00:31:48 +03:00
parent 6dcbe0e36c
commit d3990b4019
15 changed files with 1851 additions and 14 deletions
@@ -0,0 +1,205 @@
"use client";
import { useMemo, useState } from "react";
import type { FinalApprovalChecklistResponse } from "@pipeline/shared";
import {
ApiError,
fetchFinalApprovalChecklist,
submitFinalApproval,
submitFinalRevisionRequest,
} from "@/shared/pipeline-api";
import { buildFinalApprovalViewModel } from "./model";
type FinalApprovalPanelProps = {
articleId: string;
};
const DEFAULT_FRONTMATTER = '{\n "category": "Guides"\n}';
export function FinalApprovalPanel({ articleId }: FinalApprovalPanelProps) {
const [checklist, setChecklist] = useState<FinalApprovalChecklistResponse | null>(null);
const [draftVersionInput, setDraftVersionInput] = useState("");
const [contentPath, setContentPath] = useState("/guides/final-approval-gate");
const [author, setAuthor] = useState("Editorial Team");
const [publishingMode, setPublishingMode] = useState("MANUAL");
const [frontmatterText, setFrontmatterText] = useState(DEFAULT_FRONTMATTER);
const [revisionReason, setRevisionReason] = useState("Need final refinements before approval.");
const [busyKey, setBusyKey] = useState<string | null>(null);
const [message, setMessage] = useState("");
const viewModel = useMemo(
() => buildFinalApprovalViewModel(checklist),
[checklist],
);
async function refreshChecklist() {
setBusyKey("refresh");
setMessage("");
try {
const response = await fetchFinalApprovalChecklist(articleId);
setChecklist(response);
if (!draftVersionInput && response.draft_version) {
setDraftVersionInput(String(response.draft_version));
}
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to fetch final checklist.");
} finally {
setBusyKey(null);
}
}
async function approve() {
let frontmatter: Record<string, unknown> | null = null;
try {
const parsed = JSON.parse(frontmatterText);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
setMessage("Frontmatter must be a valid JSON object.");
return;
}
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
setBusyKey("approve");
setMessage("");
try {
const response = await submitFinalApproval(articleId, {
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
publishing_settings: {
content_path: contentPath,
author,
publishing_mode: publishingMode,
frontmatter,
},
});
setChecklist(response.checklist);
setMessage(`Final approval completed. Article status: ${response.article.status}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Final approval failed.");
await refreshChecklist();
} finally {
setBusyKey(null);
}
}
async function requestRevision() {
const parsedDraftVersion = Number.parseInt(draftVersionInput, 10);
setBusyKey("revision");
setMessage("");
try {
const response = await submitFinalRevisionRequest(articleId, {
reason: revisionReason,
draft_version: Number.isNaN(parsedDraftVersion) ? null : parsedDraftVersion,
});
setChecklist(response.checklist);
setMessage(`Revision requested. Article status: ${response.article.status}.`);
} catch (error) {
setMessage(error instanceof ApiError ? error.message : "Failed to request revision.");
} finally {
setBusyKey(null);
}
}
return (
<section className="reviewPanel">
<div className="sectionHeader">
<div>
<h2>Final approval gate</h2>
<p>Checklist blockers: {viewModel.blockers.length}</p>
</div>
<div className="formActions">
<button type="button" onClick={() => void refreshChecklist()} disabled={busyKey !== null}>
{busyKey === "refresh" ? "Refreshing..." : "Refresh checklist"}
</button>
</div>
</div>
{message ? <p className={message.includes("failed") ? "formError" : "formSuccess"}>{message}</p> : null}
{viewModel.items.length > 0 ? (
<table className="table">
<thead>
<tr>
<th>Checklist item</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{viewModel.items.map((item) => (
<tr key={item.key}>
<td>{item.title}</td>
<td>{item.completed ? "Completed" : "Blocked"}</td>
<td>{item.completed ? "—" : item.blocker || "—"}</td>
</tr>
))}
</tbody>
</table>
) : (
<p>No checklist yet. Refresh to load current gate status.</p>
)}
{viewModel.completedItems.length > 0 ? (
<ul className="timeline">
{viewModel.completedItems.map((item) => (
<li key={item}>Completed: {item}</li>
))}
</ul>
) : null}
{viewModel.blockers.length > 0 ? (
<ul className="timeline">
{viewModel.blockers.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
) : null}
<div className="reviewActions">
<input
value={draftVersionInput}
onChange={(event) => setDraftVersionInput(event.target.value)}
placeholder={viewModel.draftVersion ? String(viewModel.draftVersion) : "Draft version"}
inputMode="numeric"
/>
<input
value={contentPath}
onChange={(event) => setContentPath(event.target.value)}
placeholder="/guides/final-approval-gate"
/>
<input
value={author}
onChange={(event) => setAuthor(event.target.value)}
placeholder="Author"
/>
<input
value={publishingMode}
onChange={(event) => setPublishingMode(event.target.value)}
placeholder="MANUAL"
/>
<textarea
value={frontmatterText}
onChange={(event) => setFrontmatterText(event.target.value)}
rows={4}
/>
<button type="button" onClick={() => void approve()} disabled={busyKey !== null}>
{busyKey === "approve" ? "Approving..." : "Approve final"}
</button>
</div>
<div className="reviewActions">
<input
value={revisionReason}
onChange={(event) => setRevisionReason(event.target.value)}
placeholder="Revision reason"
/>
<button type="button" onClick={() => void requestRevision()} disabled={busyKey !== null}>
{busyKey === "revision" ? "Submitting..." : "Request revision"}
</button>
</div>
</section>
);
}