Added history track
This commit is contained in:
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.models import (
|
||||
TokenResponse, UserInfo, GroupResponse,
|
||||
TagResponse, RequirementResponse, PriorityResponse,
|
||||
RequirementCreateRequest, RequirementUpdateRequest,
|
||||
RequirementCreateRequest, RequirementUpdateRequest, RequirementHistoryResponse,
|
||||
ProjectResponse, ProjectCreateRequest, ProjectUpdateRequest, ProjectMemberRequest,
|
||||
ValidationStatusResponse, ValidationHistoryResponse, ValidationCreateRequest,
|
||||
RelationshipTypeResponse, RelationshipTypeCreateRequest, RelationshipTypeUpdateRequest,
|
||||
@@ -793,6 +793,16 @@ def _build_requirement_response(req) -> RequirementResponse:
|
||||
validated_at = latest_validation.created_at
|
||||
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(
|
||||
id=req.id,
|
||||
project_id=req.project_id,
|
||||
@@ -808,6 +818,8 @@ def _build_requirement_response(req) -> RequirementResponse:
|
||||
validated_by=validated_by,
|
||||
validated_at=validated_at,
|
||||
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()
|
||||
|
||||
|
||||
@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
|
||||
# ===========================================
|
||||
@@ -1073,7 +1122,7 @@ async def create_validation(
|
||||
):
|
||||
"""
|
||||
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:
|
||||
requirement_id: The requirement to validate
|
||||
@@ -1084,8 +1133,8 @@ async def create_validation(
|
||||
"""
|
||||
user = await _get_current_user_db(request, db)
|
||||
|
||||
# Only auditors (role_id=2) and admins (role_id=1) can validate
|
||||
_require_role(user, [1, 2], "validate requirements")
|
||||
# Only auditors (role_id=2) can validate
|
||||
_require_role(user, [2], "validate requirements")
|
||||
|
||||
# Check if requirement exists and user has access
|
||||
req_repo = RequirementRepository(db)
|
||||
|
||||
@@ -211,6 +211,8 @@ class RequirementResponse(BaseModel):
|
||||
validated_by: Optional[str] = None # Username of the validator
|
||||
validated_at: Optional[datetime] = None # When the latest validation was made
|
||||
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:
|
||||
from_attributes = True
|
||||
@@ -240,6 +242,22 @@ class RequirementUpdateRequest(BaseModel):
|
||||
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
|
||||
class RelationshipTypeResponse(BaseModel):
|
||||
"""Response schema for a relationship type."""
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Repository layer for Requirement 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.orm import selectinload
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,6 +32,8 @@ class RequirementRepository:
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||
selectinload(Requirement.user),
|
||||
selectinload(Requirement.last_editor),
|
||||
)
|
||||
.order_by(Requirement.created_at.desc())
|
||||
)
|
||||
@@ -55,6 +57,8 @@ class RequirementRepository:
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||
selectinload(Requirement.user),
|
||||
selectinload(Requirement.last_editor),
|
||||
)
|
||||
.where(Requirement.project_id == project_id)
|
||||
.order_by(Requirement.created_at.desc())
|
||||
@@ -79,6 +83,8 @@ class RequirementRepository:
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||
selectinload(Requirement.user),
|
||||
selectinload(Requirement.last_editor),
|
||||
)
|
||||
.where(Requirement.user_id == user_id)
|
||||
.order_by(Requirement.created_at.desc())
|
||||
@@ -130,6 +136,8 @@ class RequirementRepository:
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||
selectinload(Requirement.user),
|
||||
selectinload(Requirement.last_editor),
|
||||
)
|
||||
.join(Requirement.groups)
|
||||
.where(Group.id == group_id)
|
||||
@@ -165,6 +173,8 @@ class RequirementRepository:
|
||||
selectinload(Requirement.groups),
|
||||
selectinload(Requirement.validations).selectinload(Validation.status),
|
||||
selectinload(Requirement.validations).selectinload(Validation.user),
|
||||
selectinload(Requirement.user),
|
||||
selectinload(Requirement.last_editor),
|
||||
)
|
||||
.where(Requirement.tag_id == tag_id)
|
||||
)
|
||||
@@ -349,3 +359,53 @@ class RequirementRepository:
|
||||
|
||||
result = await self.session.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_history(self, requirement_id: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get the version history for a requirement.
|
||||
This data is populated by a database trigger on UPDATE.
|
||||
|
||||
Args:
|
||||
requirement_id: The requirement ID to get history for
|
||||
|
||||
Returns:
|
||||
List of historical versions with resolved tag/priority/editor names
|
||||
"""
|
||||
# Use raw SQL with LEFT JOINs to resolve foreign keys to display names
|
||||
query = text("""
|
||||
SELECT
|
||||
rh.history_id,
|
||||
rh.version,
|
||||
rh.req_name,
|
||||
rh.req_desc,
|
||||
t.tag_code,
|
||||
p.priority_name,
|
||||
u.full_name as edited_by_full_name,
|
||||
u.username as edited_by_username,
|
||||
rh.valid_from,
|
||||
rh.valid_to
|
||||
FROM requirements_history rh
|
||||
LEFT JOIN tags t ON rh.tag_id = t.id
|
||||
LEFT JOIN priorities p ON rh.priority_id = p.id
|
||||
LEFT JOIN users u ON rh.edited_by = u.id
|
||||
WHERE rh.original_req_id = :requirement_id
|
||||
ORDER BY rh.version DESC
|
||||
""")
|
||||
|
||||
result = await self.session.execute(query, {"requirement_id": requirement_id})
|
||||
rows = result.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"history_id": row.history_id,
|
||||
"version": row.version,
|
||||
"req_name": row.req_name,
|
||||
"req_desc": row.req_desc,
|
||||
"tag_code": row.tag_code,
|
||||
"priority_name": row.priority_name,
|
||||
"edited_by_username": row.edited_by_full_name or row.edited_by_username,
|
||||
"valid_from": row.valid_from,
|
||||
"valid_to": row.valid_to,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user