added acceptance criteria page

This commit is contained in:
gulimabr
2026-01-18 22:37:13 -03:00
parent 3be3c22be9
commit 5af9aa8358
11 changed files with 1000 additions and 20 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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