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

@@ -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)

View File

@@ -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."""

View File

@@ -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
]

View File

@@ -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>
) )
} }

View File

@@ -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()

View File

@@ -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
}