Refactoring the history tab of the requirements details
This commit is contained in:
@@ -286,6 +286,11 @@ class RequirementGroup(Base):
|
||||
ForeignKey("groups.id", ondelete="CASCADE"),
|
||||
primary_key=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
nullable=True
|
||||
)
|
||||
|
||||
|
||||
class Validation(Base):
|
||||
@@ -328,6 +333,7 @@ class RequirementHistory(Base):
|
||||
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
original_req_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
project_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
status_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) # Requirement lifecycle status
|
||||
req_name: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
req_desc: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
priority_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
@@ -512,3 +518,29 @@ class RequirementCommentReply(Base):
|
||||
__table_args__ = (
|
||||
Index("idx_rcr_parent", "parent_comment_id"),
|
||||
)
|
||||
|
||||
|
||||
class RequirementLinkHistory(Base):
|
||||
"""
|
||||
Historical records of requirement link changes (deletions).
|
||||
Note: This is populated by a database trigger on UPDATE/DELETE.
|
||||
Stores snapshots of relationship type names to preserve data if types are renamed/deleted.
|
||||
"""
|
||||
__tablename__ = "requirement_links_history"
|
||||
|
||||
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
original_link_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
source_req_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
target_req_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
relationship_type_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
relationship_type_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Preserved type name
|
||||
inverse_type_snapshot: Mapped[Optional[str]] = mapped_column(Text, nullable=True) # Preserved inverse name
|
||||
created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # When link was created
|
||||
valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) # When link was deleted
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("idx_link_hist_source", "source_req_id"),
|
||||
Index("idx_link_hist_target", "target_req_id"),
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.models import (
|
||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
||||
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
|
||||
RequirementLinkResponse, RequirementLinkCreateRequest, RequirementSearchResult,
|
||||
RequirementLinkHistoryResponse, RequirementGroupHistoryResponse, CurrentRequirementGroupResponse,
|
||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||
RequirementStatusResponse, DeletedRequirementResponse
|
||||
@@ -1600,6 +1601,117 @@ async def delete_requirement_link(
|
||||
await db.commit()
|
||||
|
||||
|
||||
@app.get("/api/requirements/{requirement_id}/links/history", response_model=List[RequirementLinkHistoryResponse])
|
||||
async def get_requirement_link_history(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get the link history for a requirement (deleted/changed links).
|
||||
Returns all historical links where this requirement was source or target.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement to get link history for
|
||||
|
||||
Returns:
|
||||
List of historical links with relationship type snapshots and requirement 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 link history
|
||||
link_repo = RequirementLinkRepository(db)
|
||||
history = await link_repo.get_history_by_requirement_id(requirement_id)
|
||||
|
||||
return [RequirementLinkHistoryResponse(**h) for h in history]
|
||||
|
||||
|
||||
@app.get("/api/requirements/{requirement_id}/groups/history", response_model=List[RequirementGroupHistoryResponse])
|
||||
async def get_requirement_group_history(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get the group association history for a requirement (removed groups).
|
||||
Returns all historical group associations for this requirement.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement to get group history for
|
||||
|
||||
Returns:
|
||||
List of historical group associations with group name/color snapshots.
|
||||
"""
|
||||
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 group history
|
||||
group_repo = GroupRepository(db)
|
||||
history = await group_repo.get_group_history_by_requirement_id(requirement_id)
|
||||
|
||||
return [RequirementGroupHistoryResponse(**h) for h in history]
|
||||
|
||||
|
||||
@app.get("/api/requirements/{requirement_id}/groups/current", response_model=List[CurrentRequirementGroupResponse])
|
||||
async def get_requirement_current_groups(
|
||||
requirement_id: int,
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Get the current groups for a requirement with their association timestamps.
|
||||
Returns all current group associations with when they were added.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement to get current groups for
|
||||
|
||||
Returns:
|
||||
List of current group associations with group name/color and created_at timestamp.
|
||||
"""
|
||||
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 current groups
|
||||
group_repo = GroupRepository(db)
|
||||
groups = await group_repo.get_current_groups_by_requirement_id(requirement_id)
|
||||
|
||||
return [CurrentRequirementGroupResponse(**g) for g in groups]
|
||||
|
||||
|
||||
# ===========================================
|
||||
# Comment Endpoints
|
||||
# ===========================================
|
||||
|
||||
@@ -351,6 +351,26 @@ class RequirementLinkCreateRequest(BaseModel):
|
||||
target_requirement_id: int
|
||||
|
||||
|
||||
class RequirementLinkHistoryResponse(BaseModel):
|
||||
"""Response schema for requirement link history (deleted/changed links)."""
|
||||
history_id: int
|
||||
original_link_id: Optional[int] = None
|
||||
source_req_id: Optional[int] = None
|
||||
target_req_id: Optional[int] = None
|
||||
source_tag_code: Optional[str] = None # Resolved from source requirement
|
||||
target_tag_code: Optional[str] = None # Resolved from target requirement
|
||||
source_req_name: Optional[str] = None
|
||||
target_req_name: Optional[str] = None
|
||||
relationship_type_snapshot: Optional[str] = None # Preserved type name
|
||||
inverse_type_snapshot: Optional[str] = None # Preserved inverse name
|
||||
created_by_username: Optional[str] = None
|
||||
valid_from: Optional[datetime] = None # When link was created
|
||||
valid_to: Optional[datetime] = None # When link was deleted
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
# Requirement Search schemas
|
||||
class RequirementSearchResult(BaseModel):
|
||||
"""Response schema for requirement search results (for autocomplete)."""
|
||||
@@ -402,3 +422,29 @@ class CommentCreateRequest(BaseModel):
|
||||
class ReplyCreateRequest(BaseModel):
|
||||
"""Request schema for creating a reply."""
|
||||
reply_text: str
|
||||
|
||||
|
||||
class RequirementGroupHistoryResponse(BaseModel):
|
||||
"""Response schema for requirement group association history (removed groups)."""
|
||||
history_id: int
|
||||
requirement_id: int
|
||||
group_id: int
|
||||
group_name_snapshot: Optional[str] = None # Preserved group name at time of removal
|
||||
group_hex_color_snapshot: Optional[str] = None # Preserved hex color at time of removal
|
||||
valid_from: Optional[datetime] = None # When the group was associated
|
||||
valid_to: Optional[datetime] = None # When the group was removed
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CurrentRequirementGroupResponse(BaseModel):
|
||||
"""Response schema for current group associations with timestamp."""
|
||||
requirement_id: int
|
||||
group_id: int
|
||||
group_name: str
|
||||
group_hex_color: str
|
||||
created_at: Optional[datetime] = None # When the group was added
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""
|
||||
Repository layer for Group database operations.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy import select, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.db_models import Group
|
||||
import logging
|
||||
@@ -74,3 +74,81 @@ class GroupRepository:
|
||||
await self.session.flush()
|
||||
await self.session.refresh(group)
|
||||
return group
|
||||
|
||||
async def get_group_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the group association history for a requirement.
|
||||
Returns records of when groups were added or removed from this requirement.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to get group history for
|
||||
|
||||
Returns:
|
||||
List of historical group associations with snapshots of group data
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
rgh.history_id,
|
||||
rgh.requirement_id,
|
||||
rgh.group_id,
|
||||
rgh.group_name_snapshot,
|
||||
rgh.group_hex_color_snapshot,
|
||||
rgh.valid_from,
|
||||
rgh.valid_to
|
||||
FROM requirements_groups_history rgh
|
||||
WHERE rgh.requirement_id = :requirement_id
|
||||
ORDER BY rgh.valid_to DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"requirement_id": requirement_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"history_id": row.history_id,
|
||||
"requirement_id": row.requirement_id,
|
||||
"group_id": row.group_id,
|
||||
"group_name_snapshot": row.group_name_snapshot,
|
||||
"group_hex_color_snapshot": row.group_hex_color_snapshot,
|
||||
"valid_from": row.valid_from,
|
||||
"valid_to": row.valid_to,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def get_current_groups_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the current groups associated with a requirement, including when they were added.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to get groups for
|
||||
|
||||
Returns:
|
||||
List of current group associations with group data and created_at timestamp
|
||||
"""
|
||||
query = text("""
|
||||
SELECT
|
||||
rg.requirement_id,
|
||||
rg.group_id,
|
||||
g.group_name,
|
||||
g.hex_color,
|
||||
rg.created_at
|
||||
FROM requirements_groups rg
|
||||
JOIN groups g ON rg.group_id = g.id
|
||||
WHERE rg.requirement_id = :requirement_id
|
||||
ORDER BY rg.created_at DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"requirement_id": requirement_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"requirement_id": row.requirement_id,
|
||||
"group_id": row.group_id,
|
||||
"group_name": row.group_name,
|
||||
"group_hex_color": row.hex_color,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Repository for RequirementLink database operations.
|
||||
"""
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy import select, or_
|
||||
from sqlalchemy import select, or_, text
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.db_models import RequirementLink, Requirement, RelationshipType, User
|
||||
@@ -197,3 +197,65 @@ class RequirementLinkRepository:
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def get_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the link history for a requirement (both as source and target).
|
||||
This data is populated by a database trigger on UPDATE/DELETE.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to get link history for
|
||||
|
||||
Returns:
|
||||
List of historical links with resolved requirement names and creator info
|
||||
"""
|
||||
# Use raw SQL with LEFT JOINs to resolve foreign keys to display names
|
||||
# Note: source/target requirements may have been deleted, so we use LEFT JOINs
|
||||
query = text("""
|
||||
SELECT
|
||||
lh.history_id,
|
||||
lh.original_link_id,
|
||||
lh.source_req_id,
|
||||
lh.target_req_id,
|
||||
lh.relationship_type_snapshot,
|
||||
lh.inverse_type_snapshot,
|
||||
lh.valid_from,
|
||||
lh.valid_to,
|
||||
u.full_name as created_by_full_name,
|
||||
u.username as created_by_username,
|
||||
sr.req_name as source_req_name,
|
||||
st.tag_code as source_tag_code,
|
||||
tr.req_name as target_req_name,
|
||||
tt.tag_code as target_tag_code
|
||||
FROM requirement_links_history lh
|
||||
LEFT JOIN users u ON lh.created_by = u.id
|
||||
LEFT JOIN requirements sr ON lh.source_req_id = sr.id
|
||||
LEFT JOIN tags st ON sr.tag_id = st.id
|
||||
LEFT JOIN requirements tr ON lh.target_req_id = tr.id
|
||||
LEFT JOIN tags tt ON tr.tag_id = tt.id
|
||||
WHERE lh.source_req_id = :requirement_id
|
||||
OR lh.target_req_id = :requirement_id
|
||||
ORDER BY lh.valid_to DESC
|
||||
""")
|
||||
|
||||
result = await self.db.execute(query, {"requirement_id": requirement_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"history_id": row.history_id,
|
||||
"original_link_id": row.original_link_id,
|
||||
"source_req_id": row.source_req_id,
|
||||
"target_req_id": row.target_req_id,
|
||||
"source_tag_code": row.source_tag_code,
|
||||
"target_tag_code": row.target_tag_code,
|
||||
"source_req_name": row.source_req_name,
|
||||
"target_req_name": row.target_req_name,
|
||||
"relationship_type_snapshot": row.relationship_type_snapshot,
|
||||
"inverse_type_snapshot": row.inverse_type_snapshot,
|
||||
"created_by_username": row.created_by_full_name or row.created_by_username,
|
||||
"valid_from": row.valid_from,
|
||||
"valid_to": row.valid_to,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -12,6 +12,33 @@ import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory,
|
||||
// Tab types
|
||||
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
|
||||
|
||||
// Timeline event types for unified history view
|
||||
type TimelineEventType = 'requirement_edit' | 'link_created' | 'link_removed' | 'group_added' | 'group_removed'
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string
|
||||
type: TimelineEventType
|
||||
date: string
|
||||
// For requirement edits
|
||||
historyItem?: RequirementHistory
|
||||
nextHistoryItem?: RequirementHistory | null // The next version (for diff comparison)
|
||||
// For link events
|
||||
linkData?: {
|
||||
isSource: boolean // Whether current requirement is the source
|
||||
typeName: string
|
||||
inverseTypeName: string | null
|
||||
linkedReqId: number | null
|
||||
linkedReqName: string | null
|
||||
linkedTagCode: string | null
|
||||
createdByUsername: string | null
|
||||
}
|
||||
// For group removed events
|
||||
groupData?: {
|
||||
groupName: string | null
|
||||
groupHexColor: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export default function RequirementDetailPage() {
|
||||
const { user, logout, isAuditor } = useAuth()
|
||||
const { currentProject } = useProject()
|
||||
@@ -83,9 +110,10 @@ export default function RequirementDetailPage() {
|
||||
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)
|
||||
const [expandedHistoryId, setExpandedHistoryId] = useState<string | null>(null)
|
||||
const [timelineEvents, setTimelineEvents] = useState<TimelineEvent[]>([])
|
||||
const [showFullDescDiff, setShowFullDescDiff] = useState<string | null>(null) // Event ID for expanded desc diff
|
||||
|
||||
// Fetch requirement data on mount
|
||||
useEffect(() => {
|
||||
@@ -184,8 +212,106 @@ export default function RequirementDetailPage() {
|
||||
|
||||
try {
|
||||
setReqHistoryLoading(true)
|
||||
const history = await requirementService.getRequirementHistory(parseInt(id, 10))
|
||||
setReqHistory(history)
|
||||
const reqId = parseInt(id, 10)
|
||||
|
||||
// Fetch all five data sources in parallel
|
||||
const [history, linkHistoryData, currentLinksData, groupHistoryData, currentGroupsData] = await Promise.all([
|
||||
requirementService.getRequirementHistory(reqId),
|
||||
relationshipService.getLinkHistory(reqId),
|
||||
relationshipService.getRequirementLinks(reqId),
|
||||
groupService.getGroupHistory(reqId),
|
||||
groupService.getCurrentGroups(reqId)
|
||||
])
|
||||
|
||||
// Build unified timeline
|
||||
const events: TimelineEvent[] = []
|
||||
|
||||
// Add requirement edit events (comparing each version to the next)
|
||||
history.forEach((item, index) => {
|
||||
const nextItem = index > 0 ? history[index - 1] : null // Previous in array = newer version
|
||||
events.push({
|
||||
id: `req-${item.history_id}`,
|
||||
type: 'requirement_edit',
|
||||
date: item.valid_to || '',
|
||||
historyItem: item,
|
||||
nextHistoryItem: nextItem,
|
||||
})
|
||||
})
|
||||
|
||||
// Add current links as "link_created" events
|
||||
currentLinksData.forEach((link) => {
|
||||
const isSource = link.direction === 'outgoing'
|
||||
events.push({
|
||||
id: `link-current-${link.id}`,
|
||||
type: 'link_created',
|
||||
date: link.created_at || '',
|
||||
linkData: {
|
||||
isSource,
|
||||
typeName: link.type_name,
|
||||
inverseTypeName: link.inverse_type_name,
|
||||
linkedReqId: link.linked_requirement.id,
|
||||
linkedReqName: link.linked_requirement.req_name,
|
||||
linkedTagCode: link.linked_requirement.tag_code,
|
||||
createdByUsername: link.created_by_username,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Add link history as "link_removed" events
|
||||
linkHistoryData.forEach((linkHist) => {
|
||||
const isSource = linkHist.source_req_id === reqId
|
||||
events.push({
|
||||
id: `link-hist-${linkHist.history_id}`,
|
||||
type: 'link_removed',
|
||||
date: linkHist.valid_to || '',
|
||||
linkData: {
|
||||
isSource,
|
||||
typeName: isSource
|
||||
? (linkHist.relationship_type_snapshot || 'Unknown')
|
||||
: (linkHist.inverse_type_snapshot || linkHist.relationship_type_snapshot || 'Unknown'),
|
||||
inverseTypeName: isSource ? linkHist.inverse_type_snapshot : linkHist.relationship_type_snapshot,
|
||||
linkedReqId: isSource ? linkHist.target_req_id : linkHist.source_req_id,
|
||||
linkedReqName: isSource ? linkHist.target_req_name : linkHist.source_req_name,
|
||||
linkedTagCode: isSource ? linkHist.target_tag_code : linkHist.source_tag_code,
|
||||
createdByUsername: linkHist.created_by_username,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Add current groups as "group_added" events
|
||||
currentGroupsData.forEach((group) => {
|
||||
events.push({
|
||||
id: `group-current-${group.group_id}`,
|
||||
type: 'group_added',
|
||||
date: group.created_at || '',
|
||||
groupData: {
|
||||
groupName: group.group_name,
|
||||
groupHexColor: group.group_hex_color,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Add group history as "group_removed" events
|
||||
groupHistoryData.forEach((groupHist) => {
|
||||
events.push({
|
||||
id: `group-hist-${groupHist.history_id}`,
|
||||
type: 'group_removed',
|
||||
date: groupHist.valid_to || '',
|
||||
groupData: {
|
||||
groupName: groupHist.group_name_snapshot,
|
||||
groupHexColor: groupHist.group_hex_color_snapshot,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
events.sort((a, b) => {
|
||||
const dateA = a.date ? new Date(a.date).getTime() : 0
|
||||
const dateB = b.date ? new Date(b.date).getTime() : 0
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
setTimelineEvents(events)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch requirement history:', err)
|
||||
} finally {
|
||||
@@ -1150,6 +1276,18 @@ export default function RequirementDetailPage() {
|
||||
)
|
||||
|
||||
case 'history':
|
||||
// Helper function to truncate text
|
||||
const truncateText = (text: string | null, maxLength: number = 100): string => {
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) return text
|
||||
return text.substring(0, maxLength) + '...'
|
||||
}
|
||||
|
||||
// Helper to check if a field changed between old and new
|
||||
const hasChange = (oldVal: string | null | undefined, newVal: string | null | undefined): boolean => {
|
||||
return (oldVal || '') !== (newVal || '')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Version History</h3>
|
||||
@@ -1168,93 +1306,335 @@ export default function RequirementDetailPage() {
|
||||
</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>
|
||||
) : timelineEvents.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No history yet. History is recorded when the requirement is edited or relationships change.</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>
|
||||
<div className="space-y-3">
|
||||
{timelineEvents.map((event) => {
|
||||
if (event.type === 'requirement_edit' && event.historyItem) {
|
||||
const oldItem = event.historyItem
|
||||
const newItem = event.nextHistoryItem
|
||||
const isExpanded = expandedHistoryId === event.id
|
||||
|
||||
{/* 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'}
|
||||
// Determine what changed (compare old version to newer version or current)
|
||||
const newName = newItem?.req_name ?? requirement.req_name
|
||||
const newDesc = newItem?.req_desc ?? requirement.req_desc
|
||||
const newTag = newItem?.tag_code ?? requirement.tag.tag_code
|
||||
const newPriority = newItem?.priority_name ?? requirement.priority?.priority_name
|
||||
|
||||
const nameChanged = hasChange(oldItem.req_name, newName)
|
||||
const descChanged = hasChange(oldItem.req_desc, newDesc)
|
||||
const tagChanged = hasChange(oldItem.tag_code, newTag)
|
||||
const priorityChanged = hasChange(oldItem.priority_name, newPriority)
|
||||
const hasAnyChange = nameChanged || descChanged || tagChanged || priorityChanged
|
||||
|
||||
return (
|
||||
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 bg-amber-50 border-l-4 border-amber-400 cursor-pointer hover:bg-amber-100"
|
||||
onClick={() => setExpandedHistoryId(isExpanded ? null : event.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-amber-600 text-lg">📝</span>
|
||||
<span className="font-semibold text-gray-800">
|
||||
v{oldItem.version} → v{newItem ? newItem.version : requirement.version}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">Requirement edited</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
{oldItem.edited_by_username && (
|
||||
<span>by {oldItem.edited_by_username}</span>
|
||||
)}
|
||||
<span>
|
||||
{oldItem.valid_to ? new Date(oldItem.valid_to).toLocaleString() : ''}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
{isExpanded ? '▼' : '▶'}
|
||||
</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'}
|
||||
</div>
|
||||
|
||||
{/* Expanded diff view */}
|
||||
{isExpanded && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-white space-y-4">
|
||||
{!hasAnyChange && (
|
||||
<p className="text-gray-500 italic text-sm">No visible changes detected (may be a group-only change).</p>
|
||||
)}
|
||||
|
||||
{/* Name diff */}
|
||||
{nameChanged && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">Name:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 bg-red-50 border border-red-200 rounded">
|
||||
<span className="text-xs text-red-600 font-medium">Old</span>
|
||||
<p className="text-gray-700 mt-1">{oldItem.req_name || <span className="italic text-gray-400">Empty</span>}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
||||
<span className="text-xs text-green-600 font-medium">New</span>
|
||||
<p className="text-gray-700 mt-1">{newName || <span className="italic text-gray-400">Empty</span>}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tag diff */}
|
||||
{tagChanged && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">Tag:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 bg-red-50 border border-red-200 rounded">
|
||||
<span className="text-xs text-red-600 font-medium">Old</span>
|
||||
<p className="text-gray-700 mt-1">
|
||||
{oldItem.tag_code ? (
|
||||
<span className="px-2 py-0.5 bg-teal-100 text-teal-800 text-xs rounded">{oldItem.tag_code}</span>
|
||||
) : (
|
||||
<span className="italic text-gray-400">None</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
||||
<span className="text-xs text-green-600 font-medium">New</span>
|
||||
<p className="text-gray-700 mt-1">
|
||||
{newTag ? (
|
||||
<span className="px-2 py-0.5 bg-teal-100 text-teal-800 text-xs rounded">{newTag}</span>
|
||||
) : (
|
||||
<span className="italic text-gray-400">None</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority diff */}
|
||||
{priorityChanged && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">Priority:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 bg-red-50 border border-red-200 rounded">
|
||||
<span className="text-xs text-red-600 font-medium">Old</span>
|
||||
<p className="text-gray-700 mt-1">{oldItem.priority_name || <span className="italic text-gray-400">None</span>}</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
||||
<span className="text-xs text-green-600 font-medium">New</span>
|
||||
<p className="text-gray-700 mt-1">{newPriority || <span className="italic text-gray-400">None</span>}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description diff */}
|
||||
{descChanged && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600 mb-2">Description:</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="p-2 bg-red-50 border border-red-200 rounded">
|
||||
<span className="text-xs text-red-600 font-medium">Old</span>
|
||||
<p className="text-gray-700 mt-1 whitespace-pre-wrap text-sm">
|
||||
{showFullDescDiff === event.id
|
||||
? (oldItem.req_desc || <span className="italic text-gray-400">Empty</span>)
|
||||
: (truncateText(oldItem.req_desc) || <span className="italic text-gray-400">Empty</span>)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
||||
<span className="text-xs text-green-600 font-medium">New</span>
|
||||
<p className="text-gray-700 mt-1 whitespace-pre-wrap text-sm">
|
||||
{showFullDescDiff === event.id
|
||||
? (newDesc || <span className="italic text-gray-400">Empty</span>)
|
||||
: (truncateText(newDesc) || <span className="italic text-gray-400">Empty</span>)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{((oldItem.req_desc && oldItem.req_desc.length > 100) || (newDesc && newDesc.length > 100)) && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowFullDescDiff(showFullDescDiff === event.id ? null : event.id)
|
||||
}}
|
||||
className="mt-2 text-sm text-teal-600 hover:underline"
|
||||
>
|
||||
{showFullDescDiff === event.id ? 'Show less' : 'Show full diff'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'link_created' && event.linkData) {
|
||||
const link = event.linkData
|
||||
return (
|
||||
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-green-600 text-lg">🔗</span>
|
||||
<span className="font-medium text-gray-800">Link Created</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{link.isSource ? (
|
||||
<>
|
||||
<span className="font-medium">{link.typeName}</span>
|
||||
{' → '}
|
||||
{link.linkedTagCode && (
|
||||
<span className="px-1.5 py-0.5 bg-teal-100 text-teal-800 text-xs rounded mr-1">
|
||||
{link.linkedTagCode}
|
||||
</span>
|
||||
)}
|
||||
{link.linkedReqName || 'Unknown Requirement'}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{link.linkedTagCode && (
|
||||
<span className="px-1.5 py-0.5 bg-teal-100 text-teal-800 text-xs rounded mr-1">
|
||||
{link.linkedTagCode}
|
||||
</span>
|
||||
)}
|
||||
{link.linkedReqName || 'Unknown Requirement'}
|
||||
{' → '}
|
||||
<span className="font-medium">{link.typeName}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
{link.createdByUsername && (
|
||||
<span>by {link.createdByUsername}</span>
|
||||
)}
|
||||
<span>
|
||||
{event.date ? new Date(event.date).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'link_removed' && event.linkData) {
|
||||
const link = event.linkData
|
||||
return (
|
||||
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-red-50 border-l-4 border-red-400">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-red-600 text-lg">🔗</span>
|
||||
<span className="font-medium text-gray-800">Link Removed</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{link.isSource ? (
|
||||
<>
|
||||
<span className="font-medium">{link.typeName}</span>
|
||||
{' → '}
|
||||
{link.linkedTagCode && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-200 text-gray-600 text-xs rounded mr-1 line-through">
|
||||
{link.linkedTagCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="line-through">{link.linkedReqName || 'Deleted Requirement'}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{link.linkedTagCode && (
|
||||
<span className="px-1.5 py-0.5 bg-gray-200 text-gray-600 text-xs rounded mr-1 line-through">
|
||||
{link.linkedTagCode}
|
||||
</span>
|
||||
)}
|
||||
<span className="line-through">{link.linkedReqName || 'Deleted Requirement'}</span>
|
||||
{' → '}
|
||||
<span className="font-medium">{link.typeName}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>
|
||||
{event.date ? new Date(event.date).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'group_added' && event.groupData) {
|
||||
const group = event.groupData
|
||||
return (
|
||||
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-green-50 border-l-4 border-green-400">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-green-600 text-lg">🏷️</span>
|
||||
<span className="font-medium text-gray-800">Group Added</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{group.groupName ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border"
|
||||
style={{
|
||||
backgroundColor: group.groupHexColor ? `${group.groupHexColor}30` : '#dcfce7',
|
||||
borderColor: group.groupHexColor || '#22c55e'
|
||||
}}
|
||||
>
|
||||
{group.groupName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic text-gray-400">Unknown Group</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>
|
||||
{event.date ? new Date(event.date).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (event.type === 'group_removed' && event.groupData) {
|
||||
const group = event.groupData
|
||||
return (
|
||||
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-orange-50 border-l-4 border-orange-400">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-orange-600 text-lg">🏷️</span>
|
||||
<span className="font-medium text-gray-800">Group Removed</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{group.groupName ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium border line-through"
|
||||
style={{
|
||||
backgroundColor: group.groupHexColor ? `${group.groupHexColor}30` : '#e5e7eb',
|
||||
borderColor: group.groupHexColor || '#9ca3af'
|
||||
}}
|
||||
>
|
||||
{group.groupName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="italic text-gray-400 line-through">Unknown Group</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span>
|
||||
{event.date ? new Date(event.date).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RequirementGroupHistory, CurrentRequirementGroup } from '@/types'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
export interface Group {
|
||||
@@ -31,6 +33,58 @@ class GroupService {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get group association history for a requirement.
|
||||
* Returns records of when groups were removed from the requirement.
|
||||
*/
|
||||
async getGroupHistory(requirementId: number): Promise<RequirementGroupHistory[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/groups/history`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const history: RequirementGroupHistory[] = await response.json()
|
||||
return history
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch group history:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current groups for a requirement with their association timestamps.
|
||||
* Returns records of when groups were added to the requirement.
|
||||
*/
|
||||
async getCurrentGroups(requirementId: number): Promise<CurrentRequirementGroup[]> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/groups/current`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const groups: CurrentRequirementGroup[] = await response.json()
|
||||
return groups
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch current groups:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const groupService = new GroupService()
|
||||
|
||||
@@ -50,6 +50,22 @@ export interface RelationshipTypeUpdateRequest {
|
||||
inverse_type_name?: string | null
|
||||
}
|
||||
|
||||
export interface RequirementLinkHistory {
|
||||
history_id: number
|
||||
original_link_id: number | null
|
||||
source_req_id: number | null
|
||||
target_req_id: number | null
|
||||
source_tag_code: string | null
|
||||
target_tag_code: string | null
|
||||
source_req_name: string | null
|
||||
target_req_name: string | null
|
||||
relationship_type_snapshot: string | null
|
||||
inverse_type_snapshot: string | null
|
||||
created_by_username: string | null
|
||||
valid_from: string | null
|
||||
valid_to: string | null
|
||||
}
|
||||
|
||||
class RelationshipService {
|
||||
/**
|
||||
* Get all relationship types for a project.
|
||||
@@ -234,6 +250,25 @@ class RelationshipService {
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the link history for a requirement (deleted/changed links).
|
||||
*/
|
||||
async getLinkHistory(requirementId: number): Promise<RequirementLinkHistory[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links/history`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const relationshipService = new RelationshipService()
|
||||
|
||||
@@ -73,6 +73,43 @@ export interface RequirementHistory {
|
||||
valid_to: string | null
|
||||
}
|
||||
|
||||
// Requirement Link History types
|
||||
export interface RequirementLinkHistory {
|
||||
history_id: number
|
||||
original_link_id: number | null
|
||||
source_req_id: number | null
|
||||
target_req_id: number | null
|
||||
source_tag_code: string | null
|
||||
target_tag_code: string | null
|
||||
source_req_name: string | null
|
||||
target_req_name: string | null
|
||||
relationship_type_snapshot: string | null
|
||||
inverse_type_snapshot: string | null
|
||||
created_by_username: string | null
|
||||
valid_from: string | null // When link was created
|
||||
valid_to: string | null // When link was deleted
|
||||
}
|
||||
|
||||
// Requirement Group History types
|
||||
export interface RequirementGroupHistory {
|
||||
history_id: number
|
||||
requirement_id: number
|
||||
group_id: number
|
||||
group_name_snapshot: string | null // Group name at time of removal
|
||||
group_hex_color_snapshot: string | null // Hex color at time of removal
|
||||
valid_from: string | null // When group was associated
|
||||
valid_to: string | null // When group was removed
|
||||
}
|
||||
|
||||
// Current Requirement Group with timestamp
|
||||
export interface CurrentRequirementGroup {
|
||||
requirement_id: number
|
||||
group_id: number
|
||||
group_name: string
|
||||
group_hex_color: string
|
||||
created_at: string | null // When group was added
|
||||
}
|
||||
|
||||
// Deleted Requirement types
|
||||
export interface DeletedRequirement {
|
||||
history_id: number
|
||||
|
||||
Reference in New Issue
Block a user