Added history track
This commit is contained in:
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.models import (
|
from src.models import (
|
||||||
TokenResponse, UserInfo, GroupResponse,
|
TokenResponse, UserInfo, GroupResponse,
|
||||||
TagResponse, RequirementResponse, PriorityResponse,
|
TagResponse, RequirementResponse, PriorityResponse,
|
||||||
RequirementCreateRequest, RequirementUpdateRequest,
|
RequirementCreateRequest, RequirementUpdateRequest, RequirementHistoryResponse,
|
||||||
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
||||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
||||||
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
|
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
|
||||||
@@ -793,6 +793,16 @@ def _build_requirement_response(req) -> RequirementResponse:
|
|||||||
validated_at = latest_validation.created_at
|
validated_at = latest_validation.created_at
|
||||||
validation_version = latest_validation.req_version_snapshot
|
validation_version = latest_validation.req_version_snapshot
|
||||||
|
|
||||||
|
# Get author (creator) display name
|
||||||
|
author_username = None
|
||||||
|
if req.user:
|
||||||
|
author_username = _get_display_name(req.user)
|
||||||
|
|
||||||
|
# Get last editor display name
|
||||||
|
last_editor_username = None
|
||||||
|
if req.last_editor:
|
||||||
|
last_editor_username = _get_display_name(req.last_editor)
|
||||||
|
|
||||||
return RequirementResponse(
|
return RequirementResponse(
|
||||||
id=req.id,
|
id=req.id,
|
||||||
project_id=req.project_id,
|
project_id=req.project_id,
|
||||||
@@ -808,6 +818,8 @@ def _build_requirement_response(req) -> RequirementResponse:
|
|||||||
validated_by=validated_by,
|
validated_by=validated_by,
|
||||||
validated_at=validated_at,
|
validated_at=validated_at,
|
||||||
validation_version=validation_version,
|
validation_version=validation_version,
|
||||||
|
author_username=author_username,
|
||||||
|
last_editor_username=last_editor_username,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -1047,6 +1059,43 @@ async def delete_requirement(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/requirements/{requirement_id}/history", response_model=List[RequirementHistoryResponse])
|
||||||
|
async def get_requirement_history(
|
||||||
|
requirement_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get the version history for a requirement.
|
||||||
|
Returns all previous versions ordered by version (newest first).
|
||||||
|
Note: Group changes are not tracked in history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requirement_id: The requirement to get history for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of historical versions with tag, priority, and editor info.
|
||||||
|
"""
|
||||||
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
|
# Check if requirement exists
|
||||||
|
req_repo = RequirementRepository(db)
|
||||||
|
requirement = await req_repo.get_by_id(requirement_id)
|
||||||
|
if not requirement:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Requirement with id {requirement_id} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify user is a member of the requirement's project
|
||||||
|
await _verify_project_membership(requirement.project_id, user.id, db)
|
||||||
|
|
||||||
|
# Get history
|
||||||
|
history = await req_repo.get_history(requirement_id)
|
||||||
|
|
||||||
|
return [RequirementHistoryResponse(**h) for h in history]
|
||||||
|
|
||||||
|
|
||||||
# ===========================================
|
# ===========================================
|
||||||
# Validation Endpoints
|
# Validation Endpoints
|
||||||
# ===========================================
|
# ===========================================
|
||||||
@@ -1073,7 +1122,7 @@ async def create_validation(
|
|||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Create a new validation for a requirement.
|
Create a new validation for a requirement.
|
||||||
Only auditors (role_id=2) and admins (role_id=1) can validate requirements.
|
Only auditors (role_id=2) can validate requirements.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
requirement_id: The requirement to validate
|
requirement_id: The requirement to validate
|
||||||
@@ -1084,8 +1133,8 @@ async def create_validation(
|
|||||||
"""
|
"""
|
||||||
user = await _get_current_user_db(request, db)
|
user = await _get_current_user_db(request, db)
|
||||||
|
|
||||||
# Only auditors (role_id=2) and admins (role_id=1) can validate
|
# Only auditors (role_id=2) can validate
|
||||||
_require_role(user, [1, 2], "validate requirements")
|
_require_role(user, [2], "validate requirements")
|
||||||
|
|
||||||
# Check if requirement exists and user has access
|
# Check if requirement exists and user has access
|
||||||
req_repo = RequirementRepository(db)
|
req_repo = RequirementRepository(db)
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ class RequirementResponse(BaseModel):
|
|||||||
validated_by: Optional[str] = None # Username of the validator
|
validated_by: Optional[str] = None # Username of the validator
|
||||||
validated_at: Optional[datetime] = None # When the latest validation was made
|
validated_at: Optional[datetime] = None # When the latest validation was made
|
||||||
validation_version: Optional[int] = None # Version at which requirement was validated
|
validation_version: Optional[int] = None # Version at which requirement was validated
|
||||||
|
author_username: Optional[str] = None # Display name of who created the requirement
|
||||||
|
last_editor_username: Optional[str] = None # Display name of who last edited the requirement
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -240,6 +242,22 @@ class RequirementUpdateRequest(BaseModel):
|
|||||||
group_ids: Optional[List[int]] = None
|
group_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class RequirementHistoryResponse(BaseModel):
|
||||||
|
"""Response schema for requirement history (previous versions)."""
|
||||||
|
history_id: int
|
||||||
|
version: Optional[int] = None
|
||||||
|
req_name: Optional[str] = None
|
||||||
|
req_desc: Optional[str] = None
|
||||||
|
tag_code: Optional[str] = None
|
||||||
|
priority_name: Optional[str] = None
|
||||||
|
edited_by_username: Optional[str] = None
|
||||||
|
valid_from: Optional[datetime] = None
|
||||||
|
valid_to: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
# Relationship Type schemas
|
# Relationship Type schemas
|
||||||
class RelationshipTypeResponse(BaseModel):
|
class RelationshipTypeResponse(BaseModel):
|
||||||
"""Response schema for a relationship type."""
|
"""Response schema for a relationship type."""
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Repository layer for Requirement database operations.
|
Repository layer for Requirement database operations.
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional
|
from typing import List, Optional, Dict, Any
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from src.db_models import Requirement, Group, Tag, Priority, Validation
|
from src.db_models import Requirement, Group, Tag, Priority, Validation, RequirementHistory
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,6 +32,8 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
|
selectinload(Requirement.user),
|
||||||
|
selectinload(Requirement.last_editor),
|
||||||
)
|
)
|
||||||
.order_by(Requirement.created_at.desc())
|
.order_by(Requirement.created_at.desc())
|
||||||
)
|
)
|
||||||
@@ -55,6 +57,8 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
|
selectinload(Requirement.user),
|
||||||
|
selectinload(Requirement.last_editor),
|
||||||
)
|
)
|
||||||
.where(Requirement.project_id == project_id)
|
.where(Requirement.project_id == project_id)
|
||||||
.order_by(Requirement.created_at.desc())
|
.order_by(Requirement.created_at.desc())
|
||||||
@@ -79,6 +83,8 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
|
selectinload(Requirement.user),
|
||||||
|
selectinload(Requirement.last_editor),
|
||||||
)
|
)
|
||||||
.where(Requirement.user_id == user_id)
|
.where(Requirement.user_id == user_id)
|
||||||
.order_by(Requirement.created_at.desc())
|
.order_by(Requirement.created_at.desc())
|
||||||
@@ -130,6 +136,8 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
|
selectinload(Requirement.user),
|
||||||
|
selectinload(Requirement.last_editor),
|
||||||
)
|
)
|
||||||
.join(Requirement.groups)
|
.join(Requirement.groups)
|
||||||
.where(Group.id == group_id)
|
.where(Group.id == group_id)
|
||||||
@@ -165,6 +173,8 @@ class RequirementRepository:
|
|||||||
selectinload(Requirement.groups),
|
selectinload(Requirement.groups),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||||
|
selectinload(Requirement.user),
|
||||||
|
selectinload(Requirement.last_editor),
|
||||||
)
|
)
|
||||||
.where(Requirement.tag_id == tag_id)
|
.where(Requirement.tag_id == tag_id)
|
||||||
)
|
)
|
||||||
@@ -349,3 +359,53 @@ class RequirementRepository:
|
|||||||
|
|
||||||
result = await self.session.execute(stmt)
|
result = await self.session.execute(stmt)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_history(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get the version history for a requirement.
|
||||||
|
This data is populated by a database trigger on UPDATE.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requirement_id: The requirement ID to get history for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of historical versions with resolved tag/priority/editor names
|
||||||
|
"""
|
||||||
|
# Use raw SQL with LEFT JOINs to resolve foreign keys to display names
|
||||||
|
query = text("""
|
||||||
|
SELECT
|
||||||
|
rh.history_id,
|
||||||
|
rh.version,
|
||||||
|
rh.req_name,
|
||||||
|
rh.req_desc,
|
||||||
|
t.tag_code,
|
||||||
|
p.priority_name,
|
||||||
|
u.full_name as edited_by_full_name,
|
||||||
|
u.username as edited_by_username,
|
||||||
|
rh.valid_from,
|
||||||
|
rh.valid_to
|
||||||
|
FROM requirements_history rh
|
||||||
|
LEFT JOIN tags t ON rh.tag_id = t.id
|
||||||
|
LEFT JOIN priorities p ON rh.priority_id = p.id
|
||||||
|
LEFT JOIN users u ON rh.edited_by = u.id
|
||||||
|
WHERE rh.original_req_id = :requirement_id
|
||||||
|
ORDER BY rh.version DESC
|
||||||
|
""")
|
||||||
|
|
||||||
|
result = await self.session.execute(query, {"requirement_id": requirement_id})
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"history_id": row.history_id,
|
||||||
|
"version": row.version,
|
||||||
|
"req_name": row.req_name,
|
||||||
|
"req_desc": row.req_desc,
|
||||||
|
"tag_code": row.tag_code,
|
||||||
|
"priority_name": row.priority_name,
|
||||||
|
"edited_by_username": row.edited_by_full_name or row.edited_by_username,
|
||||||
|
"valid_from": row.valid_from,
|
||||||
|
"valid_to": row.valid_to,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useAuth, useProject } from '@/hooks'
|
import { useAuth, useProject } from '@/hooks'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
import { requirementService, validationService, relationshipService, commentService } from '@/services'
|
import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService } from '@/services'
|
||||||
import type { Requirement } from '@/services/requirementService'
|
import type { Requirement } from '@/services/requirementService'
|
||||||
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||||
import type { ValidationStatus, ValidationHistory, Comment } from '@/types'
|
import type { Tag } from '@/services/tagService'
|
||||||
|
import type { Priority } from '@/services/priorityService'
|
||||||
|
import type { Group } from '@/services/groupService'
|
||||||
|
import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory } from '@/types'
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
|
||||||
|
|
||||||
export default function RequirementDetailPage() {
|
export default function RequirementDetailPage() {
|
||||||
const { user, logout, isAuditor } = useAuth()
|
const { user, logout, isAuditor } = useAuth()
|
||||||
@@ -52,6 +55,25 @@ export default function RequirementDetailPage() {
|
|||||||
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
||||||
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false)
|
||||||
|
const [editLoading, setEditLoading] = useState(false)
|
||||||
|
const [editError, setEditError] = useState<string | null>(null)
|
||||||
|
const [editReqName, setEditReqName] = useState('')
|
||||||
|
const [editReqDesc, setEditReqDesc] = useState('')
|
||||||
|
const [editTagId, setEditTagId] = useState<number | ''>('')
|
||||||
|
const [editPriorityId, setEditPriorityId] = useState<number | ''>('')
|
||||||
|
const [editGroupIds, setEditGroupIds] = useState<number[]>([])
|
||||||
|
const [availableTags, setAvailableTags] = useState<Tag[]>([])
|
||||||
|
const [availablePriorities, setAvailablePriorities] = useState<Priority[]>([])
|
||||||
|
const [availableGroups, setAvailableGroups] = useState<Group[]>([])
|
||||||
|
const [editOptionsLoading, setEditOptionsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Requirement history state
|
||||||
|
const [reqHistory, setReqHistory] = useState<RequirementHistory[]>([])
|
||||||
|
const [reqHistoryLoading, setReqHistoryLoading] = useState(false)
|
||||||
|
const [expandedHistoryId, setExpandedHistoryId] = useState<number | null>(null)
|
||||||
|
|
||||||
// Fetch requirement data on mount
|
// Fetch requirement data on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRequirement = async () => {
|
const fetchRequirement = async () => {
|
||||||
@@ -142,6 +164,25 @@ export default function RequirementDetailPage() {
|
|||||||
fetchCommentsData()
|
fetchCommentsData()
|
||||||
}, [activeTab, id])
|
}, [activeTab, id])
|
||||||
|
|
||||||
|
// Fetch requirement history when history tab is active
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchHistoryData = async () => {
|
||||||
|
if (activeTab !== 'history' || !id) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setReqHistoryLoading(true)
|
||||||
|
const history = await requirementService.getRequirementHistory(parseInt(id, 10))
|
||||||
|
setReqHistory(history)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch requirement history:', err)
|
||||||
|
} finally {
|
||||||
|
setReqHistoryLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchHistoryData()
|
||||||
|
}, [activeTab, id])
|
||||||
|
|
||||||
// Debounced search for target requirements
|
// Debounced search for target requirements
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showAddRelationshipModal || !currentProject || !id) return
|
if (!showAddRelationshipModal || !currentProject || !id) return
|
||||||
@@ -378,6 +419,87 @@ export default function RequirementDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open edit modal and load options
|
||||||
|
const openEditModal = async () => {
|
||||||
|
if (!requirement) return
|
||||||
|
|
||||||
|
// Set initial form values from current requirement
|
||||||
|
setEditReqName(requirement.req_name)
|
||||||
|
setEditReqDesc(requirement.req_desc || '')
|
||||||
|
setEditTagId(requirement.tag.id)
|
||||||
|
setEditPriorityId(requirement.priority?.id || '')
|
||||||
|
setEditGroupIds(requirement.groups.map(g => g.id))
|
||||||
|
setEditError(null)
|
||||||
|
setShowEditModal(true)
|
||||||
|
|
||||||
|
// Fetch options if not already loaded
|
||||||
|
if (availableTags.length === 0 || availablePriorities.length === 0 || availableGroups.length === 0) {
|
||||||
|
try {
|
||||||
|
setEditOptionsLoading(true)
|
||||||
|
const [tags, priorities, groups] = await Promise.all([
|
||||||
|
tagService.getTags(),
|
||||||
|
priorityService.getPriorities(),
|
||||||
|
groupService.getGroups()
|
||||||
|
])
|
||||||
|
setAvailableTags(tags)
|
||||||
|
setAvailablePriorities(priorities)
|
||||||
|
setAvailableGroups(groups)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch edit options:', err)
|
||||||
|
setEditError('Failed to load options. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setEditOptionsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close edit modal
|
||||||
|
const closeEditModal = () => {
|
||||||
|
setShowEditModal(false)
|
||||||
|
setEditError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle saving edits
|
||||||
|
const handleSaveEdit = async () => {
|
||||||
|
if (!id || !editReqName.trim() || !editTagId) {
|
||||||
|
setEditError('Name and Tag are required')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setEditLoading(true)
|
||||||
|
setEditError(null)
|
||||||
|
|
||||||
|
await requirementService.updateRequirement(parseInt(id, 10), {
|
||||||
|
req_name: editReqName.trim(),
|
||||||
|
req_desc: editReqDesc.trim() || undefined,
|
||||||
|
tag_id: editTagId as number,
|
||||||
|
priority_id: editPriorityId ? editPriorityId as number : undefined,
|
||||||
|
group_ids: editGroupIds
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refetch to ensure consistency (trigger updates version)
|
||||||
|
const updatedRequirement = await requirementService.getRequirement(parseInt(id, 10))
|
||||||
|
setRequirement(updatedRequirement)
|
||||||
|
|
||||||
|
closeEditModal()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update requirement:', err)
|
||||||
|
setEditError('Failed to save changes. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle group selection in edit modal
|
||||||
|
const toggleGroupSelection = (groupId: number) => {
|
||||||
|
setEditGroupIds(prev =>
|
||||||
|
prev.includes(groupId)
|
||||||
|
? prev.filter(id => id !== groupId)
|
||||||
|
: [...prev, groupId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Get validation status style
|
// Get validation status style
|
||||||
const getValidationStatusStyle = (status: string): string => {
|
const getValidationStatusStyle = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -425,6 +547,7 @@ export default function RequirementDetailPage() {
|
|||||||
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
|
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
|
||||||
{ id: 'shared-comments', label: 'Shared Comments' },
|
{ id: 'shared-comments', label: 'Shared Comments' },
|
||||||
{ id: 'validate', label: 'Validate' },
|
{ id: 'validate', label: 'Validate' },
|
||||||
|
{ id: 'history', label: 'History' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// Get display values from the requirement data
|
// Get display values from the requirement data
|
||||||
@@ -450,6 +573,20 @@ export default function RequirementDetailPage() {
|
|||||||
<span className="text-gray-500 ml-2">by @{requirement.validated_by}</span>
|
<span className="text-gray-500 ml-2">by @{requirement.validated_by}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 mb-2">
|
||||||
|
<span className="font-semibold">Author:</span>{' '}
|
||||||
|
{requirement.author_username ? (
|
||||||
|
<span className="text-gray-700">@{requirement.author_username}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Unknown</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{requirement.last_editor_username && (
|
||||||
|
<p className="text-sm text-gray-700 mb-2">
|
||||||
|
<span className="font-semibold">Last Edited By:</span>{' '}
|
||||||
|
<span className="text-gray-700">@{requirement.last_editor_username}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{requirement.created_at && (
|
{requirement.created_at && (
|
||||||
<p className="text-sm text-gray-700 mb-2">
|
<p className="text-sm text-gray-700 mb-2">
|
||||||
<span className="font-semibold">Created:</span> {new Date(requirement.created_at).toLocaleDateString()}
|
<span className="font-semibold">Created:</span> {new Date(requirement.created_at).toLocaleDateString()}
|
||||||
@@ -466,7 +603,10 @@ export default function RequirementDetailPage() {
|
|||||||
{/* Hide Edit button for auditors */}
|
{/* Hide Edit button for auditors */}
|
||||||
{!isAuditor && (
|
{!isAuditor && (
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
<button
|
||||||
|
onClick={openEditModal}
|
||||||
|
className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -783,8 +923,8 @@ export default function RequirementDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Form - Only for auditors and admins */}
|
{/* Validation Form - Only for auditors */}
|
||||||
{(isAuditor || user?.role_id === 1) && (
|
{isAuditor && (
|
||||||
<div className="mb-6 p-4 border border-gray-300 rounded">
|
<div className="mb-6 p-4 border border-gray-300 rounded">
|
||||||
<h4 className="font-semibold text-gray-800 mb-4">Submit Validation</h4>
|
<h4 className="font-semibold text-gray-800 mb-4">Submit Validation</h4>
|
||||||
|
|
||||||
@@ -907,6 +1047,117 @@ export default function RequirementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case 'history':
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-gray-800 mb-4">Version History</h3>
|
||||||
|
|
||||||
|
{/* Author info */}
|
||||||
|
<div className="mb-4 p-3 bg-gray-50 border border-gray-200 rounded text-sm">
|
||||||
|
<span className="font-medium text-gray-700">Original Author:</span>{' '}
|
||||||
|
{requirement.author_username ? (
|
||||||
|
<span className="text-gray-800">@{requirement.author_username}</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 italic">Unknown</span>
|
||||||
|
)}
|
||||||
|
{requirement.created_at && (
|
||||||
|
<span className="text-gray-500 ml-2">
|
||||||
|
(created {new Date(requirement.created_at).toLocaleDateString()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-800">
|
||||||
|
<span className="font-medium">Note:</span> Group changes are not tracked in the version history.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{reqHistoryLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
|
||||||
|
<p className="mt-2 text-gray-500 text-sm">Loading history...</p>
|
||||||
|
</div>
|
||||||
|
) : reqHistory.length === 0 ? (
|
||||||
|
<p className="text-gray-500 text-center py-8">No version history yet. History is recorded when the requirement is edited.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{reqHistory.map((historyItem) => (
|
||||||
|
<div key={historyItem.history_id} className="border border-gray-200 rounded">
|
||||||
|
{/* History Row Header */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between px-4 py-3 bg-gray-50 cursor-pointer hover:bg-gray-100"
|
||||||
|
onClick={() => setExpandedHistoryId(
|
||||||
|
expandedHistoryId === historyItem.history_id ? null : historyItem.history_id
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-semibold text-gray-800">
|
||||||
|
v{historyItem.version}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{historyItem.req_name || <span className="italic text-gray-400">No name</span>}
|
||||||
|
</span>
|
||||||
|
{historyItem.tag_code && (
|
||||||
|
<span className="px-2 py-0.5 bg-teal-100 text-teal-800 text-xs rounded">
|
||||||
|
{historyItem.tag_code}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{historyItem.priority_name && (
|
||||||
|
<span className="text-sm text-gray-500">
|
||||||
|
Priority: {historyItem.priority_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||||
|
{historyItem.edited_by_username && (
|
||||||
|
<span>by @{historyItem.edited_by_username}</span>
|
||||||
|
)}
|
||||||
|
{historyItem.valid_from && historyItem.valid_to && (
|
||||||
|
<span>
|
||||||
|
{new Date(historyItem.valid_from).toLocaleDateString()} - {new Date(historyItem.valid_to).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-400">
|
||||||
|
{expandedHistoryId === historyItem.history_id ? '▼' : '▶'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Description */}
|
||||||
|
{expandedHistoryId === historyItem.history_id && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 bg-white">
|
||||||
|
<p className="text-sm font-medium text-gray-600 mb-2">Description:</p>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded p-3 min-h-[100px]">
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap">
|
||||||
|
{historyItem.req_desc || <span className="italic text-gray-400">No description</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-600">Valid From:</span>{' '}
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{historyItem.valid_from
|
||||||
|
? new Date(historyItem.valid_from).toLocaleString()
|
||||||
|
: 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="font-medium text-gray-600">Valid To:</span>{' '}
|
||||||
|
<span className="text-gray-700">
|
||||||
|
{historyItem.valid_to
|
||||||
|
? new Date(historyItem.valid_to).toLocaleString()
|
||||||
|
: 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -1155,6 +1406,166 @@ export default function RequirementDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Requirement Modal */}
|
||||||
|
{showEditModal && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-800">Edit Requirement</h2>
|
||||||
|
<button
|
||||||
|
onClick={closeEditModal}
|
||||||
|
className="text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="px-6 py-4 space-y-4 overflow-y-auto flex-1">
|
||||||
|
{editError && (
|
||||||
|
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||||
|
{editError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editOptionsLoading ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
|
||||||
|
<p className="mt-2 text-gray-500 text-sm">Loading options...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editReqName}
|
||||||
|
onChange={(e) => setEditReqName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tag */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Tag <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editTagId}
|
||||||
|
onChange={(e) => setEditTagId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
<option value="">Select a tag...</option>
|
||||||
|
{availableTags.map((tag) => (
|
||||||
|
<option key={tag.id} value={tag.id}>
|
||||||
|
{tag.tag_code} - {tag.tag_description}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Priority
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={editPriorityId}
|
||||||
|
onChange={(e) => setEditPriorityId(e.target.value ? Number(e.target.value) : '')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
<option value="">No priority</option>
|
||||||
|
{availablePriorities.map((priority) => (
|
||||||
|
<option key={priority.id} value={priority.id}>
|
||||||
|
{priority.priority_name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editReqDesc}
|
||||||
|
onChange={(e) => setEditReqDesc(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
|
||||||
|
disabled={editLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Groups */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Groups
|
||||||
|
</label>
|
||||||
|
{availableGroups.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 italic">No groups available</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3">
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<label
|
||||||
|
key={group.id}
|
||||||
|
className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 p-1 rounded"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editGroupIds.includes(group.id)}
|
||||||
|
onChange={() => toggleGroupSelection(group.id)}
|
||||||
|
disabled={editLoading}
|
||||||
|
className="rounded border-gray-300 text-teal-600 focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${group.hex_color}30`,
|
||||||
|
borderColor: group.hex_color
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{group.group_name}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={closeEditModal}
|
||||||
|
className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
|
||||||
|
disabled={editLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={editLoading || editOptionsLoading || !editReqName.trim() || !editTagId}
|
||||||
|
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{editLoading ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Group } from './groupService'
|
import { Group } from './groupService'
|
||||||
import { Tag } from './tagService'
|
import { Tag } from './tagService'
|
||||||
import { Priority } from './priorityService'
|
import { Priority } from './priorityService'
|
||||||
|
import type { RequirementHistory } from '@/types'
|
||||||
|
|
||||||
const API_BASE_URL = '/api'
|
const API_BASE_URL = '/api'
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ export interface Requirement {
|
|||||||
validated_by: string | null
|
validated_by: string | null
|
||||||
validated_at: string | null
|
validated_at: string | null
|
||||||
validation_version: number | null
|
validation_version: number | null
|
||||||
|
author_username: string | null
|
||||||
|
last_editor_username: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequirementCreateRequest {
|
export interface RequirementCreateRequest {
|
||||||
@@ -208,6 +211,32 @@ class RequirementService {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the version history for a requirement.
|
||||||
|
* Returns all previous versions ordered by version (newest first).
|
||||||
|
*/
|
||||||
|
async getRequirementHistory(requirementId: number): Promise<RequirementHistory[]> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/history`, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const history: RequirementHistory[] = await response.json()
|
||||||
|
return history
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch requirement history:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const requirementService = new RequirementService()
|
export const requirementService = new RequirementService()
|
||||||
|
|||||||
@@ -51,3 +51,16 @@ export interface CommentCreateRequest {
|
|||||||
export interface ReplyCreateRequest {
|
export interface ReplyCreateRequest {
|
||||||
reply_text: string
|
reply_text: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Requirement History types
|
||||||
|
export interface RequirementHistory {
|
||||||
|
history_id: number
|
||||||
|
version: number | null
|
||||||
|
req_name: string | null
|
||||||
|
req_desc: string | null
|
||||||
|
tag_code: string | null
|
||||||
|
priority_name: string | null
|
||||||
|
edited_by_username: string | null
|
||||||
|
valid_from: string | null
|
||||||
|
valid_to: string | null
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user