319 lines
6.8 KiB
Vue
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>
|
|
|
|
|