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">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import RequirementsList from '../components/RequirementsList.vue'
|
||||
import RequirementsTreeList from '../components/RequirementsTreeList.vue'
|
||||
import RequirementDetail from '../components/RequirementDetail.vue'
|
||||
|
||||
import type { TreeNode } from '../components/RequirementsTreeList.vue'
|
||||
|
||||
type RequirementStatus = 'Draft' | 'In Review' | 'Approved' | 'Blocked' | 'Delivered'
|
||||
type RequirementPriority = 'Low' | 'Medium' | 'High' | 'Critical'
|
||||
|
||||
@@ -25,7 +27,8 @@ interface Requirement {
|
||||
notes: string
|
||||
}
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
// All requirements data
|
||||
const allRequirements: Requirement[] = [
|
||||
{
|
||||
id: 101,
|
||||
reference: 'REQ-101',
|
||||
@@ -116,51 +119,82 @@ const requirements: Requirement[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const searchQuery = ref('')
|
||||
const selectedId = ref(requirements[0]?.id ?? 0)
|
||||
const fallbackRequirement = requirements[0]!
|
||||
|
||||
const filteredRequirements = computed(() => {
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
|
||||
if (!query) {
|
||||
return requirements
|
||||
}
|
||||
|
||||
return requirements.filter((requirement) => {
|
||||
// Helper function to create tree nodes from requirements
|
||||
const buildTreeData = (): TreeNode[] => {
|
||||
return [
|
||||
requirement.title,
|
||||
requirement.reference,
|
||||
requirement.owner,
|
||||
requirement.status,
|
||||
requirement.priority,
|
||||
{
|
||||
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]!,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(query)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const treeData = buildTreeData()
|
||||
const searchQuery = ref('')
|
||||
const selectedId = ref(101)
|
||||
const fallbackRequirement = allRequirements[0]!
|
||||
|
||||
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 total = requirements.length
|
||||
const approvedCount = requirements.filter((requirement) => requirement.status === 'Approved').length
|
||||
const blockedCount = requirements.filter((requirement) => requirement.status === 'Blocked').length
|
||||
const criticalCount = requirements.filter((requirement) => requirement.priority === 'Critical').length
|
||||
const total = allRequirements.length
|
||||
const approvedCount = allRequirements.filter(
|
||||
(requirement) => requirement.status === 'Approved'
|
||||
).length
|
||||
const blockedCount = allRequirements.filter(
|
||||
(requirement) => requirement.status === 'Blocked'
|
||||
).length
|
||||
const criticalCount = allRequirements.filter(
|
||||
(requirement) => requirement.priority === 'Critical'
|
||||
).length
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -170,8 +204,8 @@ watch(filteredRequirements, (items) => {
|
||||
<p class="eyebrow">Requirements management</p>
|
||||
<h1>Project requirements dashboard</h1>
|
||||
<p class="hero-card__text">
|
||||
A focused interface to browse requirements, track progress, and display the business
|
||||
details of the selected item.
|
||||
A focused interface to browse requirements organized by section, track progress, and
|
||||
display business details of the selected item.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -182,9 +216,8 @@ watch(filteredRequirements, (items) => {
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<!-- Requirements list sidebar component -->
|
||||
<RequirementsList
|
||||
:requirements="requirements"
|
||||
<RequirementsTreeList
|
||||
:tree-data="treeData"
|
||||
:searchQuery="searchQuery"
|
||||
:selectedId="selectedId"
|
||||
:stats="stats"
|
||||
@@ -192,7 +225,6 @@ watch(filteredRequirements, (items) => {
|
||||
@update:selectedId="selectedId = $event"
|
||||
/>
|
||||
|
||||
<!-- Requirement detail component -->
|
||||
<RequirementDetail :requirement="selectedRequirement" />
|
||||
</section>
|
||||
</section>
|
||||
@@ -252,7 +284,7 @@ watch(filteredRequirements, (items) => {
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(19rem, 24rem) minmax(0, 1fr);
|
||||
grid-template-columns: minmax(18rem, 22rem) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user