appends requirement edition mode
This commit is contained in:
@@ -1,39 +1,181 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from 'vue'
|
||||||
import Button from 'primevue/button'
|
import Button from 'primevue/button'
|
||||||
import Chip from 'primevue/chip'
|
import Chip from 'primevue/chip'
|
||||||
import Divider from 'primevue/divider'
|
import Divider from 'primevue/divider'
|
||||||
|
import InputText from 'primevue/inputtext'
|
||||||
import TabView from 'primevue/tabview'
|
import TabView from 'primevue/tabview'
|
||||||
import TabPanel from 'primevue/tabpanel'
|
import TabPanel from 'primevue/tabpanel'
|
||||||
import Tag from 'primevue/tag'
|
import Tag from 'primevue/tag'
|
||||||
|
import Textarea from 'primevue/textarea'
|
||||||
export interface Requirement {
|
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
|
||||||
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
|
|
||||||
stakeholders: string[]
|
|
||||||
flexibility: string
|
|
||||||
tolerances: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
requirement: Requirement
|
requirement: Requirement
|
||||||
}
|
}
|
||||||
|
|
||||||
defineProps<Props>()
|
interface FormState {
|
||||||
|
title: string
|
||||||
|
owner: string
|
||||||
|
status: RequirementStatus
|
||||||
|
priority: RequirementPriority
|
||||||
|
description: string
|
||||||
|
flexibility: string
|
||||||
|
tolerances: string
|
||||||
|
notes: string
|
||||||
|
stakeholders: string
|
||||||
|
acceptanceCriteria: string
|
||||||
|
impactedModules: string
|
||||||
|
blockers: string
|
||||||
|
}
|
||||||
|
|
||||||
const statusSeverity = (status: Requirement['status']) => {
|
const props = defineProps<Props>()
|
||||||
const map: Record<Requirement['status'], 'secondary' | 'info' | 'success' | 'danger' | 'contrast'> = {
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'save', requirement: Requirement): void
|
||||||
|
(e: 'dirty-change', value: boolean): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isEditing = ref(false)
|
||||||
|
|
||||||
|
const form = reactive<FormState>({
|
||||||
|
title: '',
|
||||||
|
owner: '',
|
||||||
|
status: 'Draft',
|
||||||
|
priority: 'Low',
|
||||||
|
description: '',
|
||||||
|
flexibility: '',
|
||||||
|
tolerances: '',
|
||||||
|
notes: '',
|
||||||
|
stakeholders: '',
|
||||||
|
acceptanceCriteria: '',
|
||||||
|
impactedModules: '',
|
||||||
|
blockers: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
|
const normalizeCsv = (value: string) => csvToList(value).join(',')
|
||||||
|
const normalizeMultiline = (value: string) => multilineToList(value).join('|')
|
||||||
|
|
||||||
|
const listToCsv = (value: string[]) => value.join(', ')
|
||||||
|
const listToMultiline = (value: string[]) => value.join('\n')
|
||||||
|
|
||||||
|
const csvToList = (value: string) =>
|
||||||
|
value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const multilineToList = (value: string) =>
|
||||||
|
value
|
||||||
|
.split('\n')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const resetForm = (requirement: Requirement) => {
|
||||||
|
form.title = requirement.title
|
||||||
|
form.owner = requirement.owner
|
||||||
|
form.status = requirement.status
|
||||||
|
form.priority = requirement.priority
|
||||||
|
form.description = requirement.description
|
||||||
|
form.flexibility = requirement.flexibility
|
||||||
|
form.tolerances = requirement.tolerances
|
||||||
|
form.notes = requirement.notes
|
||||||
|
form.stakeholders = listToCsv(requirement.stakeholders)
|
||||||
|
form.acceptanceCriteria = listToMultiline(requirement.acceptanceCriteria)
|
||||||
|
form.impactedModules = listToCsv(requirement.impactedModules)
|
||||||
|
form.blockers = listToMultiline(requirement.blockers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearErrors = () => {
|
||||||
|
Object.keys(errors).forEach((key) => delete errors[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
resetForm(props.requirement)
|
||||||
|
clearErrors()
|
||||||
|
isEditing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
isEditing.value = false
|
||||||
|
clearErrors()
|
||||||
|
resetForm(props.requirement)
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
clearErrors()
|
||||||
|
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
errors.title = 'Title is required.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.owner.trim()) {
|
||||||
|
errors.owner = 'Author is required.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!form.description.trim()) {
|
||||||
|
errors.description = 'Description is required.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (csvToList(form.stakeholders).length === 0) {
|
||||||
|
errors.stakeholders = 'At least one stakeholder is required.'
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (!validate()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditing.value = false
|
||||||
|
|
||||||
|
emit('save', {
|
||||||
|
...props.requirement,
|
||||||
|
title: form.title.trim(),
|
||||||
|
owner: form.owner.trim(),
|
||||||
|
status: form.status,
|
||||||
|
priority: form.priority,
|
||||||
|
description: form.description.trim(),
|
||||||
|
flexibility: form.flexibility.trim(),
|
||||||
|
tolerances: form.tolerances.trim(),
|
||||||
|
notes: form.notes.trim(),
|
||||||
|
stakeholders: csvToList(form.stakeholders),
|
||||||
|
acceptanceCriteria: multilineToList(form.acceptanceCriteria),
|
||||||
|
impactedModules: csvToList(form.impactedModules),
|
||||||
|
blockers: multilineToList(form.blockers),
|
||||||
|
})
|
||||||
|
|
||||||
|
clearErrors()
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDirty = computed(() => {
|
||||||
|
if (!isEditing.value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
form.title.trim() !== props.requirement.title ||
|
||||||
|
form.owner.trim() !== props.requirement.owner ||
|
||||||
|
form.status !== props.requirement.status ||
|
||||||
|
form.priority !== props.requirement.priority ||
|
||||||
|
form.description.trim() !== props.requirement.description ||
|
||||||
|
form.flexibility.trim() !== props.requirement.flexibility ||
|
||||||
|
form.tolerances.trim() !== props.requirement.tolerances ||
|
||||||
|
form.notes.trim() !== props.requirement.notes ||
|
||||||
|
normalizeCsv(form.stakeholders) !== props.requirement.stakeholders.join(',') ||
|
||||||
|
normalizeCsv(form.impactedModules) !== props.requirement.impactedModules.join(',') ||
|
||||||
|
normalizeMultiline(form.acceptanceCriteria) !==
|
||||||
|
props.requirement.acceptanceCriteria.join('|') ||
|
||||||
|
normalizeMultiline(form.blockers) !== props.requirement.blockers.join('|')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusSeverity = (status: RequirementStatus) => {
|
||||||
|
const map: Record<RequirementStatus, 'secondary' | 'info' | 'success' | 'danger' | 'contrast'> = {
|
||||||
Draft: 'secondary',
|
Draft: 'secondary',
|
||||||
'In Review': 'info',
|
'In Review': 'info',
|
||||||
Approved: 'success',
|
Approved: 'success',
|
||||||
@@ -43,8 +185,8 @@ const statusSeverity = (status: Requirement['status']) => {
|
|||||||
return map[status]
|
return map[status]
|
||||||
}
|
}
|
||||||
|
|
||||||
const prioritySeverity = (priority: Requirement['priority']) => {
|
const prioritySeverity = (priority: RequirementPriority) => {
|
||||||
const map: Record<Requirement['priority'], 'success' | 'info' | 'warn' | 'danger'> = {
|
const map: Record<RequirementPriority, 'success' | 'info' | 'warn' | 'danger'> = {
|
||||||
Low: 'success',
|
Low: 'success',
|
||||||
Medium: 'info',
|
Medium: 'info',
|
||||||
High: 'warn',
|
High: 'warn',
|
||||||
@@ -52,42 +194,113 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
}
|
}
|
||||||
return map[priority]
|
return map[priority]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.requirement,
|
||||||
|
(requirement) => {
|
||||||
|
resetForm(requirement)
|
||||||
|
clearErrors()
|
||||||
|
isEditing.value = false
|
||||||
|
},
|
||||||
|
{ immediate: true, deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(isDirty, (value) => {
|
||||||
|
emit('dirty-change', value)
|
||||||
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="detail-zone">
|
<section class="detail-zone">
|
||||||
<section class="detail-surface">
|
<section class="detail-surface">
|
||||||
<header class="detail-card__header">
|
<header
|
||||||
<div>
|
class="detail-card__header"
|
||||||
|
:class="{ 'detail-card__header--editing': isEditing }"
|
||||||
|
>
|
||||||
|
<div class="detail-card__main">
|
||||||
<p class="eyebrow">Selected requirement</p>
|
<p class="eyebrow">Selected requirement</p>
|
||||||
<h2>{{ requirement.title }}</h2>
|
<h2 v-if="!isEditing">{{ requirement.title }}</h2>
|
||||||
|
<InputText
|
||||||
|
v-else
|
||||||
|
v-model="form.title"
|
||||||
|
class="edit-input"
|
||||||
|
placeholder="Requirement title"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.title" class="error-text">{{ errors.title }}</small>
|
||||||
<p class="detail-card__subtitle">
|
<p class="detail-card__subtitle">
|
||||||
{{ requirement.reference }} - Owner: {{ requirement.owner }}
|
{{ requirement.reference }} - Owner: {{ isEditing ? form.owner : requirement.owner }}
|
||||||
</p>
|
</p>
|
||||||
|
<p class="detail-card__uuid">UUID: {{ requirement.uuid }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<Button label="Edit" icon="pi pi-pencil" severity="secondary" outlined />
|
<Button
|
||||||
|
v-if="!isEditing"
|
||||||
|
label="Edit"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="startEdit"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<TabView class="detail-tabs">
|
<TabView class="detail-tabs">
|
||||||
<TabPanel value="details" header="Details">
|
<TabPanel value="details" header="Details">
|
||||||
<div class="detail-badges">
|
<div class="detail-badges">
|
||||||
<Tag :value="requirement.status" :severity="statusSeverity(requirement.status)" />
|
<Tag
|
||||||
<Tag :value="requirement.priority" :severity="prioritySeverity(requirement.priority)" rounded />
|
v-if="!isEditing"
|
||||||
|
:value="requirement.status"
|
||||||
|
:severity="statusSeverity(requirement.status)"
|
||||||
|
/>
|
||||||
|
<select v-else v-model="form.status" class="edit-select">
|
||||||
|
<option value="Draft">Draft</option>
|
||||||
|
<option value="In Review">In Review</option>
|
||||||
|
<option value="Approved">Approved</option>
|
||||||
|
<option value="Blocked">Blocked</option>
|
||||||
|
<option value="Delivered">Delivered</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Tag
|
||||||
|
v-if="!isEditing"
|
||||||
|
:value="requirement.priority"
|
||||||
|
:severity="prioritySeverity(requirement.priority)"
|
||||||
|
rounded
|
||||||
|
/>
|
||||||
|
<select v-else v-model="form.priority" class="edit-select">
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Critical">Critical</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="detail-description">{{ requirement.description }}</p>
|
<p v-if="!isEditing" class="detail-description">{{ requirement.description }}</p>
|
||||||
|
<div v-else>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.description"
|
||||||
|
class="edit-textarea"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Requirement description"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.description" class="error-text">{{ errors.description }}</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<article v-if="requirement.blockers.length" class="info-panel info-panel--warning">
|
<article v-if="isEditing || requirement.blockers.length" class="info-panel info-panel--warning">
|
||||||
<h3>Identified blockers</h3>
|
<h3>Identified blockers</h3>
|
||||||
<ul class="blockers-list">
|
<ul v-if="!isEditing" class="blockers-list">
|
||||||
<li v-for="blocker in requirement.blockers" :key="blocker">
|
<li v-for="blocker in requirement.blockers" :key="blocker">
|
||||||
<i class="pi pi-exclamation-triangle" />
|
<i class="pi pi-exclamation-triangle" />
|
||||||
<span>{{ blocker }}</span>
|
<span>{{ blocker }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<Textarea
|
||||||
|
v-else
|
||||||
|
v-model="form.blockers"
|
||||||
|
class="edit-textarea"
|
||||||
|
rows="3"
|
||||||
|
placeholder="One blocker per line"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
@@ -95,24 +308,50 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<article class="info-panel">
|
<article class="info-panel">
|
||||||
<h3>Author</h3>
|
<h3>Author</h3>
|
||||||
<p>{{ requirement.owner }}</p>
|
<p v-if="!isEditing">{{ requirement.owner }}</p>
|
||||||
|
<div v-else>
|
||||||
|
<InputText v-model="form.owner" class="edit-input" placeholder="Author" />
|
||||||
|
<small v-if="errors.owner" class="error-text">{{ errors.owner }}</small>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="info-panel">
|
<article class="info-panel">
|
||||||
<h3>Stakeholders</h3>
|
<h3>Stakeholders</h3>
|
||||||
<div class="chip-row">
|
<div v-if="!isEditing" class="chip-row">
|
||||||
<Chip v-for="stakeholder in requirement.stakeholders" :key="stakeholder" :label="stakeholder" />
|
<Chip v-for="stakeholder in requirement.stakeholders" :key="stakeholder" :label="stakeholder" />
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<InputText
|
||||||
|
v-model="form.stakeholders"
|
||||||
|
class="edit-input"
|
||||||
|
placeholder="Comma separated stakeholders"
|
||||||
|
/>
|
||||||
|
<small v-if="errors.stakeholders" class="error-text">{{ errors.stakeholders }}</small>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="info-panel">
|
<article class="info-panel">
|
||||||
<h3>Flexibility</h3>
|
<h3>Flexibility</h3>
|
||||||
<p>{{ requirement.flexibility }}</p>
|
<p v-if="!isEditing">{{ requirement.flexibility }}</p>
|
||||||
|
<Textarea
|
||||||
|
v-else
|
||||||
|
v-model="form.flexibility"
|
||||||
|
class="edit-textarea"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Flexibility constraints"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="info-panel">
|
<article class="info-panel">
|
||||||
<h3>Tolerances</h3>
|
<h3>Tolerances</h3>
|
||||||
<p>{{ requirement.tolerances }}</p>
|
<p v-if="!isEditing">{{ requirement.tolerances }}</p>
|
||||||
|
<Textarea
|
||||||
|
v-else
|
||||||
|
v-model="form.tolerances"
|
||||||
|
class="edit-textarea"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Tolerance details"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -121,24 +360,44 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
<div class="detail-columns">
|
<div class="detail-columns">
|
||||||
<article class="info-panel">
|
<article class="info-panel">
|
||||||
<h3>Acceptance criteria</h3>
|
<h3>Acceptance criteria</h3>
|
||||||
<ul class="checklist">
|
<ul v-if="!isEditing" class="checklist">
|
||||||
<li v-for="item in requirement.acceptanceCriteria" :key="item">
|
<li v-for="item in requirement.acceptanceCriteria" :key="item">
|
||||||
<i class="pi pi-check-circle" />
|
<i class="pi pi-check-circle" />
|
||||||
<span>{{ item }}</span>
|
<span>{{ item }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<Textarea
|
||||||
|
v-else
|
||||||
|
v-model="form.acceptanceCriteria"
|
||||||
|
class="edit-textarea"
|
||||||
|
rows="6"
|
||||||
|
placeholder="One acceptance criterion per line"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="info-panel">
|
<article class="info-panel">
|
||||||
<h3>Impacted modules</h3>
|
<h3>Impacted modules</h3>
|
||||||
<div class="chip-row">
|
<div v-if="!isEditing" class="chip-row">
|
||||||
<Chip v-for="module in requirement.impactedModules" :key="module" :label="module" />
|
<Chip v-for="module in requirement.impactedModules" :key="module" :label="module" />
|
||||||
</div>
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-else
|
||||||
|
v-model="form.impactedModules"
|
||||||
|
class="edit-input"
|
||||||
|
placeholder="Comma separated modules"
|
||||||
|
/>
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<h3>Remarks</h3>
|
<h3>Remarks</h3>
|
||||||
<p>{{ requirement.notes }}</p>
|
<p v-if="!isEditing">{{ requirement.notes }}</p>
|
||||||
|
<Textarea
|
||||||
|
v-else
|
||||||
|
v-model="form.notes"
|
||||||
|
class="edit-textarea"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Remarks"
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -150,6 +409,21 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabView>
|
</TabView>
|
||||||
|
|
||||||
|
<footer v-if="isEditing" class="edit-footer">
|
||||||
|
<Button
|
||||||
|
label="Cancel"
|
||||||
|
icon="pi pi-times"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="cancelEdit"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
icon="pi pi-check"
|
||||||
|
@click="saveEdit"
|
||||||
|
/>
|
||||||
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -180,6 +454,24 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-card__header--editing {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card__header--editing .detail-card__main {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card__header--editing .detail-actions {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-card__main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -200,6 +492,13 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detail-card__uuid {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
color: #7c8aa5;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: 'Consolas', 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-actions {
|
.detail-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -207,6 +506,14 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid rgba(96, 117, 156, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-tabs {
|
.detail-tabs {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -218,6 +525,38 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:deep(.detail-tabs .p-tabview-nav-container),
|
||||||
|
:deep(.detail-tabs .p-tabview-nav-content),
|
||||||
|
:deep(.detail-tabs .p-tabview-nav) {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.detail-tabs .p-tabview-nav) {
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid rgba(96, 117, 156, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.detail-tabs .p-tabview-nav::before),
|
||||||
|
:deep(.detail-tabs .p-tabview-nav::after) {
|
||||||
|
content: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.detail-tabs .p-tabview-ink-bar) {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.detail-tabs [role='tab']) {
|
||||||
|
border-bottom: 2px solid transparent !important;
|
||||||
|
margin-bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.detail-tabs [role='tab'][aria-selected='true']) {
|
||||||
|
border-bottom-color: #2563eb !important;
|
||||||
|
color: #1d4ed8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.detail-tabs .p-tabview-panels) {
|
:deep(.detail-tabs .p-tabview-panels) {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -326,6 +665,27 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-input,
|
||||||
|
.edit-textarea,
|
||||||
|
.edit-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-select {
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.45rem 0.6rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-text {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
color: #b91c1c;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.detail-grid,
|
.detail-grid,
|
||||||
.detail-columns {
|
.detail-columns {
|
||||||
@@ -349,6 +709,3 @@ const prioritySeverity = (priority: Requirement['priority']) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,23 +3,7 @@ import { computed, ref } from 'vue'
|
|||||||
import InputText from 'primevue/inputtext'
|
import InputText from 'primevue/inputtext'
|
||||||
import Tree from 'primevue/tree'
|
import Tree from 'primevue/tree'
|
||||||
import Tag from 'primevue/tag'
|
import Tag from 'primevue/tag'
|
||||||
|
import type { Requirement, RequirementPriority, RequirementStatus } from '../stores/requirements'
|
||||||
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 {
|
export interface TreeNode {
|
||||||
key: string
|
key: string
|
||||||
@@ -91,7 +75,7 @@ const filteredTreeData = computed(() => {
|
|||||||
|
|
||||||
const statusSeverity = (status: Requirement['status']) => {
|
const statusSeverity = (status: Requirement['status']) => {
|
||||||
const map: Record<
|
const map: Record<
|
||||||
Requirement['status'],
|
RequirementStatus,
|
||||||
'secondary' | 'info' | 'success' | 'danger' | 'contrast'
|
'secondary' | 'info' | 'success' | 'danger' | 'contrast'
|
||||||
> = {
|
> = {
|
||||||
Draft: 'secondary',
|
Draft: 'secondary',
|
||||||
@@ -104,7 +88,7 @@ const statusSeverity = (status: Requirement['status']) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const prioritySeverity = (priority: Requirement['priority']) => {
|
const prioritySeverity = (priority: Requirement['priority']) => {
|
||||||
const map: Record<Requirement['priority'], 'success' | 'info' | 'warn' | 'danger'> = {
|
const map: Record<RequirementPriority, 'success' | 'info' | 'warn' | 'danger'> = {
|
||||||
Low: 'success',
|
Low: 'success',
|
||||||
Medium: 'info',
|
Medium: 'info',
|
||||||
High: 'warn',
|
High: 'warn',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type RequirementPriority = 'Low' | 'Medium' | 'High' | 'Critical'
|
|||||||
export interface Requirement {
|
export interface Requirement {
|
||||||
id: number
|
id: number
|
||||||
reference: string
|
reference: string
|
||||||
|
uuid: string
|
||||||
title: string
|
title: string
|
||||||
status: RequirementStatus
|
status: RequirementStatus
|
||||||
priority: RequirementPriority
|
priority: RequirementPriority
|
||||||
@@ -29,6 +30,7 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
reference: 'REQ-101',
|
reference: 'REQ-101',
|
||||||
|
uuid: '9f6c4f7a-9d2f-4dc0-8fa2-7ac2d79b0c11',
|
||||||
title: 'Full requirement traceability',
|
title: 'Full requirement traceability',
|
||||||
status: 'Approved',
|
status: 'Approved',
|
||||||
priority: 'Critical',
|
priority: 'Critical',
|
||||||
@@ -54,6 +56,7 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
{
|
{
|
||||||
id: 102,
|
id: 102,
|
||||||
reference: 'REQ-102',
|
reference: 'REQ-102',
|
||||||
|
uuid: '8aa1b6f5-2378-4c47-b490-2b92c7ee8b5f',
|
||||||
title: 'Business status management',
|
title: 'Business status management',
|
||||||
status: 'In Review',
|
status: 'In Review',
|
||||||
priority: 'High',
|
priority: 'High',
|
||||||
@@ -79,6 +82,7 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
{
|
{
|
||||||
id: 103,
|
id: 103,
|
||||||
reference: 'REQ-103',
|
reference: 'REQ-103',
|
||||||
|
uuid: '6f99d740-67bf-4a1f-88f2-4fd947f9cb8a',
|
||||||
title: 'Fast search and filtering',
|
title: 'Fast search and filtering',
|
||||||
status: 'Draft',
|
status: 'Draft',
|
||||||
priority: 'Medium',
|
priority: 'Medium',
|
||||||
@@ -104,6 +108,7 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
{
|
{
|
||||||
id: 104,
|
id: 104,
|
||||||
reference: 'REQ-104',
|
reference: 'REQ-104',
|
||||||
|
uuid: 'f47e5f4a-c54d-46c9-8fef-f5f56e5e4d7a',
|
||||||
title: 'Delivery scope by release',
|
title: 'Delivery scope by release',
|
||||||
status: 'Blocked',
|
status: 'Blocked',
|
||||||
priority: 'Critical',
|
priority: 'Critical',
|
||||||
@@ -152,11 +157,30 @@ export const useRequirementsStore = defineStore('requirements', () => {
|
|||||||
return { total, approvedCount, blockedCount, criticalCount }
|
return { total, approvedCount, blockedCount, criticalCount }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const updateRequirement = (updatedRequirement: Requirement) => {
|
||||||
|
const index = requirements.value.findIndex(
|
||||||
|
(requirement) => requirement.id === updatedRequirement.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requirements.value[index] = {
|
||||||
|
...updatedRequirement,
|
||||||
|
acceptanceCriteria: [...updatedRequirement.acceptanceCriteria],
|
||||||
|
impactedModules: [...updatedRequirement.impactedModules],
|
||||||
|
blockers: [...updatedRequirement.blockers],
|
||||||
|
stakeholders: [...updatedRequirement.stakeholders],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requirements,
|
requirements,
|
||||||
selectedId,
|
selectedId,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedRequirement,
|
selectedRequirement,
|
||||||
stats,
|
stats,
|
||||||
|
updateRequirement,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount } from 'vue'
|
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import Splitter from 'primevue/splitter'
|
import Splitter from 'primevue/splitter'
|
||||||
import SplitterPanel from 'primevue/splitterpanel'
|
import SplitterPanel from 'primevue/splitterpanel'
|
||||||
@@ -15,6 +15,8 @@ const requirementsStore = useRequirementsStore()
|
|||||||
const { requirements, selectedId, searchQuery, selectedRequirement, stats } =
|
const { requirements, selectedId, searchQuery, selectedRequirement, stats } =
|
||||||
storeToRefs(requirementsStore)
|
storeToRefs(requirementsStore)
|
||||||
|
|
||||||
|
const hasUnsavedChanges = ref(false)
|
||||||
|
|
||||||
const splitterDraggingClass = 'is-splitter-dragging'
|
const splitterDraggingClass = 'is-splitter-dragging'
|
||||||
|
|
||||||
const onSplitterResizeStart = () => {
|
const onSplitterResizeStart = () => {
|
||||||
@@ -81,6 +83,34 @@ const buildTreeData = (): TreeNode[] => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const treeData = computed(() => buildTreeData())
|
const treeData = computed(() => buildTreeData())
|
||||||
|
|
||||||
|
const onSaveRequirement = (updatedRequirement: Requirement) => {
|
||||||
|
requirementsStore.updateRequirement(updatedRequirement)
|
||||||
|
hasUnsavedChanges.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const onDetailDirtyChange = (value: boolean) => {
|
||||||
|
hasUnsavedChanges.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSelectRequirement = (nextId: number) => {
|
||||||
|
if (nextId === selectedId.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasUnsavedChanges.value) {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'You have unsaved changes. Switching requirement will discard them. Continue?'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUnsavedChanges.value = false
|
||||||
|
selectedId.value = nextId
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -98,12 +128,16 @@ const treeData = computed(() => buildTreeData())
|
|||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
:stats="stats"
|
:stats="stats"
|
||||||
@update:searchQuery="searchQuery = $event"
|
@update:searchQuery="searchQuery = $event"
|
||||||
@update:selectedId="selectedId = $event"
|
@update:selectedId="onSelectRequirement"
|
||||||
/>
|
/>
|
||||||
</SplitterPanel>
|
</SplitterPanel>
|
||||||
|
|
||||||
<SplitterPanel :size="72" :min-size="35">
|
<SplitterPanel :size="72" :min-size="35">
|
||||||
<RequirementDetail :requirement="selectedRequirement" />
|
<RequirementDetail
|
||||||
|
:requirement="selectedRequirement"
|
||||||
|
@save="onSaveRequirement"
|
||||||
|
@dirty-change="onDetailDirtyChange"
|
||||||
|
/>
|
||||||
</SplitterPanel>
|
</SplitterPanel>
|
||||||
</Splitter>
|
</Splitter>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user