feat: implement final approval gate workflow and checklist
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user