Files
kwa-ui/kwa-ui/src/components/RequirementsTreeList.vue
2026-05-12 00:51:28 +02:00

319 lines
6.8 KiB
Vue

<script setup lang="ts">
import { computed, ref } from 'vue'
import InputText from 'primevue/inputtext'
import Tree from 'primevue/tree'
import Tag from 'primevue/tag'
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
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<
RequirementStatus,
'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<RequirementPriority, '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.5rem;
padding: 0.75rem;
border: none;
border-radius: 0;
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.35rem;
padding-bottom: 0.35rem;
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.5rem;
padding-left: 0.85rem;
font-size: 0.9rem;
}
.tree-container {
flex: 1;
min-height: 0;
overflow-y: auto;
border-radius: 0.5rem;
}
.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.5rem;
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.25rem;
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.6rem;
gap: 0.4rem;
}
.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>