Added functions to fetch requirements from db.

Added functionality to create requirements on page
This commit is contained in:
gulimabr
2025-11-30 15:35:36 -03:00
parent bbbe65067b
commit eb70598cab
11 changed files with 1383 additions and 148 deletions

View File

@@ -1,15 +1,19 @@
from contextlib import asynccontextmanager
from typing import List
from fastapi import FastAPI, Depends, Request
from typing import List, Optional
from fastapi import FastAPI, Depends, Request, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import RedirectResponse
from sqlalchemy.ext.asyncio import AsyncSession
from src.models import TokenResponse, UserInfo, GroupResponse
from src.models import (
TokenResponse, UserInfo, GroupResponse,
TagResponse, RequirementResponse, PriorityResponse,
RequirementCreateRequest, RequirementUpdateRequest
)
from src.controller import AuthController
from src.config import get_openid, get_settings
from src.database import init_db, close_db, get_db
from src.repositories import RoleRepository, GroupRepository
from src.repositories import RoleRepository, GroupRepository, TagRepository, RequirementRepository, PriorityRepository
import logging
# Configure logging
@@ -176,3 +180,254 @@ async def get_groups(db: AsyncSession = Depends(get_db)):
group_repo = GroupRepository(db)
groups = await group_repo.get_all()
return [GroupResponse.model_validate(g) for g in groups]
# ===========================================
# Tags Endpoints
# ===========================================
@app.get("/api/tags", response_model=List[TagResponse])
async def get_tags(db: AsyncSession = Depends(get_db)):
"""
Get all tags.
Returns:
List of all tags with their codes and descriptions.
"""
tag_repo = TagRepository(db)
tags = await tag_repo.get_all()
return [TagResponse.model_validate(t) for t in tags]
@app.get("/api/tags/{tag_id}", response_model=TagResponse)
async def get_tag(tag_id: int, db: AsyncSession = Depends(get_db)):
"""
Get a specific tag by ID.
Args:
tag_id: The tag ID
Returns:
The tag if found.
"""
tag_repo = TagRepository(db)
tag = await tag_repo.get_by_id(tag_id)
if not tag:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tag with id {tag_id} not found"
)
return TagResponse.model_validate(tag)
# ===========================================
# Priorities Endpoints
# ===========================================
@app.get("/api/priorities", response_model=List[PriorityResponse])
async def get_priorities(db: AsyncSession = Depends(get_db)):
"""
Get all priorities.
Returns:
List of all priorities ordered by priority_num.
"""
priority_repo = PriorityRepository(db)
priorities = await priority_repo.get_all()
return [PriorityResponse.model_validate(p) for p in priorities]
# ===========================================
# Requirements Endpoints
# ===========================================
def _build_requirement_response(req) -> RequirementResponse:
"""Helper function to build RequirementResponse from a Requirement model."""
# Determine validation status from latest validation
validation_status = "Not Validated"
if req.validations:
# Get the latest validation
latest_validation = max(req.validations, key=lambda v: v.created_at or req.created_at)
validation_status = latest_validation.status.status_name if latest_validation.status else "Not Validated"
return RequirementResponse(
id=req.id,
req_name=req.req_name,
req_desc=req.req_desc,
version=req.version,
created_at=req.created_at,
updated_at=req.updated_at,
tag=TagResponse.model_validate(req.tag),
priority=req.priority if req.priority else None,
groups=[GroupResponse.model_validate(g) for g in req.groups],
validation_status=validation_status,
)
@app.get("/api/requirements", response_model=List[RequirementResponse])
async def get_requirements(
group_id: Optional[int] = None,
tag_id: Optional[int] = None,
db: AsyncSession = Depends(get_db)
):
"""
Get all requirements, optionally filtered by group or tag.
Args:
group_id: Optional group ID to filter by
tag_id: Optional tag ID to filter by
Returns:
List of all requirements with their related data.
"""
req_repo = RequirementRepository(db)
if group_id:
requirements = await req_repo.get_by_group_id(group_id)
elif tag_id:
requirements = await req_repo.get_by_tag_id(tag_id)
else:
requirements = await req_repo.get_all()
return [_build_requirement_response(req) for req in requirements]
@app.get("/api/requirements/{requirement_id}", response_model=RequirementResponse)
async def get_requirement(requirement_id: int, db: AsyncSession = Depends(get_db)):
"""
Get a specific requirement by ID.
Args:
requirement_id: The requirement ID
Returns:
The requirement if found.
"""
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"
)
return _build_requirement_response(requirement)
@app.post("/api/requirements", response_model=RequirementResponse, status_code=status.HTTP_201_CREATED)
async def create_requirement(
request: Request,
req_data: RequirementCreateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Create a new requirement.
Args:
req_data: The requirement data
Returns:
The created requirement.
"""
# Get the current user from cookie
user_info = AuthController.get_current_user(request)
# Get the user's database ID
from src.repositories import UserRepository
user_repo = UserRepository(db)
user = await user_repo.get_by_sub(user_info.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in database"
)
req_repo = RequirementRepository(db)
requirement = await req_repo.create(
user_id=user.id,
tag_id=req_data.tag_id,
req_name=req_data.req_name,
req_desc=req_data.req_desc,
priority_id=req_data.priority_id,
group_ids=req_data.group_ids,
)
await db.commit()
return _build_requirement_response(requirement)
@app.put("/api/requirements/{requirement_id}", response_model=RequirementResponse)
async def update_requirement(
requirement_id: int,
request: Request,
req_data: RequirementUpdateRequest,
db: AsyncSession = Depends(get_db)
):
"""
Update an existing requirement.
Args:
requirement_id: The requirement ID to update
req_data: The updated requirement data
Returns:
The updated requirement.
"""
# Get the current user from cookie
user_info = AuthController.get_current_user(request)
# Get the user's database ID
from src.repositories import UserRepository
user_repo = UserRepository(db)
user = await user_repo.get_by_sub(user_info.sub)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found in database"
)
req_repo = RequirementRepository(db)
requirement = await req_repo.update(
requirement_id=requirement_id,
editor_id=user.id,
req_name=req_data.req_name,
req_desc=req_data.req_desc,
tag_id=req_data.tag_id,
priority_id=req_data.priority_id,
group_ids=req_data.group_ids,
)
if not requirement:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Requirement with id {requirement_id} not found"
)
await db.commit()
return _build_requirement_response(requirement)
@app.delete("/api/requirements/{requirement_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_requirement(
requirement_id: int,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
Delete a requirement.
Args:
requirement_id: The requirement ID to delete
"""
# Verify user is authenticated
AuthController.get_current_user(request)
req_repo = RequirementRepository(db)
deleted = await req_repo.delete(requirement_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Requirement with id {requirement_id} not found"
)
await db.commit()

View File

@@ -1,4 +1,5 @@
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel, SecretStr
@@ -35,3 +36,84 @@ class GroupResponse(BaseModel):
class GroupListResponse(BaseModel):
"""Response schema for list of groups."""
groups: List[GroupResponse]
# Tag schemas
class TagResponse(BaseModel):
"""Response schema for a single tag."""
id: int
tag_code: str
tag_description: str
class Config:
from_attributes = True
class TagListResponse(BaseModel):
"""Response schema for list of tags."""
tags: List[TagResponse]
# Priority schemas
class PriorityResponse(BaseModel):
"""Response schema for a priority."""
id: int
priority_name: str
priority_num: int
class Config:
from_attributes = True
# Validation schemas
class ValidationResponse(BaseModel):
"""Response schema for a validation."""
id: int
status_name: str
req_version_snapshot: int
comment: Optional[str] = None
created_at: Optional[datetime] = None
class Config:
from_attributes = True
# Requirement schemas
class RequirementResponse(BaseModel):
"""Response schema for a single requirement."""
id: int
req_name: str
req_desc: Optional[str] = None
version: int
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
tag: TagResponse
priority: Optional[PriorityResponse] = None
groups: List[GroupResponse] = []
validation_status: Optional[str] = None # Computed from latest validation
class Config:
from_attributes = True
class RequirementListResponse(BaseModel):
"""Response schema for list of requirements."""
requirements: List[RequirementResponse]
class RequirementCreateRequest(BaseModel):
"""Request schema for creating a requirement."""
tag_id: int
req_name: str
req_desc: Optional[str] = None
priority_id: Optional[int] = None
group_ids: Optional[List[int]] = None
class RequirementUpdateRequest(BaseModel):
"""Request schema for updating a requirement."""
req_name: Optional[str] = None
req_desc: Optional[str] = None
tag_id: Optional[int] = None
priority_id: Optional[int] = None
group_ids: Optional[List[int]] = None

View File

@@ -3,9 +3,15 @@ Repository layer for database operations.
"""
from src.repositories.user_repository import UserRepository, RoleRepository
from src.repositories.group_repository import GroupRepository
from src.repositories.tag_repository import TagRepository
from src.repositories.requirement_repository import RequirementRepository
from src.repositories.priority_repository import PriorityRepository
__all__ = [
"UserRepository",
"RoleRepository",
"GroupRepository",
"TagRepository",
"RequirementRepository",
"PriorityRepository",
]

View File

@@ -0,0 +1,76 @@
"""
Repository layer for Priority database operations.
"""
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import Priority
import logging
logger = logging.getLogger(__name__)
class PriorityRepository:
"""Repository for Priority-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_all(self) -> List[Priority]:
"""
Get all priorities ordered by priority_num.
Returns:
List of all priorities
"""
result = await self.session.execute(
select(Priority).order_by(Priority.priority_num)
)
return list(result.scalars().all())
async def get_by_id(self, priority_id: int) -> Optional[Priority]:
"""
Get a priority by ID.
Args:
priority_id: The priority ID
Returns:
Priority if found, None otherwise
"""
result = await self.session.execute(
select(Priority).where(Priority.id == priority_id)
)
return result.scalar_one_or_none()
async def get_by_name(self, priority_name: str) -> Optional[Priority]:
"""
Get a priority by name.
Args:
priority_name: The priority name
Returns:
Priority if found, None otherwise
"""
result = await self.session.execute(
select(Priority).where(Priority.priority_name == priority_name)
)
return result.scalar_one_or_none()
async def create(self, priority_name: str, priority_num: int) -> Priority:
"""
Create a new priority.
Args:
priority_name: The priority name
priority_num: The priority number for ordering
Returns:
The created Priority
"""
priority = Priority(priority_name=priority_name, priority_num=priority_num)
self.session.add(priority)
await self.session.flush()
await self.session.refresh(priority)
return priority

View File

@@ -0,0 +1,227 @@
"""
Repository layer for Requirement database operations.
"""
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import Requirement, Group, Tag, Priority, Validation
import logging
logger = logging.getLogger(__name__)
class RequirementRepository:
"""Repository for Requirement-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_all(self) -> List[Requirement]:
"""
Get all requirements with their related data (tag, priority, groups, validations).
Returns:
List of all requirements with eager-loaded relationships
"""
result = await self.session.execute(
select(Requirement)
.options(
selectinload(Requirement.tag),
selectinload(Requirement.priority),
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
)
.order_by(Requirement.created_at.desc())
)
return list(result.scalars().all())
async def get_by_id(self, requirement_id: int) -> Optional[Requirement]:
"""
Get a requirement by ID with its related data.
Args:
requirement_id: The requirement ID
Returns:
Requirement if found, None otherwise
"""
result = await self.session.execute(
select(Requirement)
.options(
selectinload(Requirement.tag),
selectinload(Requirement.priority),
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
selectinload(Requirement.user),
selectinload(Requirement.last_editor),
)
.where(Requirement.id == requirement_id)
)
return result.scalar_one_or_none()
async def get_by_group_id(self, group_id: int) -> List[Requirement]:
"""
Get all requirements belonging to a specific group.
Args:
group_id: The group ID
Returns:
List of requirements in the group
"""
result = await self.session.execute(
select(Requirement)
.options(
selectinload(Requirement.tag),
selectinload(Requirement.priority),
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
)
.join(Requirement.groups)
.where(Group.id == group_id)
.order_by(Requirement.created_at.desc())
)
return list(result.scalars().all())
async def get_by_tag_id(self, tag_id: int) -> List[Requirement]:
"""
Get all requirements with a specific tag.
Args:
tag_id: The tag ID
Returns:
List of requirements with the tag
"""
result = await self.session.execute(
select(Requirement)
.options(
selectinload(Requirement.tag),
selectinload(Requirement.priority),
selectinload(Requirement.groups),
selectinload(Requirement.validations).selectinload(Validation.status),
)
.where(Requirement.tag_id == tag_id)
.order_by(Requirement.created_at.desc())
)
return list(result.scalars().all())
async def create(
self,
user_id: int,
tag_id: int,
req_name: str,
req_desc: Optional[str] = None,
priority_id: Optional[int] = None,
group_ids: Optional[List[int]] = None,
) -> Requirement:
"""
Create a new requirement.
Args:
user_id: The creating user's ID
tag_id: The tag ID
req_name: The requirement name
req_desc: The requirement description (optional)
priority_id: The priority ID (optional)
group_ids: List of group IDs to associate (optional)
Returns:
The created Requirement
"""
requirement = Requirement(
user_id=user_id,
tag_id=tag_id,
req_name=req_name,
req_desc=req_desc,
priority_id=priority_id,
)
# Add groups if provided
if group_ids:
groups_result = await self.session.execute(
select(Group).where(Group.id.in_(group_ids))
)
groups = list(groups_result.scalars().all())
requirement.groups = groups
self.session.add(requirement)
await self.session.flush()
await self.session.refresh(requirement)
# Reload with relationships
return await self.get_by_id(requirement.id)
async def update(
self,
requirement_id: int,
editor_id: int,
req_name: Optional[str] = None,
req_desc: Optional[str] = None,
tag_id: Optional[int] = None,
priority_id: Optional[int] = None,
group_ids: Optional[List[int]] = None,
) -> Optional[Requirement]:
"""
Update an existing requirement.
Args:
requirement_id: The requirement ID to update
editor_id: The ID of the user making the edit
req_name: New requirement name (optional)
req_desc: New requirement description (optional)
tag_id: New tag ID (optional)
priority_id: New priority ID (optional)
group_ids: New list of group IDs (optional)
Returns:
The updated Requirement, or None if not found
"""
requirement = await self.get_by_id(requirement_id)
if not requirement:
return None
# Update fields if provided
if req_name is not None:
requirement.req_name = req_name
if req_desc is not None:
requirement.req_desc = req_desc
if tag_id is not None:
requirement.tag_id = tag_id
if priority_id is not None:
requirement.priority_id = priority_id
# Set the last editor
requirement.last_editor_id = editor_id
# Update groups if provided
if group_ids is not None:
groups_result = await self.session.execute(
select(Group).where(Group.id.in_(group_ids))
)
groups = list(groups_result.scalars().all())
requirement.groups = groups
await self.session.flush()
await self.session.refresh(requirement)
return await self.get_by_id(requirement_id)
async def delete(self, requirement_id: int) -> bool:
"""
Delete a requirement.
Args:
requirement_id: The requirement ID to delete
Returns:
True if deleted, False if not found
"""
requirement = await self.get_by_id(requirement_id)
if not requirement:
return False
await self.session.delete(requirement)
await self.session.flush()
return True

View File

@@ -0,0 +1,76 @@
"""
Repository layer for Tag database operations.
"""
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db_models import Tag
import logging
logger = logging.getLogger(__name__)
class TagRepository:
"""Repository for Tag-related database operations."""
def __init__(self, session: AsyncSession):
self.session = session
async def get_all(self) -> List[Tag]:
"""
Get all tags.
Returns:
List of all tags
"""
result = await self.session.execute(
select(Tag).order_by(Tag.tag_code)
)
return list(result.scalars().all())
async def get_by_id(self, tag_id: int) -> Optional[Tag]:
"""
Get a tag by ID.
Args:
tag_id: The tag ID
Returns:
Tag if found, None otherwise
"""
result = await self.session.execute(
select(Tag).where(Tag.id == tag_id)
)
return result.scalar_one_or_none()
async def get_by_code(self, tag_code: str) -> Optional[Tag]:
"""
Get a tag by code.
Args:
tag_code: The tag code (e.g., GSR, SFR)
Returns:
Tag if found, None otherwise
"""
result = await self.session.execute(
select(Tag).where(Tag.tag_code == tag_code)
)
return result.scalar_one_or_none()
async def create(self, tag_code: str, tag_description: str) -> Tag:
"""
Create a new tag.
Args:
tag_code: The tag code (e.g., GSR, SFR)
tag_description: The tag description
Returns:
The created Tag
"""
tag = Tag(tag_code=tag_code, tag_description=tag_description)
self.session.add(tag)
await self.session.flush()
await self.session.refresh(tag)
return tag

View File

@@ -1,140 +1,108 @@
import { useState, useEffect } from 'react'
import React, { useState, useEffect } from 'react'
import { useAuth } from '@/hooks'
import { useSearchParams, Link, useNavigate } from 'react-router-dom'
import { groupService, tagService, requirementService, priorityService } from '@/services'
import type { Group } from '@/services/groupService'
import type { Tag } from '@/services/tagService'
import type { Priority } from '@/services/priorityService'
import type { Requirement, RequirementCreateRequest } from '@/services/requirementService'
// Types for requirements
interface Requirement {
id: string
tag: string
title: string
validation: string
priority: number
progress: number
group: RequirementGroup
// Helper to lighten a hex color for backgrounds
function lightenColor(hex: string, percent: number): string {
const num = parseInt(hex.replace('#', ''), 16)
const amt = Math.round(2.55 * percent)
const R = (num >> 16) + amt
const G = (num >> 8 & 0x00FF) + amt
const B = (num & 0x0000FF) + amt
return '#' + (
0x1000000 +
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
(G < 255 ? (G < 1 ? 0 : G) : 255) * 0x100 +
(B < 255 ? (B < 1 ? 0 : B) : 255)
).toString(16).slice(1)
}
type RequirementGroup =
| 'Data Services'
| 'Integration'
| 'Intelligence'
| 'User Experience'
| 'Management'
| 'Trustworthiness'
// Color mapping for requirement groups
const groupColors: Record<RequirementGroup, { bg: string; border: string }> = {
'Data Services': { bg: 'bg-blue-200', border: 'border-blue-300' },
'Integration': { bg: 'bg-amber-200', border: 'border-amber-300' },
'Intelligence': { bg: 'bg-purple-200', border: 'border-purple-300' },
'User Experience': { bg: 'bg-green-200', border: 'border-green-300' },
'Management': { bg: 'bg-red-200', border: 'border-red-300' },
'Trustworthiness': { bg: 'bg-teal-600', border: 'border-teal-700' },
}
// Static mock data
const mockRequirements: Requirement[] = [
{
id: '1',
tag: 'GSR#7',
title: 'Controle e monitoramento em right-time',
validation: 'Not Validated',
priority: 0,
progress: 0,
group: 'Trustworthiness',
},
{
id: '2',
tag: 'GSR#10',
title: 'Interface de software DT-externo bem definida.',
validation: 'Not Validated',
priority: 1,
progress: 0,
group: 'Intelligence',
},
{
id: '3',
tag: 'GSR#12',
title: 'Visualizacao',
validation: 'Not Validated',
priority: 1,
progress: 0,
group: 'User Experience',
},
{
id: '4',
tag: 'GSR#1',
title: 'Estado corrente atualizado',
validation: 'Not Validated',
priority: 1,
progress: 0,
group: 'Data Services',
},
{
id: '5',
tag: 'GSR#5',
title: 'Sincronização de dados em tempo real',
validation: 'Not Validated',
priority: 2,
progress: 25,
group: 'Data Services',
},
{
id: '6',
tag: 'GSR#8',
title: 'Integração com sistemas legados',
validation: 'Not Validated',
priority: 1,
progress: 50,
group: 'Integration',
},
{
id: '7',
tag: 'GSR#15',
title: 'Dashboard de gestão',
validation: 'Not Validated',
priority: 3,
progress: 0,
group: 'Management',
},
]
// Caption types
const captionItems = [
{ abbr: 'SFR', description: 'Specific Functional Requirement', underline: true },
{ abbr: 'SNFR', description: 'Specific Non-Functional Requirement', underline: true },
{ abbr: 'GNFR', description: 'Generic Non-Functional Requirement', underline: true },
{ abbr: 'GSR', description: 'Generic Service Requirement', underline: true },
{ abbr: 'GDR', description: 'Generic Data Requirement', underline: true },
{ abbr: 'GCR', description: 'Generic Connection Requirement', underline: true },
]
export default function RequirementsPage() {
const { user, logout } = useAuth()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
// State
// Data state
const [groups, setGroups] = useState<Group[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [priorities, setPriorities] = useState<Priority[]>([])
const [requirements, setRequirements] = useState<Requirement[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filter state
const [searchQuery, setSearchQuery] = useState('')
const [selectedGroups, setSelectedGroups] = useState<RequirementGroup[]>([])
const [selectedGroups, setSelectedGroups] = useState<number[]>([])
const [orderBy, setOrderBy] = useState<'Date' | 'Priority' | 'Name'>('Date')
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list')
// Modal state
const [showCreateModal, setShowCreateModal] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
// Form state for new requirement
const [newReqName, setNewReqName] = useState('')
const [newReqDesc, setNewReqDesc] = useState('')
const [newReqTagId, setNewReqTagId] = useState<number | ''>('')
const [newReqPriorityId, setNewReqPriorityId] = useState<number | ''>('')
const [newReqGroupIds, setNewReqGroupIds] = useState<number[]>([])
// Fetch data on mount
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true)
setError(null)
// Fetch groups, tags, priorities, and requirements in parallel
const [groupsData, tagsData, prioritiesData, requirementsData] = await Promise.all([
groupService.getGroups(),
tagService.getTags(),
priorityService.getPriorities(),
requirementService.getRequirements(),
])
setGroups(groupsData)
setTags(tagsData)
setPriorities(prioritiesData)
setRequirements(requirementsData)
} catch (err) {
console.error('Failed to fetch data:', err)
setError('Failed to load data. Please try again.')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
// Initialize filters from URL params
useEffect(() => {
const groupParam = searchParams.get('group')
if (groupParam) {
setSelectedGroups([groupParam as RequirementGroup])
if (groupParam && groups.length > 0) {
const group = groups.find(g => g.group_name === groupParam)
if (group) {
setSelectedGroups([group.id])
}
}
}, [searchParams])
}, [searchParams, groups])
// Filter requirements based on search and selected groups
const filteredRequirements = mockRequirements.filter(req => {
const filteredRequirements = requirements.filter(req => {
const tagLabel = `${req.tag.tag_code}#${req.id}`
const matchesSearch = searchQuery === '' ||
req.tag.toLowerCase().includes(searchQuery.toLowerCase()) ||
req.title.toLowerCase().includes(searchQuery.toLowerCase())
tagLabel.toLowerCase().includes(searchQuery.toLowerCase()) ||
req.req_name.toLowerCase().includes(searchQuery.toLowerCase())
const matchesGroup = selectedGroups.length === 0 ||
selectedGroups.includes(req.group)
req.groups.some(g => selectedGroups.includes(g.id))
return matchesSearch && matchesGroup
})
@@ -143,19 +111,24 @@ export default function RequirementsPage() {
const sortedRequirements = [...filteredRequirements].sort((a, b) => {
switch (orderBy) {
case 'Priority':
return b.priority - a.priority
const priorityA = a.priority?.priority_num ?? 0
const priorityB = b.priority?.priority_num ?? 0
return priorityB - priorityA
case 'Name':
return a.title.localeCompare(b.title)
return a.req_name.localeCompare(b.req_name)
case 'Date':
default:
return 0
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0
return dateB - dateA
}
})
const handleGroupToggle = (group: RequirementGroup) => {
const handleGroupToggle = (groupId: number) => {
setSelectedGroups(prev =>
prev.includes(group)
? prev.filter(g => g !== group)
: [...prev, group]
prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId]
)
}
@@ -166,18 +139,128 @@ export default function RequirementsPage() {
}
const handleSearch = () => {
// For now, filtering is automatic - this could trigger an API call later
// Filtering is automatic, but this could trigger a fresh API call
}
const handleRemove = (id: string) => {
// For now, just a placeholder - will connect to backend later
console.log('Remove requirement:', id)
const handleRemove = async (id: number) => {
if (!confirm('Are you sure you want to delete this requirement?')) {
return
}
try {
await requirementService.deleteRequirement(id)
// Remove from local state
setRequirements(prev => prev.filter(r => r.id !== id))
} catch (err) {
console.error('Failed to delete requirement:', err)
alert('Failed to delete requirement. Please try again.')
}
}
const handleDetails = (id: string) => {
const handleDetails = (id: number) => {
navigate(`/requirements/${id}`)
}
// Get the primary group color for a requirement (first group or default)
const getRequirementColor = (req: Requirement): string => {
if (req.groups.length > 0) {
return req.groups[0].hex_color
}
return '#6B7280' // default gray
}
// Modal functions
const openCreateModal = () => {
setShowCreateModal(true)
setCreateError(null)
// Reset form
setNewReqName('')
setNewReqDesc('')
setNewReqTagId('')
setNewReqPriorityId('')
setNewReqGroupIds([])
}
const closeCreateModal = () => {
setShowCreateModal(false)
setCreateError(null)
}
const handleCreateGroupToggle = (groupId: number) => {
setNewReqGroupIds(prev =>
prev.includes(groupId)
? prev.filter(id => id !== groupId)
: [...prev, groupId]
)
}
const handleCreateRequirement = async (e: React.FormEvent) => {
e.preventDefault()
// Validation
if (!newReqName.trim()) {
setCreateError('Requirement name is required')
return
}
if (!newReqTagId) {
setCreateError('Please select a tag')
return
}
try {
setCreateLoading(true)
setCreateError(null)
const data: RequirementCreateRequest = {
tag_id: newReqTagId as number,
req_name: newReqName.trim(),
req_desc: newReqDesc.trim() || undefined,
priority_id: newReqPriorityId ? (newReqPriorityId as number) : undefined,
group_ids: newReqGroupIds.length > 0 ? newReqGroupIds : undefined,
}
const newRequirement = await requirementService.createRequirement(data)
// Add to local state
setRequirements(prev => [newRequirement, ...prev])
// Close modal
closeCreateModal()
} catch (err) {
console.error('Failed to create requirement:', err)
setCreateError('Failed to create requirement. Please try again.')
} finally {
setCreateLoading(false)
}
}
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-teal-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading requirements...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
>
Retry
</button>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-white">
{/* Header */}
@@ -236,7 +319,10 @@ export default function RequirementsPage() {
<div className="flex-1">
{/* New Requirement Button */}
<div className="mb-6">
<button className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50">
<button
onClick={openCreateModal}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
>
New Requirement
</button>
</div>
@@ -268,15 +354,15 @@ export default function RequirementsPage() {
<div className="mb-6">
<p className="text-sm text-gray-600 mb-3">Filter Group</p>
<div className="grid grid-cols-3 gap-x-8 gap-y-2">
{(['Data Services', 'Intelligence', 'Management', 'Integration', 'User Experience', 'Trustworthiness'] as RequirementGroup[]).map((group) => (
<label key={group} className="flex items-center gap-2 cursor-pointer">
{groups.map((group) => (
<label key={group.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedGroups.includes(group)}
onChange={() => handleGroupToggle(group)}
checked={selectedGroups.includes(group.id)}
onChange={() => handleGroupToggle(group.id)}
className="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm text-blue-600 underline">{group}</span>
<span className="text-sm text-blue-600 underline">{group.group_name}</span>
</label>
))}
</div>
@@ -327,30 +413,39 @@ export default function RequirementsPage() {
{/* Requirements List */}
<div className="space-y-4">
{sortedRequirements.map((req) => {
const colors = groupColors[req.group]
const primaryColor = getRequirementColor(req)
const bgColor = lightenColor(primaryColor, 60)
const tagLabel = `${req.tag.tag_code}#${req.id}`
const priorityNum = req.priority?.priority_num ?? 0
const validationStatus = req.validation_status || 'Not Validated'
return (
<div
key={req.id}
className={`flex items-center border ${colors.border} rounded overflow-hidden`}
className="flex items-center rounded overflow-hidden"
style={{ borderColor: primaryColor, borderWidth: '1px', borderStyle: 'solid' }}
>
{/* Colored tag section */}
<div className={`${colors.bg} px-4 py-4 min-w-[320px]`}>
<div
className="px-4 py-4 min-w-[320px]"
style={{ backgroundColor: bgColor }}
>
<span className="font-bold text-gray-800">
{req.tag} - {req.title}
{tagLabel} - {req.req_name}
</span>
</div>
{/* Validation status */}
<div className="flex-1 px-6 py-4 text-center">
<span className="text-sm text-gray-600">
Validation: {req.validation}
Validation: {validationStatus}
</span>
</div>
{/* Priority and Progress */}
{/* Priority and Version */}
<div className="px-6 py-4 text-right">
<p className="text-sm text-gray-700">Priority: {req.priority}</p>
<p className="text-sm text-gray-600">{req.progress.toFixed(2)}% Done</p>
<p className="text-sm text-gray-700">Priority: {priorityNum}</p>
<p className="text-sm text-gray-600">Version: {req.version}</p>
</div>
{/* Action buttons */}
@@ -385,10 +480,10 @@ export default function RequirementsPage() {
<div className="border-l border-gray-200 pl-6">
<h3 className="font-semibold text-gray-800 mb-4">Caption:</h3>
<div className="space-y-2">
{captionItems.map((item) => (
<p key={item.abbr} className="text-sm">
<span className="text-blue-600 underline">{item.abbr}</span>
<span className="text-gray-600">: {item.description}</span>
{tags.map((tag) => (
<p key={tag.id} className="text-sm">
<span className="text-blue-600 underline">{tag.tag_code}</span>
<span className="text-gray-600">: {tag.tag_description}</span>
</p>
))}
</div>
@@ -397,6 +492,145 @@ export default function RequirementsPage() {
</div>
</div>
</div>
{/* Create Requirement Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-800">New Requirement</h2>
<button
onClick={closeCreateModal}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Modal Body */}
<form onSubmit={handleCreateRequirement}>
<div className="px-6 py-4 space-y-4">
{/* Error message */}
{createError && (
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{createError}
</div>
)}
{/* Tag Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tag <span className="text-red-500">*</span>
</label>
<select
value={newReqTagId}
onChange={(e) => setNewReqTagId(e.target.value ? Number(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 focus:border-transparent"
required
>
<option value="">Select a tag...</option>
{tags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.tag_code} - {tag.tag_description}
</option>
))}
</select>
</div>
{/* Requirement Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={newReqName}
onChange={(e) => setNewReqName(e.target.value)}
placeholder="Enter requirement name"
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Description
</label>
<textarea
value={newReqDesc}
onChange={(e) => setNewReqDesc(e.target.value)}
placeholder="Enter requirement description (optional)"
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent resize-none"
/>
</div>
{/* Priority Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Priority
</label>
<select
value={newReqPriorityId}
onChange={(e) => setNewReqPriorityId(e.target.value ? Number(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 focus:border-transparent"
>
<option value="">Select a priority (optional)...</option>
{priorities.map((priority) => (
<option key={priority.id} value={priority.id}>
{priority.priority_name} ({priority.priority_num})
</option>
))}
</select>
</div>
{/* Groups Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Groups
</label>
<div className="grid grid-cols-2 gap-2 max-h-40 overflow-y-auto border border-gray-200 rounded p-3">
{groups.map((group) => (
<label key={group.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={newReqGroupIds.includes(group.id)}
onChange={() => handleCreateGroupToggle(group.id)}
className="w-4 h-4 rounded border-gray-300 text-teal-600 focus:ring-teal-500"
/>
<span className="text-sm text-gray-700">{group.group_name}</span>
</label>
))}
</div>
</div>
</div>
{/* Modal Footer */}
<div className="flex justify-end gap-3 px-6 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg">
<button
type="button"
onClick={closeCreateModal}
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-100"
disabled={createLoading}
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={createLoading}
>
{createLoading ? 'Creating...' : 'Create Requirement'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}

View File

@@ -1,3 +1,9 @@
export { authService } from './authService'
export { groupService } from './groupService'
export type { Group } from './groupService'
export { tagService } from './tagService'
export type { Tag } from './tagService'
export { priorityService } from './priorityService'
export type { Priority } from './priorityService'
export { requirementService } from './requirementService'
export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService'

View File

@@ -0,0 +1,36 @@
const API_BASE_URL = '/api'
export interface Priority {
id: number
priority_name: string
priority_num: number
}
class PriorityService {
/**
* Get all priorities from the API.
*/
async getPriorities(): Promise<Priority[]> {
try {
const response = await fetch(`${API_BASE_URL}/priorities`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const priorities: Priority[] = await response.json()
return priorities
} catch (error) {
console.error('Failed to fetch priorities:', error)
throw error
}
}
}
export const priorityService = new PriorityService()

View File

@@ -0,0 +1,176 @@
import { Group } from './groupService'
import { Tag } from './tagService'
import { Priority } from './priorityService'
const API_BASE_URL = '/api'
export interface Requirement {
id: number
req_name: string
req_desc: string | null
version: number
created_at: string | null
updated_at: string | null
tag: Tag
priority: Priority | null
groups: Group[]
validation_status: string | null
}
export interface RequirementCreateRequest {
tag_id: number
req_name: string
req_desc?: string
priority_id?: number
group_ids?: number[]
}
export interface RequirementUpdateRequest {
req_name?: string
req_desc?: string
tag_id?: number
priority_id?: number
group_ids?: number[]
}
class RequirementService {
/**
* Get all requirements from the API.
* Optionally filter by group_id or tag_id.
*/
async getRequirements(params?: { group_id?: number; tag_id?: number }): Promise<Requirement[]> {
try {
let url = `${API_BASE_URL}/requirements`
if (params) {
const queryParams = new URLSearchParams()
if (params.group_id) queryParams.append('group_id', params.group_id.toString())
if (params.tag_id) queryParams.append('tag_id', params.tag_id.toString())
const queryString = queryParams.toString()
if (queryString) {
url += `?${queryString}`
}
}
const response = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const requirements: Requirement[] = await response.json()
return requirements
} catch (error) {
console.error('Failed to fetch requirements:', error)
throw error
}
}
/**
* Get a specific requirement by ID.
*/
async getRequirement(requirementId: number): Promise<Requirement> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const requirement: Requirement = await response.json()
return requirement
} catch (error) {
console.error('Failed to fetch requirement:', error)
throw error
}
}
/**
* Create a new requirement.
*/
async createRequirement(data: RequirementCreateRequest): Promise<Requirement> {
try {
const response = await fetch(`${API_BASE_URL}/requirements`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const requirement: Requirement = await response.json()
return requirement
} catch (error) {
console.error('Failed to create requirement:', error)
throw error
}
}
/**
* Update an existing requirement.
*/
async updateRequirement(requirementId: number, data: RequirementUpdateRequest): Promise<Requirement> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}`, {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const requirement: Requirement = await response.json()
return requirement
} catch (error) {
console.error('Failed to update requirement:', error)
throw error
}
}
/**
* Delete a requirement.
*/
async deleteRequirement(requirementId: number): Promise<void> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}`, {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
} catch (error) {
console.error('Failed to delete requirement:', error)
throw error
}
}
}
export const requirementService = new RequirementService()

View File

@@ -0,0 +1,61 @@
const API_BASE_URL = '/api'
export interface Tag {
id: number
tag_code: string
tag_description: string
}
class TagService {
/**
* Get all tags from the API.
*/
async getTags(): Promise<Tag[]> {
try {
const response = await fetch(`${API_BASE_URL}/tags`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const tags: Tag[] = await response.json()
return tags
} catch (error) {
console.error('Failed to fetch tags:', error)
throw error
}
}
/**
* Get a specific tag by ID.
*/
async getTag(tagId: number): Promise<Tag> {
try {
const response = await fetch(`${API_BASE_URL}/tags/${tagId}`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const tag: Tag = await response.json()
return tag
} catch (error) {
console.error('Failed to fetch tag:', error)
throw error
}
}
}
export const tagService = new TagService()