206 lines
6.7 KiB
TypeScript
206 lines
6.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|