added acceptance criteria page
This commit is contained in:
@@ -55,7 +55,14 @@
|
||||
"acceptanceCriteria": {
|
||||
"title": "Acceptance Criteria",
|
||||
"noCriteria": "No acceptance criteria defined yet.",
|
||||
"addButton": "Add Criterion"
|
||||
"addButton": "Add Criterion",
|
||||
"placeholder": "Describe a new criterion...",
|
||||
"loading": "Loading acceptance criteria...",
|
||||
"creating": "Creating...",
|
||||
"editButton": "Edit",
|
||||
"saveButton": "Save",
|
||||
"cancelButton": "Cancel",
|
||||
"deleteButton": "Delete"
|
||||
},
|
||||
"comments": {
|
||||
"title": "Shared Comments",
|
||||
@@ -117,7 +124,14 @@
|
||||
"showLess": "Show less",
|
||||
"showFullDiff": "Show full diff",
|
||||
"deletedRequirement": "Deleted Requirement",
|
||||
"unknownGroup": "Unknown Group"
|
||||
"unknownGroup": "Unknown Group",
|
||||
"criteriaAdded": "Criterion Added",
|
||||
"criteriaUpdated": "Criterion Updated",
|
||||
"criteriaDeleted": "Criterion Deleted",
|
||||
"criteriaAccepted": "Accepted",
|
||||
"criteriaNotAccepted": "Not accepted",
|
||||
"criteriaUnknown": "Unknown",
|
||||
"criteriaEmpty": "Empty"
|
||||
},
|
||||
"addRelationshipModal": {
|
||||
"title": "Add Relationship",
|
||||
@@ -151,6 +165,8 @@
|
||||
"deleteRelationship": "Delete Relationship",
|
||||
"confirmDeleteComment": "Are you sure you want to delete this comment? This will also hide all replies.",
|
||||
"confirmDeleteReply": "Are you sure you want to delete this reply?",
|
||||
"confirmDeleteRelationship": "Are you sure you want to delete this relationship? This action cannot be undone."
|
||||
"confirmDeleteRelationship": "Are you sure you want to delete this relationship? This action cannot be undone.",
|
||||
"deleteCriteria": "Delete Criterion",
|
||||
"confirmDeleteCriteria": "Are you sure you want to delete this criterion? This action cannot be undone."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,14 @@
|
||||
"acceptanceCriteria": {
|
||||
"title": "Critérios de Aceitação",
|
||||
"noCriteria": "Nenhum critério de aceitação definido ainda.",
|
||||
"addButton": "Adicionar Critério"
|
||||
"addButton": "Adicionar Critério",
|
||||
"placeholder": "Descreva um novo critério...",
|
||||
"loading": "Carregando critérios de aceitação...",
|
||||
"creating": "Criando...",
|
||||
"editButton": "Editar",
|
||||
"saveButton": "Salvar",
|
||||
"cancelButton": "Cancelar",
|
||||
"deleteButton": "Excluir"
|
||||
},
|
||||
"comments": {
|
||||
"title": "Comentários Compartilhados",
|
||||
@@ -117,7 +124,14 @@
|
||||
"showLess": "Mostrar menos",
|
||||
"showFullDiff": "Mostrar diferença completa",
|
||||
"deletedRequirement": "Requisito Excluído",
|
||||
"unknownGroup": "Grupo Desconhecido"
|
||||
"unknownGroup": "Grupo Desconhecido",
|
||||
"criteriaAdded": "Critério Adicionado",
|
||||
"criteriaUpdated": "Critério Atualizado",
|
||||
"criteriaDeleted": "Critério Excluído",
|
||||
"criteriaAccepted": "Aceito",
|
||||
"criteriaNotAccepted": "Não aceito",
|
||||
"criteriaUnknown": "Desconhecido",
|
||||
"criteriaEmpty": "Vazio"
|
||||
},
|
||||
"addRelationshipModal": {
|
||||
"title": "Adicionar Relacionamento",
|
||||
@@ -151,6 +165,8 @@
|
||||
"deleteRelationship": "Excluir Relacionamento",
|
||||
"confirmDeleteComment": "Tem certeza de que deseja excluir este comentário? Isso também ocultará todas as respostas.",
|
||||
"confirmDeleteReply": "Tem certeza de que deseja excluir esta resposta?",
|
||||
"confirmDeleteRelationship": "Tem certeza de que deseja excluir este relacionamento? Esta ação não pode ser desfeita."
|
||||
"confirmDeleteRelationship": "Tem certeza de que deseja excluir este relacionamento? Esta ação não pode ser desfeita.",
|
||||
"deleteCriteria": "Excluir Critério",
|
||||
"confirmDeleteCriteria": "Tem certeza de que deseja excluir este critério? Esta ação não pode ser desfeita."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,20 @@ import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth, useProject } from '@/hooks'
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService } from '@/services'
|
||||
import { requirementService, validationService, relationshipService, commentService, tagService, priorityService, groupService, requirementStatusService, acceptanceCriteriaService } from '@/services'
|
||||
import { LanguageSelector } from '@/components'
|
||||
import type { Requirement } from '@/services/requirementService'
|
||||
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||
import type { Tag } from '@/services/tagService'
|
||||
import type { Priority } from '@/services/priorityService'
|
||||
import type { Group } from '@/services/groupService'
|
||||
import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory, RequirementStatus } from '@/types'
|
||||
import type { ValidationStatus, ValidationHistory, Comment, RequirementHistory, RequirementStatus, AcceptanceCriteria, AcceptanceCriteriaHistory } from '@/types'
|
||||
|
||||
// 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'
|
||||
type TimelineEventType = 'requirement_edit' | 'link_created' | 'link_removed' | 'group_added' | 'group_removed' | 'criteria_added' | 'criteria_updated' | 'criteria_deleted'
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string
|
||||
@@ -39,6 +39,15 @@ interface TimelineEvent {
|
||||
groupName: string | null
|
||||
groupHexColor: string | null
|
||||
}
|
||||
// For acceptance criteria events
|
||||
criteriaData?: {
|
||||
criteriaId: number
|
||||
oldText: string | null
|
||||
newText: string | null
|
||||
oldAccepted: boolean | null
|
||||
newAccepted: boolean | null
|
||||
editedByUsername: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export default function RequirementDetailPage() {
|
||||
@@ -87,10 +96,22 @@ export default function RequirementDetailPage() {
|
||||
const [deletingCommentId, setDeletingCommentId] = useState<number | null>(null)
|
||||
const [deletingReplyId, setDeletingReplyId] = useState<number | null>(null)
|
||||
|
||||
// Acceptance criteria state
|
||||
const [acceptanceCriteria, setAcceptanceCriteria] = useState<AcceptanceCriteria[]>([])
|
||||
const [criteriaLoading, setCriteriaLoading] = useState(false)
|
||||
const [criteriaError, setCriteriaError] = useState<string | null>(null)
|
||||
const [newCriteriaText, setNewCriteriaText] = useState('')
|
||||
const [creatingCriteria, setCreatingCriteria] = useState(false)
|
||||
const [editingCriteriaId, setEditingCriteriaId] = useState<number | null>(null)
|
||||
const [editCriteriaText, setEditCriteriaText] = useState('')
|
||||
const [updatingCriteriaId, setUpdatingCriteriaId] = useState<number | null>(null)
|
||||
const [togglingCriteriaIds, setTogglingCriteriaIds] = useState<number[]>([])
|
||||
const [deletingCriteriaId, setDeletingCriteriaId] = useState<number | null>(null)
|
||||
|
||||
// Delete confirmation modal state
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
|
||||
const [deleteConfirmData, setDeleteConfirmData] = useState<{
|
||||
type: 'comment' | 'reply' | 'link'
|
||||
type: 'comment' | 'reply' | 'link' | 'criteria'
|
||||
id: number
|
||||
parentId?: number
|
||||
title: string
|
||||
@@ -210,6 +231,27 @@ export default function RequirementDetailPage() {
|
||||
fetchCommentsData()
|
||||
}, [activeTab, id])
|
||||
|
||||
// Fetch acceptance criteria when tab is active
|
||||
useEffect(() => {
|
||||
const fetchCriteriaData = async () => {
|
||||
if (activeTab !== 'acceptance-criteria' || !id) return
|
||||
|
||||
try {
|
||||
setCriteriaLoading(true)
|
||||
setCriteriaError(null)
|
||||
const criteria = await acceptanceCriteriaService.getCriteria(parseInt(id, 10))
|
||||
setAcceptanceCriteria(criteria)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch acceptance criteria:', err)
|
||||
setCriteriaError('Failed to load acceptance criteria')
|
||||
} finally {
|
||||
setCriteriaLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchCriteriaData()
|
||||
}, [activeTab, id])
|
||||
|
||||
// Fetch requirement history when history tab is active
|
||||
useEffect(() => {
|
||||
const fetchHistoryData = async () => {
|
||||
@@ -219,13 +261,23 @@ export default function RequirementDetailPage() {
|
||||
setReqHistoryLoading(true)
|
||||
const reqId = parseInt(id, 10)
|
||||
|
||||
// Fetch all five data sources in parallel
|
||||
const [history, linkHistoryData, currentLinksData, groupHistoryData, currentGroupsData] = await Promise.all([
|
||||
// Fetch all data sources in parallel
|
||||
const [
|
||||
history,
|
||||
linkHistoryData,
|
||||
currentLinksData,
|
||||
groupHistoryData,
|
||||
currentGroupsData,
|
||||
currentCriteriaData,
|
||||
criteriaHistoryData
|
||||
] = await Promise.all([
|
||||
requirementService.getRequirementHistory(reqId),
|
||||
relationshipService.getLinkHistory(reqId),
|
||||
relationshipService.getRequirementLinks(reqId),
|
||||
groupService.getGroupHistory(reqId),
|
||||
groupService.getCurrentGroups(reqId)
|
||||
groupService.getCurrentGroups(reqId),
|
||||
acceptanceCriteriaService.getCriteria(reqId),
|
||||
acceptanceCriteriaService.getCriteriaHistory(reqId)
|
||||
])
|
||||
|
||||
// Build unified timeline
|
||||
@@ -308,6 +360,75 @@ export default function RequirementDetailPage() {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Add current acceptance criteria as "criteria_added" events
|
||||
currentCriteriaData.forEach((criteria) => {
|
||||
events.push({
|
||||
id: `criteria-current-${criteria.id}`,
|
||||
type: 'criteria_added',
|
||||
date: criteria.created_at || '',
|
||||
criteriaData: {
|
||||
criteriaId: criteria.id,
|
||||
oldText: null,
|
||||
newText: criteria.criteria_text,
|
||||
oldAccepted: null,
|
||||
newAccepted: criteria.is_accepted,
|
||||
editedByUsername: criteria.last_editor_username
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Build acceptance criteria update/delete events from history
|
||||
const criteriaGroups = new Map<number, AcceptanceCriteriaHistory[]>()
|
||||
criteriaHistoryData.forEach((item) => {
|
||||
const list = criteriaGroups.get(item.original_ac_id) || []
|
||||
list.push(item)
|
||||
criteriaGroups.set(item.original_ac_id, list)
|
||||
})
|
||||
|
||||
const currentCriteriaMap = new Map<number, AcceptanceCriteria>()
|
||||
currentCriteriaData.forEach((criteria) => {
|
||||
currentCriteriaMap.set(criteria.id, criteria)
|
||||
})
|
||||
|
||||
criteriaGroups.forEach((items, criteriaId) => {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
const dateA = a.valid_to ? new Date(a.valid_to).getTime() : 0
|
||||
const dateB = b.valid_to ? new Date(b.valid_to).getTime() : 0
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
const current = currentCriteriaMap.get(criteriaId)
|
||||
let nextState = {
|
||||
text: current ? current.criteria_text : null,
|
||||
accepted: current ? current.is_accepted : null
|
||||
}
|
||||
|
||||
sorted.forEach((item) => {
|
||||
const oldState = {
|
||||
text: item.criteria_text ?? null,
|
||||
accepted: item.is_accepted ?? null
|
||||
}
|
||||
|
||||
const isDeleted = nextState.text === null && nextState.accepted === null
|
||||
|
||||
events.push({
|
||||
id: `criteria-hist-${item.history_id}`,
|
||||
type: isDeleted ? 'criteria_deleted' : 'criteria_updated',
|
||||
date: item.valid_to || '',
|
||||
criteriaData: {
|
||||
criteriaId,
|
||||
oldText: oldState.text,
|
||||
newText: nextState.text,
|
||||
oldAccepted: oldState.accepted,
|
||||
newAccepted: nextState.accepted,
|
||||
editedByUsername: item.edited_by_username
|
||||
}
|
||||
})
|
||||
|
||||
nextState = oldState
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by date descending (newest first)
|
||||
events.sort((a, b) => {
|
||||
@@ -585,6 +706,98 @@ export default function RequirementDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Acceptance criteria handlers
|
||||
const handleCreateCriteria = async () => {
|
||||
if (!newCriteriaText.trim() || !id) return
|
||||
|
||||
try {
|
||||
setCreatingCriteria(true)
|
||||
const created = await acceptanceCriteriaService.createCriteria(parseInt(id, 10), {
|
||||
criteria_text: newCriteriaText.trim()
|
||||
})
|
||||
setAcceptanceCriteria(prev => [...prev, created])
|
||||
setNewCriteriaText('')
|
||||
} catch (err) {
|
||||
console.error('Failed to create acceptance criteria:', err)
|
||||
setCriteriaError('Failed to create acceptance criteria')
|
||||
} finally {
|
||||
setCreatingCriteria(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startEditCriteria = (criteria: AcceptanceCriteria) => {
|
||||
setEditingCriteriaId(criteria.id)
|
||||
setEditCriteriaText(criteria.criteria_text)
|
||||
}
|
||||
|
||||
const cancelEditCriteria = () => {
|
||||
setEditingCriteriaId(null)
|
||||
setEditCriteriaText('')
|
||||
}
|
||||
|
||||
const handleSaveCriteria = async (criteriaId: number) => {
|
||||
if (!editCriteriaText.trim()) return
|
||||
|
||||
try {
|
||||
setUpdatingCriteriaId(criteriaId)
|
||||
const updated = await acceptanceCriteriaService.updateCriteria(criteriaId, {
|
||||
criteria_text: editCriteriaText.trim()
|
||||
})
|
||||
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteriaId ? updated : c)))
|
||||
setEditingCriteriaId(null)
|
||||
setEditCriteriaText('')
|
||||
} catch (err) {
|
||||
console.error('Failed to update acceptance criteria:', err)
|
||||
setCriteriaError('Failed to update acceptance criteria')
|
||||
} finally {
|
||||
setUpdatingCriteriaId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleCriteria = async (criteria: AcceptanceCriteria) => {
|
||||
if (!canManageCriteria) return
|
||||
|
||||
const nextValue = !criteria.is_accepted
|
||||
const previousValue = criteria.is_accepted
|
||||
|
||||
setTogglingCriteriaIds(prev => [...prev, criteria.id])
|
||||
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteria.id ? { ...c, is_accepted: nextValue } : c)))
|
||||
|
||||
try {
|
||||
const updated = await acceptanceCriteriaService.updateCriteria(criteria.id, {
|
||||
is_accepted: nextValue
|
||||
})
|
||||
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteria.id ? updated : c)))
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle acceptance criteria:', err)
|
||||
setAcceptanceCriteria(prev => prev.map(c => (c.id === criteria.id ? { ...c, is_accepted: previousValue } : c)))
|
||||
} finally {
|
||||
setTogglingCriteriaIds(prev => prev.filter(id => id !== criteria.id))
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteCriteriaModal = (criteriaId: number) => {
|
||||
setDeleteConfirmData({
|
||||
type: 'criteria',
|
||||
id: criteriaId,
|
||||
title: t('deleteModal.deleteCriteria'),
|
||||
message: t('deleteModal.confirmDeleteCriteria')
|
||||
})
|
||||
setShowDeleteConfirmModal(true)
|
||||
}
|
||||
|
||||
const executeDeleteCriteria = async (criteriaId: number) => {
|
||||
try {
|
||||
setDeletingCriteriaId(criteriaId)
|
||||
await acceptanceCriteriaService.deleteCriteria(criteriaId)
|
||||
setAcceptanceCriteria(prev => prev.filter(c => c.id !== criteriaId))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete acceptance criteria:', err)
|
||||
} finally {
|
||||
setDeletingCriteriaId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = async () => {
|
||||
if (!deleteConfirmData) return
|
||||
@@ -603,6 +816,9 @@ export default function RequirementDetailPage() {
|
||||
await executeDeleteReply(deleteConfirmData.id, deleteConfirmData.parentId)
|
||||
}
|
||||
break
|
||||
case 'criteria':
|
||||
await executeDeleteCriteria(deleteConfirmData.id)
|
||||
break
|
||||
}
|
||||
} finally {
|
||||
setDeleteConfirmLoading(false)
|
||||
@@ -719,6 +935,7 @@ export default function RequirementDetailPage() {
|
||||
// Check if requirement is in draft status
|
||||
const isDraftStatus = requirement?.status?.status_code === 'DRAFT'
|
||||
const isAdmin = user?.role_id === 3
|
||||
const canManageCriteria = user?.role_id === 1 || user?.role_id === 3
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -961,12 +1178,110 @@ export default function RequirementDetailPage() {
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">{t('acceptanceCriteria.title')}</h3>
|
||||
<p className="text-gray-500">{t('acceptanceCriteria.noCriteria')}</p>
|
||||
{!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">
|
||||
{t('acceptanceCriteria.addButton')}
|
||||
</button>
|
||||
{criteriaLoading ? (
|
||||
<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">{t('acceptanceCriteria.loading')}</p>
|
||||
</div>
|
||||
) : criteriaError ? (
|
||||
<p className="text-red-500 text-sm">{criteriaError}</p>
|
||||
) : acceptanceCriteria.length === 0 ? (
|
||||
<p className="text-gray-500">{t('acceptanceCriteria.noCriteria')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{acceptanceCriteria.map((criteria) => {
|
||||
const isEditing = editingCriteriaId === criteria.id
|
||||
const isToggling = togglingCriteriaIds.includes(criteria.id)
|
||||
const isUpdating = updatingCriteriaId === criteria.id
|
||||
const isDeleting = deletingCriteriaId === criteria.id
|
||||
|
||||
return (
|
||||
<div key={criteria.id} className="flex items-start gap-3 p-3 border border-gray-200 rounded">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={criteria.is_accepted}
|
||||
onChange={() => handleToggleCriteria(criteria)}
|
||||
disabled={!canManageCriteria || isToggling}
|
||||
className="mt-1 h-4 w-4 text-teal-600 border-gray-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editCriteriaText}
|
||||
onChange={(e) => setEditCriteriaText(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"
|
||||
/>
|
||||
) : (
|
||||
<p className={`text-sm ${criteria.is_accepted ? 'line-through text-gray-500' : 'text-gray-800'}`}>
|
||||
{criteria.criteria_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManageCriteria && (
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleSaveCriteria(criteria.id)}
|
||||
disabled={isUpdating || !editCriteriaText.trim()}
|
||||
className="text-sm text-teal-600 hover:text-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{t('acceptanceCriteria.saveButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelEditCriteria}
|
||||
disabled={isUpdating}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{t('acceptanceCriteria.cancelButton')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => startEditCriteria(criteria)}
|
||||
disabled={isDeleting}
|
||||
className="text-sm text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
{t('acceptanceCriteria.editButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openDeleteCriteriaModal(criteria.id)}
|
||||
disabled={isDeleting}
|
||||
className="text-sm text-red-600 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
{t('acceptanceCriteria.deleteButton')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManageCriteria && (
|
||||
<div className="mt-6 border-t border-gray-200 pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newCriteriaText}
|
||||
onChange={(e) => setNewCriteriaText(e.target.value)}
|
||||
placeholder={t('acceptanceCriteria.placeholder')}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateCriteria}
|
||||
disabled={creatingCriteria || !newCriteriaText.trim()}
|
||||
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{creatingCriteria ? t('acceptanceCriteria.creating') : t('acceptanceCriteria.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1648,6 +1963,70 @@ export default function RequirementDetailPage() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if ((event.type === 'criteria_added' || event.type === 'criteria_updated' || event.type === 'criteria_deleted') && event.criteriaData) {
|
||||
const criteria = event.criteriaData
|
||||
const isDeleted = event.type === 'criteria_deleted'
|
||||
const isAdded = event.type === 'criteria_added'
|
||||
|
||||
const acceptanceLabel = (value: boolean | null) => {
|
||||
if (value === null) return t('history.criteriaUnknown')
|
||||
return value ? t('history.criteriaAccepted') : t('history.criteriaNotAccepted')
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={event.id} className="border border-gray-200 rounded overflow-hidden">
|
||||
<div className={`flex items-center justify-between px-4 py-3 border-l-4 ${
|
||||
isAdded
|
||||
? 'bg-green-50 border-green-400'
|
||||
: isDeleted
|
||||
? 'bg-red-50 border-red-400'
|
||||
: 'bg-blue-50 border-blue-400'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">✅</span>
|
||||
<span className="font-medium text-gray-800">
|
||||
{isAdded
|
||||
? t('history.criteriaAdded')
|
||||
: isDeleted
|
||||
? t('history.criteriaDeleted')
|
||||
: t('history.criteriaUpdated')}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{isAdded ? (
|
||||
<>
|
||||
<span className="font-medium">{criteria.newText || t('history.criteriaEmpty')}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">({acceptanceLabel(criteria.newAccepted)})</span>
|
||||
</>
|
||||
) : isDeleted ? (
|
||||
<>
|
||||
<span className="line-through">{criteria.oldText || t('history.criteriaEmpty')}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">({acceptanceLabel(criteria.oldAccepted)})</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="line-through">{criteria.oldText || t('history.criteriaEmpty')}</span>
|
||||
<span className="mx-2">→</span>
|
||||
<span className="font-medium">{criteria.newText || t('history.criteriaEmpty')}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">
|
||||
({acceptanceLabel(criteria.oldAccepted)} → {acceptanceLabel(criteria.newAccepted)})
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
||||
{criteria.editedByUsername && (
|
||||
<span>by {criteria.editedByUsername}</span>
|
||||
)}
|
||||
<span>
|
||||
{event.date ? new Date(event.date).toLocaleString() : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})}
|
||||
|
||||
101
frontend/src/services/acceptanceCriteriaService.ts
Normal file
101
frontend/src/services/acceptanceCriteriaService.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
AcceptanceCriteria,
|
||||
AcceptanceCriteriaCreateRequest,
|
||||
AcceptanceCriteriaUpdateRequest,
|
||||
AcceptanceCriteriaHistory
|
||||
} from '@/types'
|
||||
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
class AcceptanceCriteriaService {
|
||||
async getCriteria(requirementId: number): Promise<AcceptanceCriteria[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/acceptance-criteria`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async createCriteria(
|
||||
requirementId: number,
|
||||
data: AcceptanceCriteriaCreateRequest
|
||||
): Promise<AcceptanceCriteria> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/acceptance-criteria`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async updateCriteria(
|
||||
criteriaId: number,
|
||||
data: AcceptanceCriteriaUpdateRequest
|
||||
): Promise<AcceptanceCriteria> {
|
||||
const response = await fetch(`${API_BASE_URL}/acceptance-criteria/${criteriaId}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async deleteCriteria(criteriaId: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/acceptance-criteria/${criteriaId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
async getCriteriaHistory(requirementId: number): Promise<AcceptanceCriteriaHistory[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/acceptance-criteria/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 acceptanceCriteriaService = new AcceptanceCriteriaService()
|
||||
@@ -23,6 +23,7 @@ export type {
|
||||
export { userService } from './userService'
|
||||
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
|
||||
export { commentService } from './commentService'
|
||||
export { acceptanceCriteriaService } from './acceptanceCriteriaService'
|
||||
export { superAdminService } from './superAdminService'
|
||||
export type {
|
||||
SystemUser,
|
||||
|
||||
@@ -90,6 +90,37 @@ export interface RequirementLinkHistory {
|
||||
valid_to: string | null // When link was deleted
|
||||
}
|
||||
|
||||
// Acceptance Criteria types
|
||||
export interface AcceptanceCriteria {
|
||||
id: number
|
||||
requirement_id: number
|
||||
criteria_text: string
|
||||
is_accepted: boolean
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
last_editor_username: string | null
|
||||
}
|
||||
|
||||
export interface AcceptanceCriteriaCreateRequest {
|
||||
criteria_text: string
|
||||
}
|
||||
|
||||
export interface AcceptanceCriteriaUpdateRequest {
|
||||
criteria_text?: string
|
||||
is_accepted?: boolean
|
||||
}
|
||||
|
||||
export interface AcceptanceCriteriaHistory {
|
||||
history_id: number
|
||||
original_ac_id: number
|
||||
requirement_id: number
|
||||
criteria_text: string | null
|
||||
is_accepted: boolean | null
|
||||
valid_from: string | null
|
||||
valid_to: string | null
|
||||
edited_by_username: string | null
|
||||
}
|
||||
|
||||
// Requirement Group History types
|
||||
export interface RequirementGroupHistory {
|
||||
history_id: number
|
||||
|
||||
Reference in New Issue
Block a user