Refactoring the history tab of the requirements details
This commit is contained in:
@@ -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