Added history track

This commit is contained in:
gulimabr
2025-12-02 11:21:03 -03:00
parent 0e30db3c18
commit 459ceaa162
6 changed files with 593 additions and 13 deletions

View File

@@ -1,13 +1,16 @@
import { useState, useEffect } from 'react'
import { useAuth, useProject } from '@/hooks'
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 { 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
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
export default function RequirementDetailPage() {
const { user, logout, isAuditor } = useAuth()
@@ -52,6 +55,25 @@ export default function RequirementDetailPage() {
const [deletingCommentId, setDeletingCommentId] = 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
useEffect(() => {
const fetchRequirement = async () => {
@@ -142,6 +164,25 @@ export default function RequirementDetailPage() {
fetchCommentsData()
}, [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
useEffect(() => {
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
const getValidationStatusStyle = (status: string): string => {
switch (status) {
@@ -425,6 +547,7 @@ export default function RequirementDetailPage() {
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
{ id: 'shared-comments', label: 'Shared Comments' },
{ id: 'validate', label: 'Validate' },
{ id: 'history', label: 'History' },
]
// 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>
)}
</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 && (
<p className="text-sm text-gray-700 mb-2">
<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 */}
{!isAuditor && (
<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
</button>
</div>
@@ -783,8 +923,8 @@ export default function RequirementDetailPage() {
)}
</div>
{/* Validation Form - Only for auditors and admins */}
{(isAuditor || user?.role_id === 1) && (
{/* Validation Form - Only for auditors */}
{isAuditor && (
<div className="mb-6 p-4 border border-gray-300 rounded">
<h4 className="font-semibold text-gray-800 mb-4">Submit Validation</h4>
@@ -907,6 +1047,117 @@ export default function RequirementDetailPage() {
</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:
return null
}
@@ -1155,6 +1406,166 @@ export default function RequirementDetailPage() {
</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>
)
}

View File

@@ -1,6 +1,7 @@
import { Group } from './groupService'
import { Tag } from './tagService'
import { Priority } from './priorityService'
import type { RequirementHistory } from '@/types'
const API_BASE_URL = '/api'
@@ -19,6 +20,8 @@ export interface Requirement {
validated_by: string | null
validated_at: string | null
validation_version: number | null
author_username: string | null
last_editor_username: string | null
}
export interface RequirementCreateRequest {
@@ -208,6 +211,32 @@ class RequirementService {
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()

View File

@@ -51,3 +51,16 @@ export interface CommentCreateRequest {
export interface ReplyCreateRequest {
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
}