Expand request review tooling and KB
This commit is contained in:
parent
ab1dfbf029
commit
cae21b61b7
16 changed files with 1258 additions and 241 deletions
|
|
@ -53,6 +53,8 @@ type LauncherRequest = {
|
|||
problemType?: string;
|
||||
rawExcerpt?: string;
|
||||
confidence?: number | null;
|
||||
reviewRationale?: string;
|
||||
reviewOptions?: string[];
|
||||
notes?: string;
|
||||
}>;
|
||||
};
|
||||
|
|
@ -60,6 +62,8 @@ type LauncherRequest = {
|
|||
updatedAt: string;
|
||||
};
|
||||
|
||||
type LauncherRequestAnalysisItem = NonNullable<NonNullable<LauncherRequest["analysis"]>["items"]>[number];
|
||||
|
||||
type LauncherRequestsPayload = {
|
||||
requests?: LauncherRequest[];
|
||||
};
|
||||
|
|
@ -95,6 +99,10 @@ type ProcessPendingPayload = {
|
|||
pid?: number;
|
||||
};
|
||||
|
||||
type LauncherRequestMetaPayload = {
|
||||
allowedTags?: string[];
|
||||
};
|
||||
|
||||
type AdminAuthPayload = {
|
||||
ok?: boolean;
|
||||
accessGranted?: boolean;
|
||||
|
|
@ -148,6 +156,108 @@ function isAdminAccessError(error: unknown): boolean {
|
|||
|| text.includes("admin access is not configured");
|
||||
}
|
||||
|
||||
function cloneLauncherRequest(requestEntry: LauncherRequest): LauncherRequest {
|
||||
return JSON.parse(JSON.stringify(requestEntry)) as LauncherRequest;
|
||||
}
|
||||
|
||||
function getPrimaryAnalysisItem(requestEntry: LauncherRequest): LauncherRequestAnalysisItem | null {
|
||||
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis?.items : [];
|
||||
return items.length > 0 ? items[0] : null;
|
||||
}
|
||||
|
||||
function formatConfidence(value: number | null | undefined): string {
|
||||
if (!Number.isFinite(Number(value))) {
|
||||
return "Unscored";
|
||||
}
|
||||
return `${Math.round(Number(value) * 100)}%`;
|
||||
}
|
||||
|
||||
function escapePopupHtml(value: string): string {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function openReviewDetailsPopup(requestEntry: LauncherRequest): void {
|
||||
const popup = window.open("", `worldshaper-review-${requestEntry.id}`, "popup=yes,width=840,height=760,resizable=yes,scrollbars=yes");
|
||||
if (!popup) {
|
||||
return;
|
||||
}
|
||||
const items = Array.isArray(requestEntry.analysis?.items) ? requestEntry.analysis.items : [];
|
||||
const renderedItems = items.map((item, index) => {
|
||||
const reviewOptions = Array.isArray(item?.reviewOptions) ? item.reviewOptions : [];
|
||||
return `
|
||||
<section class="review-card">
|
||||
<div class="review-kicker">Review Item ${index + 1}</div>
|
||||
<h2>${escapePopupHtml(String(item?.title || `Request ${index + 1}`))}</h2>
|
||||
<div class="review-meta">
|
||||
<span>${escapePopupHtml(String(item?.primaryCategory || "Unsorted"))}</span>
|
||||
<span>${escapePopupHtml(String(item?.statusRecommendation || "needs_review"))}</span>
|
||||
<span>${escapePopupHtml(formatConfidence(item?.confidence))}</span>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Review Rationale</h3>
|
||||
<p>${escapePopupHtml(String(item?.reviewRationale || "No structured review rationale was returned."))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Parsed Interpretation</h3>
|
||||
<p>${escapePopupHtml(String(item?.parsedInterpretation || ""))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Implementation Approach</h3>
|
||||
<p>${escapePopupHtml(String(item?.implementationApproach || ""))}</p>
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Possible Options</h3>
|
||||
${reviewOptions.length > 0
|
||||
? `<ul>${reviewOptions.map((option) => `<li>${escapePopupHtml(String(option || ""))}</li>`).join("")}</ul>`
|
||||
: "<p>No structured options were returned.</p>"}
|
||||
</div>
|
||||
<div class="review-block">
|
||||
<h3>Notes</h3>
|
||||
<p>${escapePopupHtml(String(item?.notes || "No extra notes."))}</p>
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
popup.document.open();
|
||||
popup.document.write(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Worldshaper Review Details</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: Georgia, "Segoe UI", sans-serif; background: #08111f; color: #e8f2ff; }
|
||||
main { max-width: 920px; margin: 0 auto; padding: 24px; display: grid; gap: 16px; }
|
||||
.hero { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: linear-gradient(180deg, rgba(17,32,63,.96), rgba(10,19,38,.98)); }
|
||||
.hero h1 { margin: 0 0 8px; font-size: 28px; }
|
||||
.hero p { margin: 0; color: #b8cfee; line-height: 1.6; white-space: pre-wrap; }
|
||||
.review-card { padding: 18px 20px; border: 1px solid #365782; border-radius: 14px; background: rgba(17,32,63,.88); display: grid; gap: 12px; }
|
||||
.review-kicker { color: #ffd166; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||
.review-card h2, .review-block h3 { margin: 0; }
|
||||
.review-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.review-meta span { padding: 4px 8px; border: 1px solid #365782; border-radius: 999px; background: rgba(8,16,31,.75); font-size: 12px; }
|
||||
.review-block { display: grid; gap: 6px; }
|
||||
.review-block p, .review-block ul { margin: 0; color: #d7e7ff; line-height: 1.6; white-space: pre-wrap; }
|
||||
.review-block ul { padding-left: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>${escapePopupHtml(requestEntry.title)}</h1>
|
||||
<p>${escapePopupHtml(requestEntry.sourceText)}</p>
|
||||
</section>
|
||||
${renderedItems || '<section class="review-card"><h2>No Review Items</h2><p>This request does not have structured review details yet.</p></section>'}
|
||||
</main>
|
||||
</body>
|
||||
</html>`);
|
||||
popup.document.close();
|
||||
}
|
||||
|
||||
function formatRequestTimestamp(value: string): string {
|
||||
const parsed = Date.parse(String(value || ""));
|
||||
if (!Number.isFinite(parsed)) {
|
||||
|
|
@ -305,6 +415,7 @@ function WorldshaperLauncher() {
|
|||
const [requestSubmitting, setRequestSubmitting] = useState(false);
|
||||
const [requestMutatingId, setRequestMutatingId] = useState("");
|
||||
const [requestFilter, setRequestFilter] = useState("all");
|
||||
const [allowedRequestTags, setAllowedRequestTags] = useState<string[]>([]);
|
||||
const [expandedRequestIds, setExpandedRequestIds] = useState<string[]>([]);
|
||||
const [adminPanelOpen, setAdminPanelOpen] = useState(false);
|
||||
const [adminAccessGranted, setAdminAccessGranted] = useState(false);
|
||||
|
|
@ -312,6 +423,9 @@ function WorldshaperLauncher() {
|
|||
const [adminPasswordDraft, setAdminPasswordDraft] = useState("");
|
||||
const [adminAuthSubmitting, setAdminAuthSubmitting] = useState(false);
|
||||
const [adminPasswordError, setAdminPasswordError] = useState("");
|
||||
const [selectedAdminRequestId, setSelectedAdminRequestId] = useState("");
|
||||
const [adminEditorDraft, setAdminEditorDraft] = useState<LauncherRequest | null>(null);
|
||||
const [adminSaving, setAdminSaving] = useState(false);
|
||||
const [recentSaveEvents, setRecentSaveEvents] = useState<RecentSaveEvent[]>([]);
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
const [logsError, setLogsError] = useState("");
|
||||
|
|
@ -357,6 +471,15 @@ function WorldshaperLauncher() {
|
|||
}
|
||||
}
|
||||
|
||||
async function loadRequestMeta(): Promise<void> {
|
||||
try {
|
||||
const payload = await fetchJsonOrThrow<LauncherRequestMetaPayload>("/api/launcher-request-meta");
|
||||
setAllowedRequestTags(Array.isArray(payload.allowedTags) ? payload.allowedTags : []);
|
||||
} catch {
|
||||
setAllowedRequestTags([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecentSaveEvents(): Promise<void> {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
|
|
@ -397,6 +520,7 @@ function WorldshaperLauncher() {
|
|||
|
||||
useEffect(() => {
|
||||
void loadRequests();
|
||||
void loadRequestMeta();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -412,13 +536,15 @@ function WorldshaperLauncher() {
|
|||
}
|
||||
let cancelled = false;
|
||||
const refreshBoard = async (): Promise<void> => {
|
||||
try {
|
||||
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
||||
if (!cancelled) {
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
if (!adminPanelOpen) {
|
||||
try {
|
||||
const payload = await fetchJsonOrThrow<LauncherRequestsPayload>("/api/launcher-requests");
|
||||
if (!cancelled) {
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
}
|
||||
} catch {
|
||||
// Keep the current list visible during background refresh failures.
|
||||
}
|
||||
} catch {
|
||||
// Keep the current list visible during background refresh failures.
|
||||
}
|
||||
if (!adminPanelOpen || !adminAccessGranted || !adminPassword) {
|
||||
return;
|
||||
|
|
@ -443,6 +569,26 @@ function WorldshaperLauncher() {
|
|||
};
|
||||
}, [activeBoardTab, adminPanelOpen, adminAccessGranted, adminPassword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminPanelOpen || !adminAccessGranted) {
|
||||
return;
|
||||
}
|
||||
if (requests.length === 0) {
|
||||
setSelectedAdminRequestId("");
|
||||
setAdminEditorDraft(null);
|
||||
return;
|
||||
}
|
||||
const selectedRequest = requests.find((entry) => entry.id === selectedAdminRequestId);
|
||||
if (selectedRequest) {
|
||||
if (!adminEditorDraft || adminEditorDraft.id !== selectedRequest.id) {
|
||||
setAdminEditorDraft(cloneLauncherRequest(selectedRequest));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setSelectedAdminRequestId(requests[0].id);
|
||||
setAdminEditorDraft(cloneLauncherRequest(requests[0]));
|
||||
}, [adminPanelOpen, adminAccessGranted, requests, selectedAdminRequestId, adminEditorDraft]);
|
||||
|
||||
async function handleLaunch(): Promise<void> {
|
||||
setError("");
|
||||
const nextWorldId = worldId || DEFAULT_EDITOR_WORLD_ID_FALLBACK;
|
||||
|
|
@ -539,6 +685,152 @@ function WorldshaperLauncher() {
|
|||
}
|
||||
}
|
||||
|
||||
function handleSelectAdminRequest(requestId: string): void {
|
||||
const nextRequest = requests.find((entry) => entry.id === requestId);
|
||||
setSelectedAdminRequestId(requestId);
|
||||
setAdminEditorDraft(nextRequest ? cloneLauncherRequest(nextRequest) : null);
|
||||
setAdminNotice("");
|
||||
setAdminPasswordError("");
|
||||
}
|
||||
|
||||
function updateAdminDraft(updater: (current: LauncherRequest) => LauncherRequest): void {
|
||||
setAdminEditorDraft((current) => (current ? updater(current) : current));
|
||||
}
|
||||
|
||||
function updateAdminDraftItem(
|
||||
itemIndex: number,
|
||||
updater: (item: LauncherRequestAnalysisItem) => LauncherRequestAnalysisItem,
|
||||
): void {
|
||||
updateAdminDraft((current) => {
|
||||
const next = cloneLauncherRequest(current);
|
||||
if (!next.analysis) {
|
||||
next.analysis = {
|
||||
state: "needs_review",
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
const items = Array.isArray(next.analysis.items) ? [...next.analysis.items] : [];
|
||||
const existingItem = items[itemIndex] || {};
|
||||
items[itemIndex] = updater({
|
||||
...existingItem,
|
||||
tags: Array.isArray(existingItem.tags) ? [...existingItem.tags] : [],
|
||||
affectedSystems: Array.isArray(existingItem.affectedSystems) ? [...existingItem.affectedSystems] : [],
|
||||
affectedFiles: Array.isArray(existingItem.affectedFiles) ? [...existingItem.affectedFiles] : [],
|
||||
reviewOptions: Array.isArray(existingItem.reviewOptions) ? [...existingItem.reviewOptions] : [],
|
||||
});
|
||||
next.analysis.items = items;
|
||||
next.analysis.itemCount = items.length;
|
||||
next.analysis.updatedAt = new Date().toISOString();
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdminSavePayload(requestEntry: LauncherRequest): RequestInit {
|
||||
return {
|
||||
method: "PATCH",
|
||||
headers: buildAdminHeaders(adminPassword, {
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
title: requestEntry.title,
|
||||
status: requestEntry.status,
|
||||
category: requestEntry.category,
|
||||
tags: requestEntry.tags,
|
||||
sourceText: requestEntry.sourceText,
|
||||
summary: requestEntry.summary,
|
||||
implementationNotes: requestEntry.implementationNotes,
|
||||
analysis: requestEntry.analysis,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSaveAdminRequest(): Promise<void> {
|
||||
if (!adminEditorDraft) {
|
||||
return;
|
||||
}
|
||||
setAdminSaving(true);
|
||||
try {
|
||||
const payload = await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
|
||||
`/api/launcher-requests/${encodeURIComponent(adminEditorDraft.id)}`,
|
||||
buildAdminSavePayload(adminEditorDraft),
|
||||
);
|
||||
const nextRequests = Array.isArray(payload.requests) ? payload.requests : requests;
|
||||
setRequests(nextRequests);
|
||||
const refreshed = nextRequests.find((entry) => entry.id === adminEditorDraft.id) || payload.request || adminEditorDraft;
|
||||
setAdminEditorDraft(cloneLauncherRequest(refreshed));
|
||||
setAdminNotice(`Saved admin changes for "${adminEditorDraft.title}".`);
|
||||
if (adminPanelOpen) {
|
||||
void loadRecentSaveEvents();
|
||||
}
|
||||
} catch (nextError: unknown) {
|
||||
if (isAdminAccessError(nextError)) {
|
||||
setAdminAccessGranted(false);
|
||||
setAdminPassword("");
|
||||
setAdminPasswordError("Admin access expired. Enter the password again.");
|
||||
}
|
||||
setLogsError(String(nextError || "Failed to save admin changes."));
|
||||
} finally {
|
||||
setAdminSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApproveAdminRequest(): Promise<void> {
|
||||
if (!adminEditorDraft) {
|
||||
return;
|
||||
}
|
||||
const nextDraft = cloneLauncherRequest(adminEditorDraft);
|
||||
if (!nextDraft.analysis) {
|
||||
setLogsError("This request has no analysis to approve yet.");
|
||||
return;
|
||||
}
|
||||
const items = Array.isArray(nextDraft.analysis.items) ? nextDraft.analysis.items : [];
|
||||
nextDraft.analysis.items = items.map((item) => ({
|
||||
...item,
|
||||
statusRecommendation: "active",
|
||||
}));
|
||||
nextDraft.analysis.state = "processed";
|
||||
nextDraft.analysis.updatedAt = new Date().toISOString();
|
||||
setAdminEditorDraft(nextDraft);
|
||||
setAdminSaving(true);
|
||||
try {
|
||||
await fetchJsonOrThrow<{ request?: LauncherRequest; requests?: LauncherRequest[] }>(
|
||||
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}`,
|
||||
buildAdminSavePayload(nextDraft),
|
||||
);
|
||||
const promotePayload = await fetchJsonOrThrow<LauncherRequestsPayload>(
|
||||
`/api/launcher-requests/${encodeURIComponent(nextDraft.id)}/process-analysis`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: buildAdminHeaders(adminPassword, {
|
||||
"Content-Type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
action: "promote",
|
||||
analysis: nextDraft.analysis,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const nextRequests = Array.isArray(promotePayload.requests) ? promotePayload.requests : [];
|
||||
setRequests(nextRequests);
|
||||
const fallbackSelection = nextRequests[0] || null;
|
||||
setSelectedAdminRequestId(fallbackSelection?.id || "");
|
||||
setAdminEditorDraft(fallbackSelection ? cloneLauncherRequest(fallbackSelection) : null);
|
||||
setAdminNotice(`Approved "${nextDraft.title}" and promoted its active request item${(nextDraft.analysis.items?.length || 0) === 1 ? "" : "s"}.`);
|
||||
if (adminPanelOpen) {
|
||||
void loadRecentSaveEvents();
|
||||
}
|
||||
} catch (nextError: unknown) {
|
||||
if (isAdminAccessError(nextError)) {
|
||||
setAdminAccessGranted(false);
|
||||
setAdminPassword("");
|
||||
setAdminPasswordError("Admin access expired. Enter the password again.");
|
||||
}
|
||||
setLogsError(String(nextError || "Failed to approve this request."));
|
||||
} finally {
|
||||
setAdminSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleExpandedRequest(requestId: string): void {
|
||||
setExpandedRequestIds((current) => (
|
||||
current.includes(requestId)
|
||||
|
|
@ -622,12 +914,14 @@ function WorldshaperLauncher() {
|
|||
const activeRequestCount = requests.filter((entry) => entry.status === "active").length;
|
||||
const queuedPendingRequestCount = requests.filter(isQueuedPendingRequest).length;
|
||||
const needsReviewRequestCount = requests.filter(isNeedsReviewRequest).length;
|
||||
const requestTags = Array.from(new Set(
|
||||
requests
|
||||
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean),
|
||||
)).sort((a, b) => a.localeCompare(b));
|
||||
const requestTags = (allowedRequestTags.length > 0
|
||||
? allowedRequestTags
|
||||
: Array.from(new Set(
|
||||
requests
|
||||
.flatMap((entry) => Array.isArray(entry.tags) ? entry.tags : [])
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean),
|
||||
))).sort((a, b) => a.localeCompare(b));
|
||||
const filteredRequests = requests.filter((entry) => {
|
||||
if (requestFilter === "status:pending") {
|
||||
return entry.status === "pending";
|
||||
|
|
@ -881,27 +1175,40 @@ function WorldshaperLauncher() {
|
|||
<section className="launcher-request-admin-card">
|
||||
<div className="launcher-request-admin-card-head">
|
||||
<h4 className="launcher-request-admin-card-title">Request Management</h4>
|
||||
<div className="launcher-request-admin-card-hint">Delete actions live here now.</div>
|
||||
<div className="launcher-request-admin-card-hint">Select a request to review or edit it.</div>
|
||||
</div>
|
||||
<div className="launcher-request-admin-request-list">
|
||||
{requests.map((requestEntry) => {
|
||||
const isMutating = requestMutatingId === requestEntry.id;
|
||||
const analysisState = formatAnalysisStateLabel(requestEntry.analysis?.state);
|
||||
const reviewItem = getPrimaryAnalysisItem(requestEntry);
|
||||
const isSelected = requestEntry.id === selectedAdminRequestId;
|
||||
return (
|
||||
<article key={`admin-${requestEntry.id}`} className="launcher-request-admin-request-row">
|
||||
<article
|
||||
key={`admin-${requestEntry.id}`}
|
||||
className={`launcher-request-admin-request-row ${isSelected ? "is-selected" : ""}`}
|
||||
onClick={() => handleSelectAdminRequest(requestEntry.id)}
|
||||
>
|
||||
<div className="launcher-request-admin-request-copy">
|
||||
<div className="launcher-request-admin-request-title">{requestEntry.title}</div>
|
||||
<div className="launcher-request-admin-request-meta">
|
||||
<span>{formatRequestStatusLabel(requestEntry.status)}</span>
|
||||
<span>{requestEntry.category}</span>
|
||||
<span>{analysisState}</span>
|
||||
<span>{formatConfidence(reviewItem?.confidence ?? requestEntry.analysis?.confidence)}</span>
|
||||
<span>{formatRequestTimestamp(requestEntry.updatedAt)}</span>
|
||||
</div>
|
||||
{reviewItem?.reviewRationale ? (
|
||||
<div className="launcher-request-admin-request-rationale">{reviewItem.reviewRationale}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-request-delete-btn"
|
||||
onClick={() => void handleDeleteRequest(requestEntry)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDeleteRequest(requestEntry);
|
||||
}}
|
||||
disabled={isMutating}
|
||||
aria-label={`Delete ${requestEntry.title}`}
|
||||
>
|
||||
|
|
@ -912,6 +1219,253 @@ function WorldshaperLauncher() {
|
|||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="launcher-request-admin-card launcher-request-admin-editor-card">
|
||||
<div className="launcher-request-admin-card-head">
|
||||
<h4 className="launcher-request-admin-card-title">Review Editor</h4>
|
||||
<div className="launcher-request-admin-card-hint">Edit request fields, review details, and approval state.</div>
|
||||
</div>
|
||||
{!adminEditorDraft ? (
|
||||
<div className="launcher-request-empty">Select a request from the list to review it.</div>
|
||||
) : (
|
||||
<div className="launcher-request-admin-editor">
|
||||
<div className="launcher-request-admin-editor-grid">
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Title</span>
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={adminEditorDraft.title}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Category</span>
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={adminEditorDraft.category}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, category: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Original Submission</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
value={adminEditorDraft.sourceText}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, sourceText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Request Summary</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={adminEditorDraft.summary}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, summary: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Request Implementation Notes</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea-sm"
|
||||
value={adminEditorDraft.implementationNotes}
|
||||
onChange={(event) => updateAdminDraft((current) => ({ ...current, implementationNotes: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Request Tags</span>
|
||||
<div className="launcher-request-admin-tag-grid">
|
||||
{requestTags.map((tag) => {
|
||||
const isActive = adminEditorDraft.tags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={`draft-tag-${tag}`}
|
||||
type="button"
|
||||
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
|
||||
onClick={() => updateAdminDraft((current) => ({
|
||||
...current,
|
||||
tags: isActive
|
||||
? current.tags.filter((entry) => entry !== tag)
|
||||
: [...current.tags, tag].sort((left, right) => left.localeCompare(right)),
|
||||
}))}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
{(adminEditorDraft.analysis?.items || []).map((item, itemIndex) => (
|
||||
<section key={`analysis-item-${itemIndex}`} className="launcher-request-admin-analysis-item">
|
||||
<div className="launcher-request-admin-analysis-head">
|
||||
<div>
|
||||
<div className="launcher-request-admin-kicker">Review Item {itemIndex + 1}</div>
|
||||
<div className="launcher-request-admin-request-title">{item.title || `Request ${itemIndex + 1}`}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => openReviewDetailsPopup(adminEditorDraft)}
|
||||
>
|
||||
Open Review Popup
|
||||
</button>
|
||||
</div>
|
||||
<div className="launcher-request-admin-editor-grid">
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Item Title</span>
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.title || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Primary Category</span>
|
||||
<input
|
||||
type="text"
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.primaryCategory || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, primaryCategory: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Recommendation</span>
|
||||
<select
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.statusRecommendation || "needs_review")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, statusRecommendation: event.target.value }))}
|
||||
>
|
||||
<option value="needs_review">Needs Review</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="blocked">Blocked</option>
|
||||
<option value="duplicate">Duplicate</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Problem Type</span>
|
||||
<select
|
||||
className="launcher-request-filter-select"
|
||||
value={String(item.problemType || "unknown")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, problemType: event.target.value }))}
|
||||
>
|
||||
<option value="feature">Feature</option>
|
||||
<option value="bug">Bug</option>
|
||||
<option value="workflow">Workflow</option>
|
||||
<option value="performance">Performance</option>
|
||||
<option value="ux">UX</option>
|
||||
<option value="content">Content</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Confidence</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
className="launcher-request-filter-select"
|
||||
value={Number.isFinite(Number(item.confidence)) ? String(item.confidence) : ""}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
|
||||
...current,
|
||||
confidence: event.target.value === "" ? null : Number(event.target.value),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Standardized Tags</span>
|
||||
<div className="launcher-request-admin-tag-grid">
|
||||
{requestTags.map((tag) => {
|
||||
const isActive = Array.isArray(item.tags) && item.tags.includes(tag);
|
||||
return (
|
||||
<button
|
||||
key={`item-${itemIndex}-tag-${tag}`}
|
||||
type="button"
|
||||
className={`launcher-request-tag launcher-request-admin-tag-toggle ${isActive ? "is-active" : ""}`}
|
||||
onClick={() => updateAdminDraftItem(itemIndex, (current) => {
|
||||
const currentTags = Array.isArray(current.tags) ? current.tags : [];
|
||||
return {
|
||||
...current,
|
||||
tags: isActive
|
||||
? currentTags.filter((entry) => entry !== tag)
|
||||
: [...currentTags, tag].sort((left, right) => left.localeCompare(right)),
|
||||
};
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Review Rationale</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
value={String(item.reviewRationale || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, reviewRationale: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Possible Options</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||
value={Array.isArray(item.reviewOptions) ? item.reviewOptions.join("\n") : ""}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({
|
||||
...current,
|
||||
reviewOptions: event.target.value.split(/\r?\n/).map((entry) => entry.trim()).filter(Boolean),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Parsed Interpretation</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
value={String(item.parsedInterpretation || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, parsedInterpretation: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Implementation Approach</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea"
|
||||
value={String(item.implementationApproach || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, implementationApproach: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="launcher-request-admin-field">
|
||||
<span className="launcher-request-filter-label">Notes</span>
|
||||
<textarea
|
||||
className="launcher-request-textarea launcher-request-admin-textarea launcher-request-admin-textarea-sm"
|
||||
value={String(item.notes || "")}
|
||||
onChange={(event) => updateAdminDraftItem(itemIndex, (current) => ({ ...current, notes: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
))}
|
||||
<div className="launcher-request-admin-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-primary-btn"
|
||||
onClick={() => void handleSaveAdminRequest()}
|
||||
disabled={adminSaving}
|
||||
>
|
||||
{adminSaving ? "Saving..." : "Save Review Changes"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="launcher-secondary-btn"
|
||||
onClick={() => void handleApproveAdminRequest()}
|
||||
disabled={adminSaving}
|
||||
>
|
||||
Approve Request
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<section className="launcher-request-admin-card">
|
||||
<div className="launcher-request-admin-card-head">
|
||||
<h4 className="launcher-request-admin-card-title">Recent Logs</h4>
|
||||
|
|
@ -997,6 +1551,7 @@ function WorldshaperLauncher() {
|
|||
const isActiveRequest = requestEntry.status === "active";
|
||||
const requestDisplayState = getRequestDisplayStateLabel(requestEntry);
|
||||
const requestDisplayStateClassName = getRequestDisplayStateClassName(requestEntry);
|
||||
const reviewItem = getPrimaryAnalysisItem(requestEntry);
|
||||
const analysisStateLabel = requestEntry.status === "pending"
|
||||
? formatAnalysisStateLabel(requestEntry.analysis?.state)
|
||||
: "";
|
||||
|
|
@ -1027,6 +1582,11 @@ function WorldshaperLauncher() {
|
|||
<div className="launcher-request-entry-text">
|
||||
{requestEntry.status === "active" ? requestEntry.summary : requestEntry.sourceText}
|
||||
</div>
|
||||
{requestEntry.status === "pending" && reviewItem?.reviewRationale ? (
|
||||
<div className="launcher-request-entry-review-note">
|
||||
Review reason: {reviewItem.reviewRationale}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="launcher-request-entry-meta">
|
||||
{requestEntry.status === "pending" ? `${analysisStateLabel} | ` : ""}
|
||||
{formatRequestTimestamp(requestEntry.updatedAt || requestEntry.createdAt)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue