Files
OPC/clarity.controlplane/src/opc/OpcPage.tsx
T
2026-04-26 11:32:23 -04:00

926 lines
39 KiB
TypeScript

import { useState, useMemo, useEffect, useCallback } from 'react';
import { GitCommitDrawer } from '../components/GitCommitDrawer';
import {
Button, Callout, Divider, Drawer, FormGroup,
HTMLSelect, HTMLTable, InputGroup, Intent,
NonIdealState, Spinner, Tab, Tabs, Tag, TextArea, Tooltip,
EditableText,
} from '@blueprintjs/core';
import type { Opc, OpcArtifact, OpcNote, OpcPriority, OpcStatus, OpcType, ArtifactType } from '../types/opc';
import {
listOpcs, createOpc, updateOpc, getNextNumber,
listNotes, addNote,
listArtifacts, createArtifact, updateArtifact, deleteArtifact,
getLinkedCommits, getPinnedCommits, pinCommit, unpinCommit, getBranchCoverageForRepo,
listGiteaBranches, createGiteaBranch,
aiAssist,
type LinkedCommit, type PinnedCommit, type BranchCoverage, type GiteaBranch,
} from '../api/opcApi';
// -- Label / intent maps -------------------------------------------------------
const TYPE_LABELS: Record<OpcType, string> = {
ChangeOrder: 'Change Order',
NonDevTask: 'Non-Dev Task',
QaTask: 'QA Task',
BusinessRequirement: 'Business Req.',
Feature: 'Feature',
General: 'General',
};
const TYPE_INTENT: Record<OpcType, Intent> = {
ChangeOrder: Intent.PRIMARY,
NonDevTask: Intent.NONE,
QaTask: Intent.WARNING,
BusinessRequirement: Intent.SUCCESS,
Feature: Intent.SUCCESS,
General: Intent.NONE,
};
const STATUS_INTENT: Record<OpcStatus, Intent> = {
New: Intent.PRIMARY,
InProgress: Intent.WARNING,
InReview: Intent.PRIMARY,
Blocked: Intent.DANGER,
Closed: Intent.SUCCESS,
Cancelled: Intent.NONE,
};
const STATUS_LABELS: Record<OpcStatus, string> = {
New: 'New',
InProgress: 'In Progress',
InReview: 'In Review',
Blocked: 'Blocked',
Closed: 'Closed',
Cancelled: 'Cancelled',
};
const PRIORITY_INTENT: Record<OpcPriority, Intent> = {
Low: Intent.NONE,
Medium: Intent.WARNING,
High: Intent.DANGER,
Critical: Intent.DANGER,
};
const ARTIFACT_TABS: { type: ArtifactType; label: string; placeholder: string }[] = [
{ type: 'BusinessRequirement', label: 'Business Req.', placeholder: 'Document business requirements, JAD/JAR outputs, acceptance criteria...' },
{ type: 'Rule', label: 'Rules', placeholder: 'Define rule engine rules, validation logic, business rules...' },
{ type: 'Spec', label: 'Spec', placeholder: 'Technical specification — data contracts, API shapes, architecture decisions...' },
{ type: 'Documentation', label: 'Docs', placeholder: 'End-user or developer documentation for this change...' },
{ type: 'QaTestPath', label: 'QA Test Paths', placeholder: 'Step-by-step QA test scenarios, edge cases, regression checks...' },
];
// -- SDLC delivery chain -------------------------------------------------------
const SDLC_STAGES: { branch: string; label: string; intent: Intent }[] = [
{ branch: 'develop', label: 'Dev', intent: Intent.PRIMARY },
{ branch: 'staging', label: 'Staging', intent: Intent.WARNING },
{ branch: 'uat', label: 'UAT', intent: Intent.DANGER },
{ branch: 'main', label: 'Production', intent: Intent.SUCCESS },
];
function deriveSdlcSummary(coverage: BranchCoverage[]): { label: string; intent: Intent } | null {
for (let i = SDLC_STAGES.length - 1; i >= 0; i--) {
const stage = SDLC_STAGES[i];
const hit = coverage.find(c => c.branch === stage.branch);
if (hit?.contains) return { label: stage.label, intent: stage.intent };
}
return null;
}
// Aggregate per-repo branch coverage into a single view.
// A stage is "reached" only when every repo that recognised at least one hash
// reports contains=true for that branch. Repos that recognised no hashes are
// excluded from the constraint (they have no code linked to this OPC).
const KNOWN_REPOS = ['Clarity', 'OPC', 'Gateway'] as const;
type RepoCoverage = { repoKey: string; coverage: BranchCoverage[] };
function aggregateCoverage(perRepo: RepoCoverage[]): BranchCoverage[] {
const active = perRepo.filter(r => r.coverage.length > 0);
if (active.length === 0) return [];
const branches = [...new Set(active.flatMap(r => r.coverage.map(c => c.branch)))];
return branches.map(branch => {
const entries = active
.map(r => r.coverage.find(c => c.branch === branch))
.filter((c): c is BranchCoverage => c !== undefined);
return {
branch,
contains: entries.length > 0 && entries.every(c => c.contains),
tipHash: entries[0]?.tipHash ?? '',
isHead: entries.some(c => c.isHead),
};
});
}
// -- Helpers -------------------------------------------------------------------
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function fmtDateTime(iso: string): string {
return new Date(iso).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
}
// -- AI Assist Box -------------------------------------------------------------
function AiAssistBox({ context, onApply }: { context?: string; onApply: (text: string) => void }) {
const [prompt, setPrompt] = useState('');
const [result, setResult] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const run = async () => {
if (!prompt.trim()) return;
setLoading(true); setError(null); setResult('');
try {
const text = await aiAssist(prompt.trim(), context);
setResult(text);
} catch (e) { setError(String(e)); }
finally { setLoading(false); }
};
return (
<div className="opc-ai-box">
<div className="opc-ai-label">
<span className="opc-field-label" style={{ color: '#7c3aed' }}>AI Assist</span>
</div>
<div className="opc-ai-input-row">
<InputGroup
placeholder="Ask AI to draft content (e.g. 'Write acceptance criteria for...')"
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); run(); } }}
rightElement={
<Tooltip content="Send prompt" placement="top">
<Button icon="arrow-up" minimal intent={Intent.PRIMARY} loading={loading}
onClick={run} disabled={!prompt.trim() || loading} />
</Tooltip>
}
/>
</div>
{error && <Callout intent={Intent.DANGER} icon="error" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>{error}</Callout>}
{result && (
<div className="opc-ai-result">
<pre className="opc-ai-result-text">{result}</pre>
<div className="opc-ai-result-actions">
<Button icon="arrow-up" intent={Intent.PRIMARY} text="Copy to field above" small
onClick={() => { onApply(result); setResult(''); setPrompt(''); }} />
<Button icon="cross" text="Dismiss" small minimal onClick={() => setResult('')} />
</div>
</div>
)}
</div>
);
}
// -- Artifact panel ------------------------------------------------------------
function ArtifactPanel({ opcId, opcNumber, artifactType, placeholder }: {
opcId: string; opcNumber: string; artifactType: ArtifactType; placeholder: string;
}) {
const [artifacts, setArtifacts] = useState<OpcArtifact[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editId, setEditId] = useState<string | null>(null);
const [editTitle, setEditTitle] = useState('');
const [editBody, setEditBody] = useState('');
const [adding, setAdding] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try { setArtifacts(await listArtifacts(opcId, artifactType)); }
catch { /* API may not be up */ }
finally { setLoading(false); }
}, [opcId, artifactType]);
useEffect(() => { load(); }, [load]);
const startAdd = () => { setEditId(null); setEditTitle(''); setEditBody(''); setAdding(true); };
const startEdit = (a: OpcArtifact) => { setAdding(false); setEditId(a.id); setEditTitle(a.title); setEditBody(a.content); };
const cancel = () => { setAdding(false); setEditId(null); };
const save = async () => {
setSaving(true);
try {
if (editId) {
const u = await updateArtifact(editId, { artifactType, title: editTitle, content: editBody });
setArtifacts(prev => prev.map(a => a.id === editId ? u : a));
} else {
const c = await createArtifact(opcId, { artifactType, title: editTitle, content: editBody });
setArtifacts(prev => [...prev, c]);
}
cancel();
} catch { /* no-op */ }
finally { setSaving(false); }
};
const remove = async (id: string) => {
await deleteArtifact(id);
setArtifacts(prev => prev.filter(a => a.id !== id));
};
const isEditing = adding || editId !== null;
if (loading) return <div style={{ padding: '2rem', textAlign: 'center' }}><Spinner size={20} /></div>;
return (
<div className="opc-artifact-panel">
{!isEditing && (
<>
{artifacts.length === 0 ? (
<NonIdealState icon="document" title="Nothing here yet"
description={`Add the first entry for ${opcNumber}.`}
action={<Button icon="plus" intent={Intent.PRIMARY} text="Add Entry" onClick={startAdd} />} />
) : (
<>
{artifacts.map(a => (
<div key={a.id} className="opc-artifact-card">
<div className="opc-artifact-card-header">
<strong>{a.title || '(untitled)'}</strong>
<div style={{ display: 'flex', gap: '0.3rem' }}>
<Button icon="edit" minimal small onClick={() => startEdit(a)} />
<Button icon="trash" minimal small intent={Intent.DANGER} onClick={() => remove(a.id)} />
</div>
</div>
<pre className="opc-artifact-body">{a.content}</pre>
<div className="opc-artifact-meta">{fmtDate(a.updatedAt)}</div>
</div>
))}
<Button icon="plus" text="Add Entry" minimal intent={Intent.PRIMARY} onClick={startAdd} style={{ marginTop: '0.5rem' }} />
</>
)}
</>
)}
{isEditing && (
<div className="opc-artifact-form">
<FormGroup label="Title">
<InputGroup placeholder="Brief title" value={editTitle}
onChange={e => setEditTitle(e.target.value)} autoFocus />
</FormGroup>
<FormGroup label="Content">
<TextArea fill rows={10} placeholder={placeholder} value={editBody}
onChange={e => setEditBody(e.target.value)} />
</FormGroup>
<AiAssistBox context={editBody || undefined} onApply={t => setEditBody(prev => prev ? `${prev}\n\n${t}` : t)} />
<div className="opc-drawer-actions" style={{ paddingTop: '0.75rem' }}>
<Button text="Cancel" onClick={cancel} minimal />
<Button intent={Intent.PRIMARY} text="Save" loading={saving}
onClick={save} disabled={!editBody.trim()} />
</div>
</div>
)}
</div>
);
}
// -- Repo badge ---------------------------------------------------------------
const REPO_INTENT: Record<string, Intent> = {
Clarity: Intent.PRIMARY,
OPC: Intent.WARNING,
Gateway: Intent.SUCCESS,
};
// -- Commit row (shared) -------------------------------------------------------
function CommitRow({ commit, onPin, isPinned, onViewDiff }: { commit: LinkedCommit; onPin?: () => void; isPinned?: boolean; onViewDiff?: (hash: string) => void }) {
return (
<div className="opc-commit-row">
<Tooltip content="View diff" placement="left">
<code className="opc-commit-hash" style={{ cursor: 'pointer' }}
onClick={() => onViewDiff ? onViewDiff(commit.hash) : navigator.clipboard.writeText(commit.hash)}>
{commit.shortHash}
</code>
</Tooltip>
{commit.repoKey && commit.repoKey !== 'unknown' && (
<Tag minimal round small
intent={REPO_INTENT[commit.repoKey] ?? Intent.NONE}
style={{ fontSize: '0.68rem', flexShrink: 0 }}>
{commit.repoKey}
</Tag>
)}
<div className="opc-commit-info">
<div className="opc-commit-msg">{commit.subject}</div>
<div className="opc-commit-meta">{commit.author} · {commit.date}</div>
{commit.files.length > 0 && (
<div className="opc-commit-files">
{commit.files.slice(0, 5).map(f => <span key={f}>{f}</span>)}
{commit.files.length > 5 && <span>+{commit.files.length - 5} more</span>}
</div>
)}
</div>
{onPin && (
<Tooltip content={isPinned ? 'Locked in' : 'Lock in this commit'} placement="left">
<Button
icon={isPinned ? 'lock' : 'pin'}
minimal small
intent={isPinned ? Intent.SUCCESS : Intent.NONE}
disabled={isPinned}
style={{ flexShrink: 0, alignSelf: 'center' }}
onClick={onPin}
/>
</Tooltip>
)}
</div>
);
}
// -- Commits tab ---------------------------------------------------------------
function CommitsTab({ opc, isActive }: { opc: Opc; isActive: boolean }) {
const [autoCommits, setAutoCommits] = useState<LinkedCommit[]>([]);
const [pinned, setPinned] = useState<PinnedCommit[]>([]);
const [coverage, setCoverage] = useState<BranchCoverage[]>([]);
const [loaded, setLoaded] = useState(false);
const [pinInput, setPinInput] = useState('');
const [pinning, setPinning] = useState(false);
const [pinError, setPinError] = useState<string | null>(null);
const [viewingHash, setViewingHash] = useState<string | null>(null);
// undefined = not yet loaded, null = not found, GiteaBranch = found
const [linkedBranch, setLinkedBranch] = useState<GiteaBranch | null | undefined>(undefined);
const [creatingBranch, setCreatingBranch] = useState(false);
const [branchError, setBranchError] = useState<string | null>(null);
useEffect(() => {
if (!isActive || loaded) return;
(async () => {
try {
const [auto, pins] = await Promise.all([
getLinkedCommits(opc.number),
getPinnedCommits(opc.id),
]);
setAutoCommits(auto);
setPinned(pins);
const allHashes = [...new Set([...auto.map(c => c.hash), ...pins.map(c => c.hash)])];
if (allHashes.length > 0) {
const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
}
} catch { /* non-critical — API may not be up */ }
finally { setLoaded(true); }
})();
// Load Gitea branch independently — don't block commit rendering
const opcTag = opc.number.replace('OPC # ', 'OPC-');
listGiteaBranches('all')
.then(branches => {
const found = branches.find(b => b.name.includes(opcTag));
setLinkedBranch(found ?? null);
})
.catch(() => setLinkedBranch(null));
}, [isActive, loaded, opc.id, opc.number]);
const handleCreateBranch = async () => {
setCreatingBranch(true); setBranchError(null);
try {
const branch = await createGiteaBranch(opc.number, opc.title);
setLinkedBranch(branch);
} catch (e) { setBranchError(String(e)); }
finally { setCreatingBranch(false); }
};
const handlePin = async () => {
if (!pinInput.trim()) return;
setPinning(true); setPinError(null);
try {
const c = await pinCommit(opc.id, pinInput.trim(), 'amadzarak');
setPinned(prev => [...prev, c]);
setPinInput('');
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])];
const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
} catch (e) { setPinError(String(e)); }
finally { setPinning(false); }
};
const handleLockIn = async (commit: LinkedCommit) => {
try {
const c = await pinCommit(opc.id, commit.hash, 'amadzarak');
setPinned(prev => [...prev, c]);
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...pinned.map(x => x.hash), c.hash])];
const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
} catch { /* no-op */ }
};
const handleUnpin = async (hash: string) => {
try {
await unpinCommit(opc.id, hash);
const remaining = pinned.filter(c => c.hash !== hash);
setPinned(remaining);
const allHashes = [...new Set([...autoCommits.map(x => x.hash), ...remaining.map(x => x.hash)])];
if (allHashes.length > 0) {
const perRepoCoverage = await Promise.all(
KNOWN_REPOS.map(async repoKey => ({
repoKey,
coverage: await getBranchCoverageForRepo(repoKey, allHashes).catch(() => [] as BranchCoverage[]),
}))
);
setCoverage(aggregateCoverage(perRepoCoverage));
} else {
setCoverage([]);
}
} catch { /* no-op */ }
};
if (!loaded) return <NonIdealState icon={<Spinner size={20} />} title="Scanning commits..." />;
return (
<div className="opc-tab-panel">
{/* Git branch */}
<div style={{ marginBottom: '0.75rem' }}>
<div className="opc-field-label" style={{ marginBottom: '0.4rem' }}>Git Branch</div>
{linkedBranch === undefined ? (
<Spinner size={14} />
) : linkedBranch !== null ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
<Tag icon="git-branch" minimal round intent={Intent.PRIMARY}
style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
{linkedBranch.name}
</Tag>
<a
href={`https://opc.clarity.test/ClarityStack/${linkedBranch.repoKey ?? 'Clarity'}/src/branch/${encodeURIComponent(linkedBranch.name)}`}
target="_blank" rel="noreferrer"
style={{ fontSize: '0.8rem', color: 'var(--bp4-intent-primary)' }}>
Open in Gitea
</a>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<span className="opc-empty-hint" style={{ margin: 0 }}>No branch yet</span>
<Button
icon="git-branch"
intent={Intent.PRIMARY}
text="Create Branch"
small
loading={creatingBranch}
onClick={handleCreateBranch}
/>
</div>
)}
{branchError && (
<Callout intent={Intent.DANGER} style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
{branchError}
</Callout>
)}
</div>
<Divider style={{ margin: '0.5rem 0 0.75rem' }} />
{/* SDLC Delivery Chain */}
{coverage.length > 0 && (() => {
const summary = deriveSdlcSummary(coverage);
return (
<div className="opc-delivery-chain">
<div className="opc-field-label" style={{ marginBottom: '0.6rem' }}>Delivery Chain</div>
<div className="opc-sdlc-pipeline">
{SDLC_STAGES.map((stage, i) => {
const hit = coverage.find(c => c.branch === stage.branch);
const reached = hit?.contains ?? false;
return (
<div key={stage.branch} className="opc-sdlc-stage-item">
{i > 0 && <span className="opc-sdlc-arrow"></span>}
<Tooltip content={
reached
? `All linked commits have reached ${stage.label}`
: hit
? `Not all linked commits have reached ${stage.label} yet`
: `${stage.label} branch not found locally`
}>
<Tag
intent={reached ? stage.intent : Intent.NONE}
icon={reached ? 'tick-circle' : 'circle'}
minimal={!reached}
round
>
{stage.label}
</Tag>
</Tooltip>
</div>
);
})}
</div>
{summary && (
<div className="opc-sdlc-furthest">
Furthest: <strong>{summary.label}</strong>
</div>
)}
</div>
);
})()}
{/* Auto-detected */}
<div className="opc-commits-section-label">
<span className="opc-field-label">Auto-linked</span>
<span className="opc-section-hint"> commits with <code>{opc.number}</code> in the message</span>
</div>
{autoCommits.length === 0
? <p className="opc-empty-hint">None found include <strong>{opc.number}</strong> in your commit message.</p>
: <div className="opc-commits-list">{autoCommits.map(c => <CommitRow key={c.hash} commit={c} onPin={() => handleLockIn(c)} isPinned={pinned.some(p => p.hash === c.hash)} onViewDiff={setViewingHash} />)}</div>
}
<Divider />
{/* Pinned */}
<div className="opc-commits-section-label">
<span className="opc-field-label">Pinned</span>
<span className="opc-section-hint"> manually linked commits</span>
</div>
{pinned.length === 0
? <p className="opc-empty-hint">No commits pinned yet.</p>
: (
<div className="opc-commits-list">
{pinned.map(c => (
<div key={c.hash} className="opc-commit-row">
<Tooltip content="View diff" placement="left">
<code className="opc-commit-hash" style={{ cursor: 'pointer' }}
onClick={() => setViewingHash(c.hash)}>
{c.shortHash}
</code>
</Tooltip>
<div className="opc-commit-info">
<div className="opc-commit-msg">{c.subject || c.hash}</div>
<div className="opc-commit-meta">{c.author} · pinned by {c.pinnedBy}</div>
</div>
<Button icon="unpin" minimal small intent={Intent.DANGER}
style={{ flexShrink: 0, alignSelf: 'center' }}
onClick={() => handleUnpin(c.hash)} />
</div>
))}
</div>
)
}
<Divider />
{/* Pin input */}
<div className="opc-field-label" style={{ marginBottom: '0.4rem' }}>Pin a commit</div>
{pinError && <Callout intent={Intent.DANGER} style={{ marginBottom: '0.5rem', fontSize: '0.8rem' }}>{pinError}</Callout>}
<div style={{ display: 'flex', gap: '0.5rem' }}>
<InputGroup fill placeholder="Enter commit hash (full or short)"
value={pinInput} onChange={e => setPinInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handlePin(); }} />
<Button intent={Intent.PRIMARY} text="Pin" loading={pinning}
disabled={!pinInput.trim()} onClick={handlePin} />
</div>
<GitCommitDrawer hash={viewingHash} onClose={() => setViewingHash(null)} />
</div>
);
}
// -- Detail drawer -------------------------------------------------------------
function OpcDetailDrawer({ opc, onClose, onUpdate }: {
opc: Opc; onClose: () => void; onUpdate: (updated: Opc) => void;
}) {
const [notes, setNotes] = useState<OpcNote[]>([]);
const [notesLoaded, setNotesLoaded] = useState(false);
const [noteText, setNoteText] = useState('');
const [savingNote, setSavingNote] = useState(false);
const [editDesc, setEditDesc] = useState(opc.description);
const [activeTab, setActiveTab] = useState('details');
const handleStatusChange = async (s: OpcStatus) => {
const updated = await updateOpc(opc.id, { status: s });
onUpdate({ ...opc, ...updated });
};
const handleDescConfirm = async (val: string) => {
const updated = await updateOpc(opc.id, { description: val });
onUpdate({ ...opc, ...updated });
};
const loadNotes = useCallback(async () => {
if (notesLoaded) return;
setNotes(await listNotes(opc.id));
setNotesLoaded(true);
}, [opc.id, notesLoaded]);
useEffect(() => { loadNotes(); }, [loadNotes]);
const handleAddNote = async () => {
if (!noteText.trim()) return;
setSavingNote(true);
try {
const note = await addNote(opc.id, 'amadzarak', noteText.trim());
setNotes(prev => [...prev, note]);
setNoteText('');
} finally { setSavingNote(false); }
};
const [copied, setCopied] = useState(false);
const commitRef = `${opc.number}: ${opc.title}`;
const handleCopy = () => {
navigator.clipboard.writeText(commitRef).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
return (
<Drawer isOpen onClose={onClose} size="760px"
title={
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<code className="opc-number-chip">{opc.number}</code>
<span style={{ fontWeight: 600, fontSize: '0.975rem' }}>{opc.title}</span>
<Tooltip content={copied ? 'Copied!' : 'Copy commit message'} placement="top">
<Button
icon={copied ? 'tick' : 'clipboard'}
minimal
small
intent={copied ? Intent.SUCCESS : Intent.NONE}
onClick={handleCopy}
style={{ marginLeft: '0.1rem' }}
/>
</Tooltip>
</span>
}
>
<div className="opc-drawer-body">
<div className="opc-meta-strip">
<Tag intent={TYPE_INTENT[opc.type]} minimal round>{TYPE_LABELS[opc.type]}</Tag>
<Tag intent={PRIORITY_INTENT[opc.priority]} minimal>{opc.priority}</Tag>
<div style={{ flex: 1 }} />
<span className="opc-meta-label">Status</span>
<HTMLSelect minimal value={opc.status}
onChange={e => handleStatusChange(e.target.value as OpcStatus)}
options={Object.entries(STATUS_LABELS).map(([v, l]) => ({ value: v, label: l }))} />
</div>
<Divider style={{ margin: '0.75rem 0' }} />
<Tabs animate selectedTabId={activeTab} onChange={id => setActiveTab(String(id))}>
{/* Details */}
<Tab id="details" title="Details" panel={
<div className="opc-tab-panel">
<div className="opc-field-label">Description</div>
<EditableText multiline minLines={3} maxLines={12} value={editDesc}
onChange={setEditDesc} onConfirm={handleDescConfirm}
placeholder="Click to add description..." className="opc-editable-desc" />
<AiAssistBox context={editDesc || opc.title}
onApply={t => setEditDesc(prev => prev ? `${prev}\n\n${t}` : t)} />
<div className="opc-field-grid">
<div><div className="opc-field-label">Assignee</div><div className="opc-field-value">{opc.assignee}</div></div>
<div><div className="opc-field-label">Created</div><div className="opc-field-value">{fmtDate(opc.createdAt)}</div></div>
<div><div className="opc-field-label">Updated</div><div className="opc-field-value">{fmtDate(opc.updatedAt)}</div></div>
</div>
</div>
} />
{/* Artifact tabs */}
{ARTIFACT_TABS.map(at => (
<Tab key={at.type} id={at.type} title={at.label} panel={
<div className="opc-tab-panel">
<ArtifactPanel opcId={opc.id} opcNumber={opc.number}
artifactType={at.type} placeholder={at.placeholder} />
</div>
} />
))}
{/* Notes */}
<Tab id="notes" title="Notes" panel={
<div className="opc-tab-panel">
<div className="opc-notes-feed">
{!notesLoaded
? <NonIdealState icon={<Spinner size={20} />} title="Loading..." />
: notes.length === 0
? <NonIdealState icon="comment" title="No notes yet" description="Add the first note below." />
: notes.map(n => (
<div key={n.id} className="opc-note-card">
<div className="opc-note-header">
<span className="opc-note-author">{n.author}</span>
<span className="opc-note-time">{fmtDateTime(n.timestamp)}</span>
</div>
<div className="opc-note-content">{n.content}</div>
</div>
))
}
</div>
<div className="opc-note-compose">
<TextArea fill rows={4} placeholder="Add a stakeholder note..."
value={noteText} onChange={e => setNoteText(e.target.value)} />
<AiAssistBox context={opc.description}
onApply={t => setNoteText(prev => prev ? `${prev}\n\n${t}` : t)} />
<Button intent={Intent.PRIMARY} text="Add Note" loading={savingNote}
style={{ marginTop: '0.5rem', alignSelf: 'flex-end' }}
onClick={handleAddNote} disabled={!noteText.trim()} />
</div>
</div>
} />
{/* Code & SDLC */}
<Tab id="commits" title="Code & SDLC" panel={
<CommitsTab opc={opc} isActive={activeTab === 'commits'} />
} />
</Tabs>
</div>
</Drawer>
);
}
// -- Create drawer -------------------------------------------------------------
interface CreateForm {
title: string; type: OpcType; priority: OpcPriority; assignee: string; description: string;
}
function OpcCreateDrawer({ onClose, onCreate }: { onClose: () => void; onCreate: (opc: Opc) => void }) {
const [form, setForm] = useState<CreateForm>({ title: '', type: 'General', priority: 'Medium', assignee: 'amadzarak', description: '' });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [nextNumber, setNextNumber] = useState<string | null>(null);
useEffect(() => {
getNextNumber().then(setNextNumber).catch(() => { /* non-critical */ });
}, []);
const patch = <K extends keyof CreateForm>(key: K, value: CreateForm[K]) => setForm(f => ({ ...f, [key]: value }));
const applyAi = (t: string) => patch('description', form.description ? `${form.description}\n\n${t}` : t);
const handleCreate = async () => {
setSaving(true); setError(null);
try { onCreate(await createOpc(form)); }
catch (e) { setError(String(e)); }
finally { setSaving(false); }
};
return (
<Drawer isOpen onClose={onClose} size="520px"
title={
<span style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
{nextNumber
? <code className="opc-number-chip">{nextNumber}</code>
: <Spinner size={14} />}
<span style={{ fontWeight: 600 }}>New OPC</span>
</span>
}
>
<div className="opc-drawer-body">
<Callout intent={Intent.PRIMARY} icon="info-sign" style={{ marginBottom: '1.25rem' }}>
An OPC tracks a change, task, or business requirement through its full lifecycle.
{nextNumber && <> This will be saved as <strong>{nextNumber}</strong>.</>}{' '}
Include the OPC number in commit messages to link check-ins automatically across any repo.
</Callout>
{error && <Callout intent={Intent.DANGER} style={{ marginBottom: '1rem' }}>{error}</Callout>}
<FormGroup label="Title" labelInfo="(required)">
<InputGroup placeholder="Short descriptive title" value={form.title}
onChange={e => patch('title', e.target.value)} autoFocus />
</FormGroup>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.75rem' }}>
<FormGroup label="Type">
<HTMLSelect fill value={form.type} onChange={e => patch('type', e.target.value as OpcType)}
options={[
{ value: 'ChangeOrder', label: 'Change Order' },
{ value: 'NonDevTask', label: 'Non-Dev Task' },
{ value: 'QaTask', label: 'QA Task' },
{ value: 'BusinessRequirement', label: 'Business Requirement (JAD / ARD)' },
{ value: 'Feature', label: 'Feature' },
{ value: 'General', label: 'General' },
]} />
</FormGroup>
<FormGroup label="Priority">
<HTMLSelect fill value={form.priority} onChange={e => patch('priority', e.target.value as OpcPriority)}
options={(['Low', 'Medium', 'High', 'Critical'] as OpcPriority[]).map(v => ({ value: v, label: v }))} />
</FormGroup>
</div>
<FormGroup label="Assignee">
<InputGroup value={form.assignee} onChange={e => patch('assignee', e.target.value)} />
</FormGroup>
<FormGroup label="Description">
<TextArea fill rows={6} placeholder="Goals, scope, acceptance criteria..."
value={form.description} onChange={e => patch('description', e.target.value)} />
</FormGroup>
<AiAssistBox context={form.title || undefined} onApply={applyAi} />
<div className="opc-drawer-actions">
<Button text="Cancel" onClick={onClose} minimal />
<Button intent={Intent.PRIMARY} text="Create OPC" loading={saving}
disabled={!form.title.trim() || saving} onClick={handleCreate} />
</div>
</div>
</Drawer>
);
}
// -- Main page -----------------------------------------------------------------
export default function OpcPage() {
const [opcs, setOpcs] = useState<Opc[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Opc | null>(null);
const [creating, setCreating] = useState(false);
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const load = useCallback(async () => {
setLoading(true);
try { setOpcs(await listOpcs()); }
catch { /* API may not be reachable in dev */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return opcs.filter(o => {
if (typeFilter !== 'all' && o.type !== typeFilter) return false;
if (statusFilter !== 'all' && o.status !== statusFilter) return false;
if (q && !o.title.toLowerCase().includes(q) && !o.number.toLowerCase().includes(q)) return false;
return true;
});
}, [opcs, typeFilter, statusFilter, search]);
const handleCreated = (opc: Opc) => { setOpcs(prev => [opc, ...prev]); setCreating(false); setSelected(opc); };
const handleUpdated = (updated: Opc) => { setOpcs(prev => prev.map(o => o.id === updated.id ? updated : o)); setSelected(updated); };
return (
<div>
<div className="page-header">
<div>
<h1>OPC</h1>
<p>Online Project Communication track changes, requirements, and SDLC delivery chain.</p>
</div>
<Button intent={Intent.PRIMARY} icon="plus" text="New OPC"
onClick={() => { setSelected(null); setCreating(true); }} />
</div>
<div className="opc-filter-bar">
<InputGroup leftIcon="search" placeholder="Search OPCs..." value={search}
onChange={e => setSearch(e.target.value)} style={{ width: 260 }} />
<HTMLSelect value={typeFilter} onChange={e => setTypeFilter(e.target.value)}
options={[
{ value: 'all', label: 'All Types' },
{ value: 'ChangeOrder', label: 'Change Orders' },
{ value: 'NonDevTask', label: 'Non-Dev Tasks' },
{ value: 'QaTask', label: 'QA Tasks' },
{ value: 'BusinessRequirement', label: 'Business Requirements' },
{ value: 'Feature', label: 'Features' },
{ value: 'General', label: 'General' },
]} />
<HTMLSelect value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
options={[
{ value: 'all', label: 'All Statuses' },
...Object.entries(STATUS_LABELS).map(([v, l]) => ({ value: v, label: l })),
]} />
<Button icon="refresh" minimal onClick={load} />
<span className="opc-count-badge">{filtered.length} OPC{filtered.length !== 1 ? 's' : ''}</span>
</div>
{loading ? (
<NonIdealState icon={<Spinner />} title="Loading OPCs..." />
) : filtered.length === 0 ? (
<NonIdealState icon="search" title="No OPCs match" description="Adjust your filters or create a new OPC." />
) : (
<HTMLTable className="opc-table" interactive striped bordered>
<thead>
<tr>
<th>Number</th><th>Title</th><th>Type</th><th>Priority</th><th>Status</th><th>Assignee</th><th>Updated</th>
</tr>
</thead>
<tbody>
{filtered.map(o => (
<tr key={o.id} onClick={() => { setCreating(false); setSelected(o); }}
style={{ cursor: 'pointer' }} className={selected?.id === o.id ? 'opc-row-selected' : undefined}>
<td><code className="opc-number-chip">{o.number}</code></td>
<td className="opc-title-cell">{o.title}</td>
<td><Tag intent={TYPE_INTENT[o.type]} minimal round>{TYPE_LABELS[o.type]}</Tag></td>
<td><Tag intent={PRIORITY_INTENT[o.priority]} minimal>{o.priority}</Tag></td>
<td><Tag intent={STATUS_INTENT[o.status]} round>{STATUS_LABELS[o.status]}</Tag></td>
<td>{o.assignee}</td>
<td className="opc-date-cell">{fmtDate(o.updatedAt)}</td>
</tr>
))}
</tbody>
</HTMLTable>
)}
{selected && !creating && (
<OpcDetailDrawer opc={selected} onClose={() => setSelected(null)} onUpdate={handleUpdated} />
)}
{creating && (
<OpcCreateDrawer onClose={() => setCreating(false)} onCreate={handleCreated} />
)}
</div>
);
}