init commit
This commit is contained in:
411
frontend/src/app/features/detail/detail-panel.component.html
Normal file
411
frontend/src/app/features/detail/detail-panel.component.html
Normal file
@@ -0,0 +1,411 @@
|
||||
<div class="detail-panel">
|
||||
<div class="panel-header">
|
||||
<div class="header-top">
|
||||
<span class="node-id">{{ node.id }}</span>
|
||||
<span class="node-type" [style.color]="node.node_type === 'pillar' ? '#58a6ff' : '#8b949e'">
|
||||
{{ node.node_type }}
|
||||
</span>
|
||||
<button class="close-btn" (click)="close.emit()">x</button>
|
||||
</div>
|
||||
|
||||
@if (!editing) {
|
||||
<h2 class="node-title">{{ node.title }}</h2>
|
||||
} @else {
|
||||
<input class="edit-input title-input" [(ngModel)]="editData.title" placeholder="Title" />
|
||||
}
|
||||
|
||||
<div class="badges-row">
|
||||
<span class="badge priority-badge" [style.background]="getPriorityColor(node.priority)">
|
||||
{{ node.priority }}
|
||||
</span>
|
||||
<span class="badge status-badge" [style.background]="getStatusColor(node.status)">
|
||||
{{ node.status }}
|
||||
</span>
|
||||
@if (node.phase != null) {
|
||||
<span class="badge phase-badge">Phase {{ node.phase }}</span>
|
||||
}
|
||||
@if (node.gitea_issue_number && node.gitea_repo) {
|
||||
<a class="badge issue-badge" [href]="getGiteaIssueUrl()" target="_blank" title="Open in Gitea">
|
||||
#{{ node.gitea_issue_number }}
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-body">
|
||||
<!-- Quality Gates -->
|
||||
<div class="section quality-section">
|
||||
<h3 class="section-title">Quality Gates</h3>
|
||||
<div class="quality-badges">
|
||||
<span class="quality-badge auto" [class.pass]="node.traceable" [class.fail]="!node.traceable" title="Traceable (auto-calculated)">T</span>
|
||||
<span class="quality-badge toggle" [class.pass]="effectiveMeasurable" [class.fail]="!effectiveMeasurable" [title]="measurableForced ? 'Measurable (forced off — missing test spec)' : 'Measurable (click to toggle)'" (click)="toggleGate('measurable')">M</span>
|
||||
<span class="quality-badge toggle" [class.pass]="node.consistent" [class.fail]="!node.consistent" title="Consistent (click to toggle)" (click)="toggleGate('consistent')">C</span>
|
||||
<span class="quality-badge toggle" [class.pass]="node.single_purpose" [class.fail]="!node.single_purpose" title="Single Purpose (click to toggle)" (click)="toggleGate('single_purpose')">S</span>
|
||||
</div>
|
||||
@if (validationReport) {
|
||||
@if (validationReport.errors.length > 0) {
|
||||
<div class="validation-list errors">
|
||||
@for (err of validationReport.errors; track err) {
|
||||
<div class="validation-item error-item">{{ err }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (validationReport.warnings.length > 0) {
|
||||
<div class="validation-list warnings">
|
||||
@for (warn of validationReport.warnings; track warn) {
|
||||
<div class="validation-item warning-item">{{ warn }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (validationReport.valid && node.traceable && node.measurable && node.consistent && node.single_purpose) {
|
||||
<div class="validation-pass">All quality gates passed</div>
|
||||
} @else if (validationReport.valid && validationReport.warnings.length === 0) {
|
||||
<div class="validation-warn">No errors, but not all gates satisfied</div>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Description</h3>
|
||||
@if (!editing) {
|
||||
<p class="description">{{ node.description || 'No description' }}</p>
|
||||
} @else {
|
||||
<textarea class="edit-textarea" [(ngModel)]="editData.description" placeholder="Description..." rows="4"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Editing fields -->
|
||||
@if (editing) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">Properties</h3>
|
||||
<div class="edit-row">
|
||||
<label>Priority</label>
|
||||
<select class="edit-select" [(ngModel)]="editData.priority">
|
||||
@for (p of priorities; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Status</label>
|
||||
<select class="edit-select" [(ngModel)]="editData.status">
|
||||
@for (s of statuses; track s) {
|
||||
<option [value]="s">{{ s }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Phase</label>
|
||||
<input class="edit-input" type="number" [(ngModel)]="editData.phase" placeholder="Phase" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Gitea Repo</label>
|
||||
<input class="edit-input" [(ngModel)]="editData.gitea_repo" placeholder="owner/repo" />
|
||||
</div>
|
||||
<div class="edit-row">
|
||||
<label>Issue #</label>
|
||||
<input class="edit-input" type="number" [(ngModel)]="editData.gitea_issue_number" placeholder="Issue number" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Acceptance Criteria -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Acceptance Criteria</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block">{{ node.acceptance_criteria || 'No acceptance criteria defined' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea" [(ngModel)]="editData.acceptance_criteria" placeholder="Define what must be true for this requirement to be satisfied..." rows="4"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Test Specification -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Test Specification</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block">{{ node.test_spec || 'No test specification defined' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea mono" [(ngModel)]="editData.test_spec" placeholder="GIVEN...\nWHEN...\nTHEN..." rows="6"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Test Code -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Test Code</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block mono">{{ node.test_code || 'No test code' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea mono" [(ngModel)]="editData.test_code" placeholder="Test implementation..." rows="8"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Implementation Code -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">Implementation Code</h3>
|
||||
@if (!editing) {
|
||||
<pre class="code-block mono">{{ node.impl_code || 'No implementation code' }}</pre>
|
||||
} @else {
|
||||
<textarea class="edit-textarea mono" [(ngModel)]="editData.impl_code" placeholder="Implementation code..." rows="8"></textarea>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Parent Requirement -->
|
||||
@if (parentNode) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">Parent</h3>
|
||||
<div class="child-card clickable" (click)="goToNode(parentNode.id)">
|
||||
<div class="child-header">
|
||||
<span class="child-id">{{ parentNode.id }}</span>
|
||||
<span class="child-priority" [style.background]="getPriorityColor(parentNode.priority)">
|
||||
{{ parentNode.priority }}
|
||||
</span>
|
||||
<div class="child-quality">
|
||||
<span [class.pass]="parentNode.traceable" [class.fail]="!parentNode.traceable">T</span>
|
||||
<span [class.pass]="parentNode.measurable" [class.fail]="!parentNode.measurable">M</span>
|
||||
<span [class.pass]="parentNode.consistent" [class.fail]="!parentNode.consistent">C</span>
|
||||
<span [class.pass]="parentNode.single_purpose" [class.fail]="!parentNode.single_purpose">S</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="child-title">{{ parentNode.title }}</div>
|
||||
<span class="child-status" [style.color]="getStatusColor(parentNode.status)">
|
||||
{{ parentNode.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Child Requirements -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
Child Requirements
|
||||
<span class="child-count">({{ children.length }})</span>
|
||||
</h3>
|
||||
<div class="children-list">
|
||||
@for (child of children; track child.id) {
|
||||
<div class="child-card clickable" (click)="goToNode(child.id)">
|
||||
<div class="child-header">
|
||||
<span class="child-id">{{ child.id }}</span>
|
||||
<span class="child-priority" [style.background]="getPriorityColor(child.priority)">
|
||||
{{ child.priority }}
|
||||
</span>
|
||||
<div class="child-quality">
|
||||
<span [class.pass]="child.traceable" [class.fail]="!child.traceable">T</span>
|
||||
<span [class.pass]="child.measurable" [class.fail]="!child.measurable">M</span>
|
||||
<span [class.pass]="child.consistent" [class.fail]="!child.consistent">C</span>
|
||||
<span [class.pass]="child.single_purpose" [class.fail]="!child.single_purpose">S</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="child-title">{{ child.title }}</div>
|
||||
@if (child.description) {
|
||||
<div class="child-desc">{{ child.description }}</div>
|
||||
}
|
||||
<span class="child-status" [style.color]="getStatusColor(child.status)">
|
||||
{{ child.status }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Child Form -->
|
||||
@if (showAddChild) {
|
||||
<div class="section add-child-form">
|
||||
<h3 class="section-title">New Child Requirement</h3>
|
||||
<input class="edit-input" [(ngModel)]="$any(newChild).id" placeholder="ID (e.g. {{ node.id }}.1)" />
|
||||
<input class="edit-input" [(ngModel)]="newChild.title" placeholder="Title" />
|
||||
<textarea class="edit-textarea" [(ngModel)]="newChild.description" placeholder="Description..." rows="3"></textarea>
|
||||
<div class="edit-row">
|
||||
<label>Priority</label>
|
||||
<select class="edit-select" [(ngModel)]="newChild.priority">
|
||||
@for (p of priorities; track p) {
|
||||
<option [value]="p">{{ p }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
@if (createError) {
|
||||
<div class="create-error">{{ createError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button class="action-btn primary" (click)="createChild()">Create</button>
|
||||
<button class="action-btn" (click)="toggleAddChild()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Also Satisfies (outgoing links) -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
Also Satisfies
|
||||
<span class="child-count">({{ outgoingLinks.length }})</span>
|
||||
</h3>
|
||||
<div class="links-list">
|
||||
@for (link of outgoingLinks; track link.target_id) {
|
||||
<div class="link-card">
|
||||
<span class="link-id" (click)="goToNode(link.target_id)">{{ link.target_id }}</span>
|
||||
<button class="link-remove" (click)="deleteLink(link.source_id, link.target_id)" title="Remove link">x</button>
|
||||
</div>
|
||||
}
|
||||
@if (outgoingLinks.length === 0 && !showAddLink) {
|
||||
<div class="no-children">No satisfaction links</div>
|
||||
}
|
||||
</div>
|
||||
@if (showAddLink) {
|
||||
<div class="add-link-form">
|
||||
<div class="typeahead-container">
|
||||
<input
|
||||
class="edit-input"
|
||||
[(ngModel)]="newLinkTargetId"
|
||||
placeholder="Search by ID or title..."
|
||||
(input)="onLinkSearchInput()"
|
||||
(focus)="onLinkSearchFocus()"
|
||||
(keydown)="onLinkSearchKeydown($event)"
|
||||
(keydown.enter)="highlightedIndex < 0 && createLink()"
|
||||
(blur)="showLinkDropdown = false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
@if (showLinkDropdown) {
|
||||
<div class="typeahead-dropdown">
|
||||
@for (req of filteredRequirements; track req.id; let i = $index) {
|
||||
<div
|
||||
class="typeahead-option"
|
||||
[class.highlighted]="i === highlightedIndex"
|
||||
(mousedown)="selectLinkTarget(req)"
|
||||
>
|
||||
<span class="typeahead-id">{{ req.id }}</span>
|
||||
<span class="typeahead-title">{{ req.title }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (linkError) {
|
||||
<div class="create-error">{{ linkError }}</div>
|
||||
}
|
||||
<div class="form-actions">
|
||||
<button class="action-btn primary small" (click)="createLink()">Link</button>
|
||||
<button class="action-btn small" (click)="toggleAddLink()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<button class="action-btn small" (click)="toggleAddLink()">+ Add Link</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Satisfied By (incoming links) -->
|
||||
@if (incomingLinks.length > 0) {
|
||||
<div class="section">
|
||||
<h3 class="section-title">
|
||||
Satisfied By
|
||||
<span class="child-count">({{ incomingLinks.length }})</span>
|
||||
</h3>
|
||||
<div class="links-list">
|
||||
@for (link of incomingLinks; track link.source_id) {
|
||||
<div class="link-card clickable" (click)="goToNode(link.source_id)">
|
||||
<span class="link-id">{{ link.source_id }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="section meta-section">
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Created</span>
|
||||
<span class="meta-value">{{ node.created_at | date:'short' }}</span>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="meta-label">Updated</span>
|
||||
<span class="meta-value">{{ node.updated_at | date:'short' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- History -->
|
||||
<div class="section">
|
||||
<h3 class="section-title clickable-title" (click)="toggleHistory()">
|
||||
<span>History</span>
|
||||
@if (historyList.length > 0) {
|
||||
<span class="child-count">({{ historyList.length }})</span>
|
||||
}
|
||||
<span class="toggle-arrow">{{ showHistory ? '\u25BC' : '\u25B6' }}</span>
|
||||
</h3>
|
||||
@if (showHistory) {
|
||||
<div class="history-list">
|
||||
@for (entry of historyList; track entry.id) {
|
||||
<div class="history-item" [class.active]="selectedVersion?.version === entry.version" (click)="viewVersion(entry.version)">
|
||||
<div class="history-header">
|
||||
<span class="history-version">v{{ entry.version }}</span>
|
||||
<span class="history-time">{{ entry.created_at | date:'short' }}</span>
|
||||
</div>
|
||||
<div class="history-summary">{{ entry.change_summary || 'No summary' }}</div>
|
||||
</div>
|
||||
} @empty {
|
||||
<div class="no-children">No history yet</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (selectedVersion) {
|
||||
<div class="history-detail">
|
||||
<h4 class="history-detail-title">Version {{ selectedVersion.version }} Details</h4>
|
||||
<div class="history-fields">
|
||||
<div class="history-field" [class.changed]="isFieldChanged('title')">
|
||||
<span class="history-field-label">Title</span>
|
||||
<span class="history-field-value">{{ selectedVersion.title }}</span>
|
||||
</div>
|
||||
<div class="history-field" [class.changed]="isFieldChanged('description')">
|
||||
<span class="history-field-label">Description</span>
|
||||
<span class="history-field-value">{{ selectedVersion.description || '(empty)' }}</span>
|
||||
</div>
|
||||
<div class="history-field" [class.changed]="isFieldChanged('priority')">
|
||||
<span class="history-field-label">Priority</span>
|
||||
<span class="history-field-value">{{ selectedVersion.priority }}</span>
|
||||
</div>
|
||||
<div class="history-field" [class.changed]="isFieldChanged('status')">
|
||||
<span class="history-field-label">Status</span>
|
||||
<span class="history-field-value">{{ selectedVersion.status }}</span>
|
||||
</div>
|
||||
@if (isFieldChanged('acceptance_criteria')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Acceptance Criteria</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.acceptance_criteria || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldChanged('test_spec')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Test Spec</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.test_spec || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldChanged('test_code')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Test Code</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.test_code || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
@if (isFieldChanged('impl_code')) {
|
||||
<div class="history-field changed">
|
||||
<span class="history-field-label">Impl Code</span>
|
||||
<pre class="history-field-pre">{{ selectedVersion.impl_code || '(empty)' }}</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button class="action-btn primary small" (click)="restoreVersion(selectedVersion.version)">Restore this version</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
@if (!editing) {
|
||||
<button class="action-btn primary" (click)="startEdit()">Edit</button>
|
||||
<button class="action-btn" (click)="toggleAddChild()">Add Child</button>
|
||||
<button class="action-btn danger" (click)="deleteNode()">Delete</button>
|
||||
} @else {
|
||||
<button class="action-btn primary" (click)="saveEdit()">Save</button>
|
||||
<button class="action-btn" (click)="cancelEdit()">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user