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

@@ -62,6 +62,10 @@ class User(Base):
created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator") created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator")
comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", back_populates="user") comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", back_populates="user")
comment_replies: Mapped[List["RequirementCommentReply"]] = relationship("RequirementCommentReply", back_populates="user") comment_replies: Mapped[List["RequirementCommentReply"]] = relationship("RequirementCommentReply", back_populates="user")
acceptance_criteria_edits: Mapped[List["AcceptanceCriteria"]] = relationship(
"AcceptanceCriteria",
back_populates="last_editor"
)
class Tag(Base): class Tag(Base):
@@ -261,6 +265,11 @@ class Requirement(Base):
back_populates="requirement", back_populates="requirement",
cascade="all, delete-orphan" cascade="all, delete-orphan"
) )
acceptance_criteria: Mapped[List["AcceptanceCriteria"]] = relationship(
"AcceptanceCriteria",
back_populates="requirement",
cascade="all, delete-orphan"
)
# Indexes # Indexes
__table_args__ = ( __table_args__ = (
@@ -293,6 +302,45 @@ class RequirementGroup(Base):
) )
class AcceptanceCriteria(Base):
"""Acceptance criteria for requirements."""
__tablename__ = "acceptance_criteria"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
requirement_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("requirements.id", ondelete="CASCADE"),
nullable=False
)
criteria_text: Mapped[str] = mapped_column(Text, nullable=False)
is_accepted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=True
)
last_editor_id: Mapped[Optional[int]] = mapped_column(
Integer,
ForeignKey("users.id"),
nullable=True
)
# Relationships
requirement: Mapped["Requirement"] = relationship("Requirement", back_populates="acceptance_criteria")
last_editor: Mapped[Optional["User"]] = relationship("User", back_populates="acceptance_criteria_edits")
# Indexes
__table_args__ = (
Index("idx_ac_requirement", "requirement_id"),
)
class Validation(Base): class Validation(Base):
"""Validation records for requirements.""" """Validation records for requirements."""
__tablename__ = "validations" __tablename__ = "validations"
@@ -344,6 +392,28 @@ class RequirementHistory(Base):
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
class AcceptanceCriteriaHistory(Base):
"""
Historical records of acceptance criteria changes.
Note: This is populated by a database trigger on UPDATE/DELETE.
"""
__tablename__ = "acceptance_criteria_history"
history_id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
original_ac_id: Mapped[int] = mapped_column(Integer, nullable=False)
requirement_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
criteria_text: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
is_accepted: Mapped[Optional[bool]] = mapped_column(Boolean, nullable=True)
valid_from: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
valid_to: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
edited_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
# Indexes
__table_args__ = (
Index("idx_ach_req", "requirement_id"),
)
class RelationshipType(Base): class RelationshipType(Base):
""" """
Defines valid relationship types per project. Defines valid relationship types per project.

View File

