OPC # 0001: Extract OPC into standalone repo
This commit is contained in:
@@ -0,0 +1,813 @@
|
||||
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, getBranchCoverage,
|
||||
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...' },
|
||||
];
|
||||
|
||||
// -- 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>
|
||||
);
|
||||
}
|
||||
|
||||
// -- 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>
|
||||
<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) setCoverage(await getBranchCoverage(allHashes));
|
||||
} 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()
|
||||
.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])];
|
||||
setCoverage(await getBranchCoverage(allHashes));
|
||||
} 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])];
|
||||
setCoverage(await getBranchCoverage(allHashes));
|
||||
} catch { /* no-op */ }
|
||||
};
|
||||
|
||||
const handleUnpin = async (hash: string) => {
|
||||
try {
|
||||
await unpinCommit(opc.id, hash);
|
||||
setPinned(prev => prev.filter(c => c.hash !== hash));
|
||||
} 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/Clarity/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' }} />
|
||||
|
||||
{/* Branch coverage */}
|
||||
{coverage.length > 0 && (
|
||||
<div className="opc-branch-coverage">
|
||||
<div className="opc-field-label" style={{ marginBottom: '0.5rem' }}>Branch Coverage</div>
|
||||
<div className="opc-branch-chips">
|
||||
{coverage.map(b => (
|
||||
<Tooltip key={b.branch}
|
||||
content={b.contains
|
||||
? `All linked commits reachable from ${b.branch}`
|
||||
: `Not all linked commits have reached ${b.branch} yet`}>
|
||||
<Tag intent={b.contains ? Intent.SUCCESS : Intent.NONE}
|
||||
icon={b.contains ? 'tick-circle' : 'minus'}
|
||||
minimal={!b.contains} round>
|
||||
{b.branch}{b.isHead ? ' ★' : ''}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
</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>
|
||||
} />
|
||||
|
||||
{/* Commits */}
|
||||
<Tab id="commits" title="Commits" 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 on <code>develop</code> to link check-ins automatically.
|
||||
</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 — change orders, tasks, and business requirements.</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user