diff --git a/backend/src/db_models.py b/backend/src/db_models.py index fa84ca2..19147a0 100644 --- a/backend/src/db_models.py +++ b/backend/src/db_models.py @@ -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. diff --git a/backend/src/main.py b/backend/src/main.py index 0286177..083e903 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -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 # =========================================== diff --git a/backend/src/models.py b/backend/src/models.py index 8dee159..961d837 100644 --- a/backend/src/models.py +++ b/backend/src/models.py @@ -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).""" diff --git a/backend/src/repositories/__init__.py b/backend/src/repositories/__init__.py index b388257..bde445e 100644 --- a/backend/src/repositories/__init__.py +++ b/backend/src/repositories/__init__.py @@ -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", ] diff --git a/backend/src/repositories/acceptance_criteria_repository.py b/backend/src/repositories/acceptance_criteria_repository.py new file mode 100644 index 0000000..9bb4ca4 --- /dev/null +++ b/backend/src/repositories/acceptance_criteria_repository.py @@ -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 + ] diff --git a/frontend/src/i18n/locales/en/requirementDetail.json b/frontend/src/i18n/locales/en/requirementDetail.json index e82d5a9..a2086d3 100644 --- a/frontend/src/i18n/locales/en/requirementDetail.json +++ b/frontend/src/i18n/locales/en/requirementDetail.json @@ -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." } } diff --git a/frontend/src/i18n/locales/pt/requirementDetail.json b/frontend/src/i18n/locales/pt/requirementDetail.json index ff7eb8a..26e5a20 100644 --- a/frontend/src/i18n/locales/pt/requirementDetail.json +++ b/frontend/src/i18n/locales/pt/requirementDetail.json @@ -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." } } diff --git a/frontend/src/pages/RequirementDetailPage.tsx b/frontend/src/pages/RequirementDetailPage.tsx index b14c081..1a1e25e 100644 --- a/frontend/src/pages/RequirementDetailPage.tsx +++ b/frontend/src/pages/RequirementDetailPage.tsx @@ -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(null) const [deletingReplyId, setDeletingReplyId] = useState(null) + // Acceptance criteria state + const [acceptanceCriteria, setAcceptanceCriteria] = useState([]) + const [criteriaLoading, setCriteriaLoading] = useState(false) + const [criteriaError, setCriteriaError] = useState(null) + const [newCriteriaText, setNewCriteriaText] = useState('') + const [creatingCriteria, setCreatingCriteria] = useState(false) + const [editingCriteriaId, setEditingCriteriaId] = useState(null) + const [editCriteriaText, setEditCriteriaText] = useState('') + const [updatingCriteriaId, setUpdatingCriteriaId] = useState(null) + const [togglingCriteriaIds, setTogglingCriteriaIds] = useState([]) + const [deletingCriteriaId, setDeletingCriteriaId] = useState(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() + 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() + 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 (

{t('acceptanceCriteria.title')}

-

{t('acceptanceCriteria.noCriteria')}

- {!isAuditor && ( -
- + {criteriaLoading ? ( +
+
+

{t('acceptanceCriteria.loading')}

+
+ ) : criteriaError ? ( +

{criteriaError}

+ ) : acceptanceCriteria.length === 0 ? ( +

{t('acceptanceCriteria.noCriteria')}

+ ) : ( +
+ {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 ( +
+ handleToggleCriteria(criteria)} + disabled={!canManageCriteria || isToggling} + className="mt-1 h-4 w-4 text-teal-600 border-gray-300 rounded" + /> +
+ {isEditing ? ( + 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" + /> + ) : ( +

+ {criteria.criteria_text} +

+ )} +
+ + {canManageCriteria && ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ )} +
+ ) + })} +
+ )} + + {canManageCriteria && ( +
+
+ 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" + /> + +
)}
@@ -1648,6 +1963,70 @@ export default function RequirementDetailPage() {
) } + + 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 ( +
+
+
+ + + {isAdded + ? t('history.criteriaAdded') + : isDeleted + ? t('history.criteriaDeleted') + : t('history.criteriaUpdated')} + + + {isAdded ? ( + <> + {criteria.newText || t('history.criteriaEmpty')} + ({acceptanceLabel(criteria.newAccepted)}) + + ) : isDeleted ? ( + <> + {criteria.oldText || t('history.criteriaEmpty')} + ({acceptanceLabel(criteria.oldAccepted)}) + + ) : ( + <> + {criteria.oldText || t('history.criteriaEmpty')} + + {criteria.newText || t('history.criteriaEmpty')} + + ({acceptanceLabel(criteria.oldAccepted)} → {acceptanceLabel(criteria.newAccepted)}) + + + )} + +
+
+ {criteria.editedByUsername && ( + by {criteria.editedByUsername} + )} + + {event.date ? new Date(event.date).toLocaleString() : ''} + +
+
+
+ ) + } return null })} diff --git a/frontend/src/services/acceptanceCriteriaService.ts b/frontend/src/services/acceptanceCriteriaService.ts new file mode 100644 index 0000000..0ed0ab6 --- /dev/null +++ b/frontend/src/services/acceptanceCriteriaService.ts @@ -0,0 +1,101 @@ +import type { + AcceptanceCriteria, + AcceptanceCriteriaCreateRequest, + AcceptanceCriteriaUpdateRequest, + AcceptanceCriteriaHistory +} from '@/types' + +const API_BASE_URL = '/api' + +class AcceptanceCriteriaService { + async getCriteria(requirementId: number): Promise { + 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 { + 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 { + 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 { + 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 { + 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() diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 3ed71ea..0a9a77c 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -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, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9875bdd..dc01a10 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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