926 lines
39 KiB
TypeScript
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>
|
|
);
|
|
}
|