@@ -18,6 +18,7 @@ from src.models import (
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES, RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest, CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
RequirementStatusResponse, DeletedRequirementResponse, RequirementStatusResponse, DeletedRequirementResponse,
AcceptanceCriteriaResponse, AcceptanceCriteriaCreateRequest, AcceptanceCriteriaUpdateRequest, AcceptanceCriteriaHistoryResponse,
UserCreateRequest, UserCreateResponse, UserCreateRequest, UserCreateResponse,
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse, SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
AssignUserToProjectRequest, SystemUserCreateRequest AssignUserToProjectRequest, SystemUserCreateRequest
@@ -29,7 +30,7 @@ from src.repositories import (
RoleRepository, GroupRepository, TagRepository, RequirementRepository, RoleRepository, GroupRepository, TagRepository, RequirementRepository,
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository, PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository, RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
RequirementStatusRepository, UserRepository RequirementStatusRepository, UserRepository, AcceptanceCriteriaRepository
) )
from src.service import KeycloakAdminService from src.service import KeycloakAdminService
import logging import logging
@@ -1842,6 +1843,211 @@ async def get_requirement_current_groups(
return [CurrentRequirementGroupResponse(**g) for g in groups] return [CurrentRequirementGroupResponse(**g) for g in groups]
# ===========================================
# Acceptance Criteria Endpoints
# ===========================================
def _build_acceptance_criteria_response(criteria) -> AcceptanceCriteriaResponse:
return AcceptanceCriteriaResponse(
id=criteria.id,
requirement_id=criteria.requirement_id,
criteria_text=criteria.criteria_text,
is_accepted=criteria.is_accepted,
created_at=criteria.created_at,
updated_at=criteria.updated_at,
last_editor_username=_get_display_name(criteria.last_editor) if getattr(criteria, "last_editor", None) else None
)
@app.get("/api/requirements/{requirement_id}/acceptance-criteria", response_model=List[AcceptanceCriteriaResponse])
async def get_acceptance_criteria(
requirement_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get all acceptance criteria for a requirement.
User must be a member of the requirement's project.
"""
user = await _get_current_user_db(request, db)
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"
)
await _verify_project_membership(requirement.project_id, user.id, db)
criteria_repo = AcceptanceCriteriaRepository(db)
criteria = await criteria_repo.get_by_requirement_id(requirement_id)
return [_build_acceptance_criteria_response(c) for c in criteria]
@app.post(
"/api/requirements/{requirement_id}/acceptance-criteria",
response_model=AcceptanceCriteriaResponse,
status_code=status.HTTP_201_CREATED
)
async def create_acceptance_criteria(
requirement_id: int,
request: Request,
criteria_data: AcceptanceCriteriaCreateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Create a new acceptance criterion for a requirement.
Only editors and admins can create criteria.
"""
user = await _get_current_user_db(request, db)
_require_role(user, [1, 3], "create acceptance criteria")
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"
)
await _verify_project_membership(requirement.project_id, user.id, db)
if not criteria_data.criteria_text.strip():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Criteria text cannot be empty"
)
criteria_repo = AcceptanceCriteriaRepository(db)
criteria = await criteria_repo.create(
requirement_id=requirement_id,
criteria_text=criteria_data.criteria_text.strip(),
editor_id=user.id
)
await db.commit()
criteria = await criteria_repo.get_by_id(criteria.id)
return _build_acceptance_criteria_response(criteria)
@app.put("/api/acceptance-criteria/{criteria_id}", response_model=AcceptanceCriteriaResponse)
async def update_acceptance_criteria(
criteria_id: int,
request: Request,
criteria_data: AcceptanceCriteriaUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update an acceptance criterion (text and/or accepted flag).
Only editors and admins can update criteria.
"""
user = await _get_current_user_db(request, db)
_require_role(user, [1, 3], "update acceptance criteria")
criteria_repo = AcceptanceCriteriaRepository(db)
existing = await criteria_repo.get_by_id(criteria_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Acceptance criteria with id {criteria_id} not found"
)
req_repo = RequirementRepository(db)
requirement = await req_repo.get_by_id(existing.requirement_id)
await _verify_project_membership(requirement.project_id, user.id, db)
if criteria_data.criteria_text is None and criteria_data.is_accepted is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No fields provided to update"
)
criteria_text = criteria_data.criteria_text
if criteria_text is not None:
criteria_text = criteria_text.strip()
if not criteria_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Criteria text cannot be empty"
)
await criteria_repo.update(
criteria_id=criteria_id,
editor_id=user.id,
criteria_text=criteria_text,
is_accepted=criteria_data.is_accepted
)
await db.commit()
updated = await criteria_repo.get_by_id(criteria_id)
return _build_acceptance_criteria_response(updated)
@app.delete("/api/acceptance-criteria/{criteria_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_acceptance_criteria(
criteria_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Delete an acceptance criterion.
Only editors and admins can delete criteria.
"""
user = await _get_current_user_db(request, db)
_require_role(user, [1, 3], "delete acceptance criteria")
criteria_repo = AcceptanceCriteriaRepository(db)
existing = await criteria_repo.get_by_id(criteria_id)
if not existing:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Acceptance criteria with id {criteria_id} not found"
)
req_repo = RequirementRepository(db)
requirement = await req_repo.get_by_id(existing.requirement_id)
await _verify_project_membership(requirement.project_id, user.id, db)
await criteria_repo.delete(criteria_id)
await db.commit()
@app.get(
"/api/requirements/{requirement_id}/acceptance-criteria/history",
response_model=List[AcceptanceCriteriaHistoryResponse]
)
async def get_acceptance_criteria_history(
requirement_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Get acceptance criteria history for a requirement.
Returns update/delete history with editor info.
"""
user = await _get_current_user_db(request, db)
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"
)
await _verify_project_membership(requirement.project_id, user.id, db)
criteria_repo = AcceptanceCriteriaRepository(db)
history = await criteria_repo.get_history_by_requirement_id(requirement_id)
return [AcceptanceCriteriaHistoryResponse(**h) for h in history]
# =========================================== # ===========================================
# Comment Endpoints # Comment Endpoints
# =========================================== # ===========================================

View File

@@ -374,6 +374,47 @@ class RequirementLinkHistoryResponse(BaseModel):
from_attributes = True from_attributes = True
# Acceptance Criteria schemas
class AcceptanceCriteriaResponse(BaseModel):
"""Response schema for acceptance criteria."""
id: int
requirement_id: int
criteria_text: str
is_accepted: bool
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
last_editor_username: Optional[str] = None
class Config:
from_attributes = True
class AcceptanceCriteriaCreateRequest(BaseModel):
"""Request schema for creating acceptance criteria."""
criteria_text: str
class AcceptanceCriteriaUpdateRequest(BaseModel):
"""Request schema for updating acceptance criteria."""
criteria_text: Optional[str] = None
is_accepted: Optional[bool] = None
class AcceptanceCriteriaHistoryResponse(BaseModel):
"""Response schema for acceptance criteria history."""
history_id: int
original_ac_id: int
requirement_id: int
criteria_text: Optional[str] = None
is_accepted: Optional[bool] = None
valid_from: Optional[datetime] = None
valid_to: Optional[datetime] = None
edited_by_username: Optional[str] = None
class Config:
from_attributes = True
# Requirement Search schemas # Requirement Search schemas
class RequirementSearchResult(BaseModel): class RequirementSearchResult(BaseModel):
"""Response schema for requirement search results (for autocomplete).""" """Response schema for requirement search results (for autocomplete)."""

View File

@@ -13,6 +13,7 @@ from src.repositories.relationship_type_repository import RelationshipTypeReposi
from src.repositories.requirement_link_repository import RequirementLinkRepository from src.repositories.requirement_link_repository import RequirementLinkRepository
from src.repositories.comment_repository import CommentRepository, ReplyRepository from src.repositories.comment_repository import CommentRepository, ReplyRepository
from src.repositories.requirement_status_repository import RequirementStatusRepository from src.repositories.requirement_status_repository import RequirementStatusRepository
from src.repositories.acceptance_criteria_repository import AcceptanceCriteriaRepository
__all__ = [ __all__ = [
"UserRepository", "UserRepository",
@@ -29,4 +30,5 @@ __all__ = [
"CommentRepository", "CommentRepository",
"ReplyRepository", "ReplyRepository",
"RequirementStatusRepository", "RequirementStatusRepository",
"AcceptanceCriteriaRepository",
] ]

View File

@@ -0,0 +1,117 @@
"""
Repository for Acceptance Criteria database operations.
"""
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 AcceptanceCriteria
class AcceptanceCriteriaRepository:
"""Repository for acceptance criteria CRUD and history operations."""
def __init__(self, db: AsyncSession):
self.db = db
async def get_by_requirement_id(self, requirement_id: int) -> List[AcceptanceCriteria]:
result = await self.db.execute(
select(AcceptanceCriteria)
.options(selectinload(AcceptanceCriteria.last_editor))
.where(AcceptanceCriteria.requirement_id == requirement_id)
.order_by(AcceptanceCriteria.created_at.asc())
)
return list(result.scalars().all())
async def get_by_id(self, criteria_id: int) -> Optional[AcceptanceCriteria]:
result = await self.db.execute(
select(AcceptanceCriteria)
.options(selectinload(AcceptanceCriteria.last_editor))
.where(AcceptanceCriteria.id == criteria_id)
)
return result.scalar_one_or_none()
async def create(
self,
requirement_id: int,
criteria_text: str,
editor_id: int
) -> AcceptanceCriteria:
criteria = AcceptanceCriteria(
requirement_id=requirement_id,
criteria_text=criteria_text,
is_accepted=False,
last_editor_id=editor_id
)
self.db.add(criteria)
await self.db.flush()
await self.db.refresh(criteria)
return criteria
async def update(
self,
criteria_id: int,
editor_id: int,
criteria_text: Optional[str] = None,
is_accepted: Optional[bool] = None
) -> Optional[AcceptanceCriteria]:
criteria = await self.get_by_id(criteria_id)
if not criteria:
return None
if criteria_text is not None:
criteria.criteria_text = criteria_text
if is_accepted is not None:
criteria.is_accepted = is_accepted
criteria.last_editor_id = editor_id
await self.db.flush()
await self.db.refresh(criteria)
return criteria
async def delete(self, criteria_id: int) -> bool:
result = await self.db.execute(
select(AcceptanceCriteria).where(AcceptanceCriteria.id == criteria_id)
)
criteria = result.scalar_one_or_none()
if criteria:
await self.db.delete(criteria)
await self.db.flush()
return True
return False
async def get_history_by_requirement_id(self, requirement_id: int) -> List[Dict[str, Any]]:
query = text("""
SELECT
ach.history_id,
ach.original_ac_id,
ach.requirement_id,
ach.criteria_text,
ach.is_accepted,
ach.valid_from,
ach.valid_to,
u.full_name as edited_by_full_name,
u.username as edited_by_username
FROM acceptance_criteria_history ach
LEFT JOIN users u ON ach.edited_by = u.id
WHERE ach.requirement_id = :requirement_id
ORDER BY ach.valid_to DESC
""")
result = await self.db.execute(query, {"requirement_id": requirement_id})
rows = result.fetchall()
return [
{
"history_id": row.history_id,
"original_ac_id": row.original_ac_id,
"requirement_id": row.requirement_id,
"criteria_text": row.criteria_text,
"is_accepted": row.is_accepted,
"valid_from": row.valid_from,
"valid_to": row.valid_to,
"edited_by_username": row.edited_by_full_name or row.edited_by_username,
}
for row in rows
]

View File

@@ -55,7 +55,14 @@
"acceptanceCriteria": { "acceptanceCriteria": {
"title": "Acceptance Criteria", "title": "Acceptance Criteria",
"noCriteria": "No acceptance criteria defined yet.", "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": { "comments": {
"title": "Shared Comments", "title": "Shared Comments",
@@ -117,7 +124,14 @@
"showLess": "Show less", "showLess": "Show less",
"showFullDiff": "Show full diff", "showFullDiff": "Show full diff",
"deletedRequirement": "Deleted Requirement", "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": { "addRelationshipModal": {
"title": "Add Relationship", "title": "Add Relationship",
@@ -151,6 +165,8 @@
"deleteRelationship": "Delete Relationship", "deleteRelationship": "Delete Relationship",
"confirmDeleteComment": "Are you sure you want to delete this comment? This will also hide all replies.", "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?", "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": { "acceptanceCriteria": {
"title": "Critérios de Aceitação", "title": "Critérios de Aceitação",
"noCriteria": "Nenhum critério de aceitação definido ainda.", "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": { "comments": {
"title": "Comentários Compartilhados", "title": "Comentários Compartilhados",
@@ -117,7 +124,14 @@
"showLess": "Mostrar menos", "showLess": "Mostrar menos",
"showFullDiff": "Mostrar diferença completa", "showFullDiff": "Mostrar diferença completa",
"deletedRequirement": "Requisito Excluído", "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": { "addRelationshipModal": {
"title": "Adicionar Relacionamento", "title": "Adicionar Relacionamento",
@@ -151,6 +165,8 @@
"deleteRelationship": "Excluir Relacionamento", "deleteRelationship": "Excluir Relacionamento",
"confirmDeleteComment": "Tem certeza de que deseja excluir este comentário? Isso também ocultará todas as respostas.", "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?", "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 { useTranslation } from 'react-i18next'
import { useAuth, useProject } from '@/hooks' import { useAuth, useProject } from '@/hooks'
import { useParams, Link, useNavigate } from 'react-router-dom' 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 { LanguageSelector } from '@/components'
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 { Tag } from '@/services/tagService' import type { Tag } from '@/services/tagService'
import type { Priority } from '@/services/priorityService' import type { Priority } from '@/services/priorityService'
import type { Group } from '@/services/groupService' 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 // Tab types
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history' type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate' | 'history'
// Timeline event types for unified history view // 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 { interface TimelineEvent {
id: string id: string
@@ -39,6 +39,15 @@ interface TimelineEvent {
groupName: string | null groupName: string | null
groupHexColor: 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() { export default function RequirementDetailPage() {
@@ -87,10 +96,22 @@ 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)
// 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 // Delete confirmation modal state
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false) const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false)
const [deleteConfirmData, setDeleteConfirmData] = useState<{ const [deleteConfirmData, setDeleteConfirmData] = useState<{
type: 'comment' | 'reply' | 'link' type: 'comment' | 'reply' | 'link' | 'criteria'
id: number id: number
parentId?: number parentId?: number
title: string title: string
@@ -210,6 +231,27 @@ export default function RequirementDetailPage() {
fetchCommentsData() fetchCommentsData()
}, [activeTab, id]) }, [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 // Fetch requirement history when history tab is active
useEffect(() => { useEffect(() => {
const fetchHistoryData = async () => { const fetchHistoryData = async () => {
@@ -219,13 +261,23 @@ export default function RequirementDetailPage() {
setReqHistoryLoading(true) setReqHistoryLoading(true)
const reqId = parseInt(id, 10) const reqId = parseInt(id, 10)
// Fetch all five data sources in parallel // Fetch all data sources in parallel
const [history, linkHistoryData, currentLinksData, groupHistoryData, currentGroupsData] = await Promise.all([ const [
history,
linkHistoryData,
currentLinksData,
groupHistoryData,
currentGroupsData,
currentCriteriaData,
criteriaHistoryData
] = await Promise.all([
requirementService.getRequirementHistory(reqId), requirementService.getRequirementHistory(reqId),
relationshipService.getLinkHistory(reqId), relationshipService.getLinkHistory(reqId),
relationshipService.getRequirementLinks(reqId), relationshipService.getRequirementLinks(reqId),
groupService.getGroupHistory(reqId), groupService.getGroupHistory(reqId),
groupService.getCurrentGroups(reqId) groupService.getCurrentGroups(reqId),
acceptanceCriteriaService.getCriteria(reqId),
acceptanceCriteriaService.getCriteriaHistory(reqId)
]) ])
// Build unified timeline // 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) // Sort by date descending (newest first)
events.sort((a, b) => { 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 // Handle delete confirmation
const handleConfirmDelete = async () => { const handleConfirmDelete = async () => {
if (!deleteConfirmData) return if (!deleteConfirmData) return
@@ -603,6 +816,9 @@ export default function RequirementDetailPage() {
await executeDeleteReply(deleteConfirmData.id, deleteConfirmData.parentId) await executeDeleteReply(deleteConfirmData.id, deleteConfirmData.parentId)
} }
break break
case 'criteria':
await executeDeleteCriteria(deleteConfirmData.id)
break
} }
} finally { } finally {
setDeleteConfirmLoading(false) setDeleteConfirmLoading(false)
@@ -719,6 +935,7 @@ export default function RequirementDetailPage() {
// Check if requirement is in draft status // Check if requirement is in draft status
const isDraftStatus = requirement?.status?.status_code === 'DRAFT' const isDraftStatus = requirement?.status?.status_code === 'DRAFT'
const isAdmin = user?.role_id === 3 const isAdmin = user?.role_id === 3
const canManageCriteria = user?.role_id === 1 || user?.role_id === 3
if (loading) { if (loading) {
return ( return (
@@ -961,12 +1178,110 @@ export default function RequirementDetailPage() {
return ( return (
<div> <div>
<h3 className="text-xl font-bold text-gray-800 mb-4">{t('acceptanceCriteria.title')}</h3> <h3 className="text-xl font-bold text-gray-800 mb-4">{t('acceptanceCriteria.title')}</h3>
<p className="text-gray-500">{t('acceptanceCriteria.noCriteria')}</p> {criteriaLoading ? (
{!isAuditor && ( <div className="text-center py-8">
<div className="mt-4"> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"> <p className="mt-2 text-gray-500 text-sm">{t('acceptanceCriteria.loading')}</p>
{t('acceptanceCriteria.addButton')} </div>
</button> ) : 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>
)} )}
</div> </div>
@@ -1648,6 +1963,70 @@ export default function RequirementDetailPage() {
</div> </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 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 { userService } from './userService'
export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService' export type { Role, ProjectMember, UserRoleUpdateRequest } from './userService'
export { commentService } from './commentService' export { commentService } from './commentService'
export { acceptanceCriteriaService } from './acceptanceCriteriaService'
export { superAdminService } from './superAdminService' export { superAdminService } from './superAdminService'
export type { export type {
SystemUser, SystemUser,

View File

@@ -90,6 +90,37 @@ export interface RequirementLinkHistory {
valid_to: string | null // When link was deleted 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 // Requirement Group History types
export interface RequirementGroupHistory { export interface RequirementGroupHistory {
history_id: number history_id: number