works on requirements tree list
This commit is contained in:
332
kwa-ui/src/components/RequirementsTreeList.vue
Normal file
332
kwa-ui/src/components/RequirementsTreeList.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
|
import Tree from 'primevue/tree'
|
||||||
|
import Tag from 'primevue/tag'
|
||||||
|
|
||||||
|
export interface Requirement {
|
||||||
|
id: number
|
||||||
|
reference: string
|
||||||
|
title: string
|
||||||
|
status: 'Draft' | 'In Review' | 'Approved' | 'Blocked' | 'Delivered'
|
||||||
|
priority: 'Low' | 'Medium' | 'High' | 'Critical'
|
||||||
|
owner: string
|
||||||
|
progress: number
|
||||||
|
dueDate: string
|
||||||
|
description: string
|
||||||
|
rationale: string
|
||||||
|
acceptanceCriteria: string[]
|
||||||
|
impactedModules: string[]
|
||||||
|
blockers: string[]
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
key: string
|
||||||
|
label?: string
|
||||||
|
data?: Requirement
|
||||||
|
children?: TreeNode[]
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
treeData: TreeNode[]
|
||||||
|
searchQuery: string
|
||||||
|
selectedId: number
|
||||||
|
stats: {
|
||||||
|
total: number
|
||||||
|
approvedCount: number
|
||||||
|
blockedCount: number
|
||||||
|
criticalCount: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'update:searchQuery', value: string): void
|
||||||
|
(e: 'update:selectedId', value: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
|
||||||
|
const expandedKeys = ref<Record<string, boolean>>({
|
||||||
|
'SECT-ELE': true,
|
||||||
|
'SECT-INFO': true,
|
||||||
|
'SECT-MEC': true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedKey = ref<string>('101')
|
||||||
|
|
||||||
|
const filteredTreeData = computed(() => {
|
||||||
|
const query = props.searchQuery.trim().toLowerCase()
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return props.treeData
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterNode = (node: TreeNode): TreeNode | null => {
|
||||||
|
const matchesFilter =
|
||||||
|
node.label?.toLowerCase().includes(query) ||
|
||||||
|
(node.data?.reference.toLowerCase().includes(query) ?? false) ||
|
||||||
|
(node.data?.owner.toLowerCase().includes(query) ?? false)
|
||||||
|
|
||||||
|
const childrenMatch = node.children
|
||||||
|
?.map((child) => filterNode(child))
|
||||||
|
.filter((child) => child !== null) as TreeNode[]
|
||||||
|
|
||||||
|
if (matchesFilter || (childrenMatch && childrenMatch.length > 0)) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: childrenMatch && childrenMatch.length > 0 ? childrenMatch : node.children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.treeData
|
||||||
|
.map((node) => filterNode(node))
|
||||||
|
.filter((node) => node !== null) as TreeNode[]
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusSeverity = (status: Requirement['status']) => {
|
||||||
|
const map: Record<
|
||||||
|
Requirement['status'],
|
||||||
|
'secondary' | 'info' | 'success' | 'danger' | 'contrast'
|
||||||
|
> = {
|
||||||
|
Draft: 'secondary',
|
||||||
|
'In Review': 'info',
|
||||||
|
Approved: 'success',
|
||||||
|
Blocked: 'danger',
|
||||||
|
Delivered: 'contrast',
|
||||||
|
}
|
||||||
|
return map[status]
|
||||||
|
}
|
||||||
|
|
||||||
|
const prioritySeverity = (priority: Requirement['priority']) => {
|
||||||
|
const map: Record<Requirement['priority'], 'success' | 'info' | 'warn' | 'danger'> = {
|
||||||
|
Low: 'success',
|
||||||
|
Medium: 'info',
|
||||||
|
High: 'warn',
|
||||||
|
Critical: 'danger',
|
||||||
|
}
|
||||||
|
return map[priority]
|
||||||
|
}
|
||||||
|
|
||||||
|
const onNodeSelect = (node: TreeNode) => {
|
||||||
|
if (node.data?.id) {
|
||||||
|
emit('update:selectedId', node.data.id)
|
||||||
|
selectedKey.value = node.key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<aside class="requirements-tree">
|
||||||
|
<div class="stats-bar">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Total</span>
|
||||||
|
<strong>{{ stats.total }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Approved</span>
|
||||||
|
<strong>{{ stats.approvedCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Blocked</span>
|
||||||
|
<strong>{{ stats.blockedCount }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-label">Critical</span>
|
||||||
|
<strong>{{ stats.criticalCount }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="search-field">
|
||||||
|
<InputText
|
||||||
|
:value="searchQuery"
|
||||||
|
@input="(event) => $emit('update:searchQuery', (event.target as HTMLInputElement).value)"
|
||||||
|
placeholder="Filter by title, ref, owner..."
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="tree-container">
|
||||||
|
<Tree
|
||||||
|
:value="filteredTreeData"
|
||||||
|
:expanded-keys="expandedKeys"
|
||||||
|
@node-select="(node) => onNodeSelect(node)"
|
||||||
|
selection-mode="single"
|
||||||
|
:selection-keys="{ [selectedKey]: true }"
|
||||||
|
@update:expanded-keys="(keys) => (expandedKeys = keys)"
|
||||||
|
class="requirements-tree-view"
|
||||||
|
>
|
||||||
|
<template #default="{ node }">
|
||||||
|
<div class="tree-node-content">
|
||||||
|
<span class="node-label">{{ node.label }}</span>
|
||||||
|
<div v-if="node.data" class="node-tags">
|
||||||
|
<Tag :value="node.data.status" :severity="statusSeverity(node.data.status)" />
|
||||||
|
<Tag
|
||||||
|
:value="node.data.priority"
|
||||||
|
:severity="prioritySeverity(node.data.priority)"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tree>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.requirements-tree {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid rgba(96, 117, 156, 0.16);
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
box-shadow: 0 24px 60px rgba(34, 49, 77, 0.12);
|
||||||
|
backdrop-filter: blur(18px);
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid rgba(96, 117, 156, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
color: #667690;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item strong {
|
||||||
|
color: #12213a;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field {
|
||||||
|
width: 100%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field :is(.p-inputtext) {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
padding-left: 0.85rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-container {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view :deep(.p-tree) {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view :deep(.p-tree-container) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view :deep(.p-treenode) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view :deep(.p-treenode-content) {
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view :deep(.p-treenode-content:hover) {
|
||||||
|
background-color: rgba(78, 107, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree-view :deep(.p-treenode-content.p-treenode-selected) {
|
||||||
|
background-color: rgba(78, 107, 255, 0.16);
|
||||||
|
color: #12213a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #12213a;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-tags :deep(.p-tag) {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.stats-bar {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.requirements-tree {
|
||||||
|
padding: 0.75rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-field :is(.p-inputtext) {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item strong {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import RequirementsList from '../components/RequirementsList.vue'
|
import RequirementsTreeList from '../components/RequirementsTreeList.vue'
|
||||||
import RequirementDetail from '../components/RequirementDetail.vue'
|
import RequirementDetail from '../components/RequirementDetail.vue'
|
||||||
|
|
||||||
|
import type { TreeNode } from '../components/RequirementsTreeList.vue'
|
||||||
|
|
||||||
type RequirementStatus = 'Draft' | 'In Review' | 'Approved' | 'Blocked' | 'Delivered'
|
type RequirementStatus = 'Draft' | 'In Review' | 'Approved' | 'Blocked' | 'Delivered'
|
||||||
type RequirementPriority = 'Low' | 'Medium' | 'High' | 'Critical'
|
type RequirementPriority = 'Low' | 'Medium' | 'High' | 'Critical'
|
||||||
|
|
||||||
@@ -25,7 +27,8 @@ interface Requirement {
|
|||||||
notes: string
|
notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const requirements: Requirement[] = [
|
// All requirements data
|
||||||
|
const allRequirements: Requirement[] = [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
reference: 'REQ-101',
|
reference: 'REQ-101',
|
||||||
@@ -116,51 +119,82 @@ const requirements: Requirement[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Helper function to create tree nodes from requirements
|
||||||
|
const buildTreeData = (): TreeNode[] => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'SECT-ELE',
|
||||||
|
label: 'Electronics',
|
||||||
|
icon: 'pi pi-fw pi-bolt',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '101',
|
||||||
|
label: `${allRequirements[0]!.reference} - ${allRequirements[0]!.title}`,
|
||||||
|
data: allRequirements[0]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '102-child',
|
||||||
|
label: 'Sub-requirement: Power management',
|
||||||
|
data: { ...allRequirements[1]!, id: 1021, reference: 'REQ-102.1' } as Requirement,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SECT-INFO',
|
||||||
|
label: 'Software',
|
||||||
|
icon: 'pi pi-fw pi-code',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '102',
|
||||||
|
label: `${allRequirements[1]!.reference} - ${allRequirements[1]!.title}`,
|
||||||
|
data: allRequirements[1]!,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '103',
|
||||||
|
label: `${allRequirements[2]!.reference} - ${allRequirements[2]!.title}`,
|
||||||
|
data: allRequirements[2]!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'SECT-MEC',
|
||||||
|
label: 'Mechanical',
|
||||||
|
icon: 'pi pi-fw pi-cog',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: '104',
|
||||||
|
label: `${allRequirements[3]!.reference} - ${allRequirements[3]!.title}`,
|
||||||
|
data: allRequirements[3]!,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const treeData = buildTreeData()
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedId = ref(requirements[0]?.id ?? 0)
|
const selectedId = ref(101)
|
||||||
const fallbackRequirement = requirements[0]!
|
const fallbackRequirement = allRequirements[0]!
|
||||||
|
|
||||||
const filteredRequirements = computed(() => {
|
|
||||||
const query = searchQuery.value.trim().toLowerCase()
|
|
||||||
|
|
||||||
if (!query) {
|
|
||||||
return requirements
|
|
||||||
}
|
|
||||||
|
|
||||||
return requirements.filter((requirement) => {
|
|
||||||
return [
|
|
||||||
requirement.title,
|
|
||||||
requirement.reference,
|
|
||||||
requirement.owner,
|
|
||||||
requirement.status,
|
|
||||||
requirement.priority,
|
|
||||||
]
|
|
||||||
.join(' ')
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(query)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedRequirement = computed<Requirement>(() => {
|
const selectedRequirement = computed<Requirement>(() => {
|
||||||
return requirements.find((requirement) => requirement.id === selectedId.value) ?? fallbackRequirement
|
const found = allRequirements.find((req) => req.id === selectedId.value)
|
||||||
|
return found ?? fallbackRequirement
|
||||||
})
|
})
|
||||||
|
|
||||||
const stats = computed(() => {
|
const stats = computed(() => {
|
||||||
const total = requirements.length
|
const total = allRequirements.length
|
||||||
const approvedCount = requirements.filter((requirement) => requirement.status === 'Approved').length
|
const approvedCount = allRequirements.filter(
|
||||||
const blockedCount = requirements.filter((requirement) => requirement.status === 'Blocked').length
|
(requirement) => requirement.status === 'Approved'
|
||||||
const criticalCount = requirements.filter((requirement) => requirement.priority === 'Critical').length
|
).length
|
||||||
|
const blockedCount = allRequirements.filter(
|
||||||
|
(requirement) => requirement.status === 'Blocked'
|
||||||
|
).length
|
||||||
|
const criticalCount = allRequirements.filter(
|
||||||
|
(requirement) => requirement.priority === 'Critical'
|
||||||
|
).length
|
||||||
|
|
||||||
return { total, approvedCount, blockedCount, criticalCount }
|
return { total, approvedCount, blockedCount, criticalCount }
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(filteredRequirements, (items) => {
|
|
||||||
const firstItem = items[0]
|
|
||||||
|
|
||||||
if (firstItem && !items.some((item) => item.id === selectedId.value)) {
|
|
||||||
selectedId.value = firstItem.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -170,8 +204,8 @@ watch(filteredRequirements, (items) => {
|
|||||||
<p class="eyebrow">Requirements management</p>
|
<p class="eyebrow">Requirements management</p>
|
||||||
<h1>Project requirements dashboard</h1>
|
<h1>Project requirements dashboard</h1>
|
||||||
<p class="hero-card__text">
|
<p class="hero-card__text">
|
||||||
A focused interface to browse requirements, track progress, and display the business
|
A focused interface to browse requirements organized by section, track progress, and
|
||||||
details of the selected item.
|
display business details of the selected item.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,9 +216,8 @@ watch(filteredRequirements, (items) => {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="workspace">
|
<section class="workspace">
|
||||||
<!-- Requirements list sidebar component -->
|
<RequirementsTreeList
|
||||||
<RequirementsList
|
:tree-data="treeData"
|
||||||
:requirements="requirements"
|
|
||||||
:searchQuery="searchQuery"
|
:searchQuery="searchQuery"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
@@ -192,7 +225,6 @@ watch(filteredRequirements, (items) => {
|
|||||||
@update:selectedId="selectedId = $event"
|
@update:selectedId="selectedId = $event"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Requirement detail component -->
|
|
||||||
<RequirementDetail :requirement="selectedRequirement" />
|
<RequirementDetail :requirement="selectedRequirement" />
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
@@ -252,7 +284,7 @@ watch(filteredRequirements, (items) => {
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(19rem, 24rem) minmax(0, 1fr);
|
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user