added acceptance criteria page
This commit is contained in:
@@ -62,6 +62,10 @@ class User(Base):
|
||||
created_links: Mapped[List["RequirementLink"]] = relationship("RequirementLink", back_populates="creator")
|
||||
comments: Mapped[List["RequirementComment"]] = relationship("RequirementComment", 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):
|
||||
@@ -261,6 +265,11 @@ class Requirement(Base):
|
||||
back_populates="requirement",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
acceptance_criteria: Mapped[List["AcceptanceCriteria"]] = relationship(
|
||||
"AcceptanceCriteria",
|
||||
back_populates="requirement",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Indexes
|
||||
__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):
|
||||
"""Validation records for requirements."""
|
||||
__tablename__ = "validations"
|
||||
@@ -344,6 +392,28 @@ class RequirementHistory(Base):
|
||||
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):
|
||||
"""
|
||||
Defines valid relationship types per project.
|
||||
|
||||
@@ -18,6 +18,7 @@ from src.models import (
|
||||
RoleResponse, ProjectMemberResponse, UserRoleUpdateRequest, ROLE_DISPLAY_NAMES,
|
||||
CommentResponse, CommentReplyResponse, CommentCreateRequest, ReplyCreateRequest,
|
||||
RequirementStatusResponse, DeletedRequirementResponse,
|
||||
AcceptanceCriteriaResponse, AcceptanceCriteriaCreateRequest, AcceptanceCriteriaUpdateRequest, AcceptanceCriteriaHistoryResponse,
|
||||
UserCreateRequest, UserCreateResponse,
|
||||
SystemUserResponse, SystemProjectResponse, SystemProjectMemberResponse,
|
||||
AssignUserToProjectRequest, SystemUserCreateRequest
|
||||
@@ -29,7 +30,7 @@ from src.repositories import (
|
||||
RoleRepository, GroupRepository, TagRepository, RequirementRepository,
|
||||
PriorityRepository, ProjectRepository, ValidationStatusRepository, ValidationRepository,
|
||||
RelationshipTypeRepository, RequirementLinkRepository, CommentRepository, ReplyRepository,
|
||||
RequirementStatusRepository, UserRepository
|
||||
RequirementStatusRepository, UserRepository, AcceptanceCriteriaRepository
|
||||
)
|
||||
from src.service import KeycloakAdminService
|
||||
import logging
|
||||
@@ -1842,6 +1843,211 @@ async def get_requirement_current_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
|
||||
# ===========================================
|
||||
|
||||
@@ -374,6 +374,47 @@ class RequirementLinkHistoryResponse(BaseModel):
|
||||
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
|
||||
class RequirementSearchResult(BaseModel):
|
||||
"""Response schema for requirement search results (for autocomplete)."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from src.repositories.relationship_type_repository import RelationshipTypeReposi
|
||||
from src.repositories.requirement_link_repository import RequirementLinkRepository
|
||||
from src.repositories.comment_repository import CommentRepository, ReplyRepository
|
||||
from src.repositories.requirement_status_repository import RequirementStatusRepository
|
||||
from src.repositories.acceptance_criteria_repository import AcceptanceCriteriaRepository
|
||||
|
||||
__all__ = [
|
||||
"UserRepository",
|
||||
@@ -29,4 +30,5 @@ __all__ = [
|
||||
"CommentRepository",
|
||||
"ReplyRepository",
|
||||
"RequirementStatusRepository",
|
||||
"AcceptanceCriteriaRepository",
|
||||
]
|
||||
|
||||
117
backend/src/repositories/acceptance_criteria_repository.py
Normal file
117
backend/src/repositories/acceptance_criteria_repository.py
Normal 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
|
||||
]
|
||||
@@ -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