works on requirements tree list

This commit is contained in:
Sylvain Schneider
2026-05-11 23:06:53 +02:00
parent a5a28f231c
commit a07d217953
2 changed files with 410 additions and 46 deletions

View 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>

View File

@@ -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[] = [
},
]
// 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 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) => {
return [
requirement.title,
requirement.reference,
requirement.owner,
requirement.status,
requirement.priority,
]
.join(' ')
.toLowerCase()
.includes(query)
})
})
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;
}