686 lines
27 KiB
TypeScript
686 lines
27 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { useAuth, useProject } 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'
|
|
|
|
// Get validation status color
|
|
const getValidationStatusStyle = (status: string): { bgColor: string; textColor: string } => {
|
|
switch (status) {
|
|
case 'Approved':
|
|
return { bgColor: 'bg-green-100', textColor: 'text-green-800' }
|
|
case 'Denied':
|
|
return { bgColor: 'bg-red-100', textColor: 'text-red-800' }
|
|
case 'Partial':
|
|
return { bgColor: 'bg-yellow-100', textColor: 'text-yellow-800' }
|
|
case 'Not Validated':
|
|
default:
|
|
return { bgColor: 'bg-gray-100', textColor: 'text-gray-600' }
|
|
}
|
|
}
|
|
|
|
export default function RequirementsPage() {
|
|
const { user, logout, isAuditor } = useAuth()
|
|
const { currentProject, isLoading: projectLoading } = useProject()
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const navigate = useNavigate()
|
|
|
|
// 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<number[]>([])
|
|
const [orderBy, setOrderBy] = useState<'Date' | 'Priority' | 'Name'>('Date')
|
|
|
|
// 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 when project changes
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
// Don't fetch if no project is selected
|
|
if (!currentProject) {
|
|
setRequirements([])
|
|
setLoading(false)
|
|
return
|
|
}
|
|
|
|
try {
|
|
setLoading(true)
|
|
setError(null)
|
|
|
|
// Fetch groups, tags, and priorities in parallel
|
|
const [groupsData, tagsData, prioritiesData] = await Promise.all([
|
|
groupService.getGroups(),
|
|
tagService.getTags(),
|
|
priorityService.getPriorities(),
|
|
])
|
|
|
|
setGroups(groupsData)
|
|
setTags(tagsData)
|
|
setPriorities(prioritiesData)
|
|
|
|
// Fetch requirements for the current project
|
|
const requirementsData = await requirementService.getRequirements({
|
|
project_id: currentProject.id,
|
|
})
|
|
setRequirements(requirementsData)
|
|
} catch (err) {
|
|
console.error('Failed to fetch data:', err)
|
|
setError('Failed to load data. Please try again.')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
if (!projectLoading) {
|
|
fetchData()
|
|
}
|
|
}, [currentProject, projectLoading])
|
|
|
|
// Initialize filters from URL params
|
|
useEffect(() => {
|
|
const groupParam = searchParams.get('group')
|
|
if (groupParam && groups.length > 0) {
|
|
const group = groups.find(g => g.group_name === groupParam)
|
|
if (group) {
|
|
setSelectedGroups([group.id])
|
|
}
|
|
}
|
|
}, [searchParams, groups])
|
|
|
|
// Filter requirements based on search and selected groups
|
|
const filteredRequirements = requirements.filter(req => {
|
|
const matchesSearch = searchQuery === '' ||
|
|
req.tag.tag_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
req.req_name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
|
|
const matchesGroup = selectedGroups.length === 0 ||
|
|
req.groups.some(g => selectedGroups.includes(g.id))
|
|
|
|
return matchesSearch && matchesGroup
|
|
})
|
|
|
|
// Sort requirements
|
|
const sortedRequirements = [...filteredRequirements].sort((a, b) => {
|
|
switch (orderBy) {
|
|
case 'Priority':
|
|
const priorityA = a.priority?.priority_num ?? 0
|
|
const priorityB = b.priority?.priority_num ?? 0
|
|
return priorityB - priorityA
|
|
case 'Name':
|
|
return a.req_name.localeCompare(b.req_name)
|
|
case 'Date':
|
|
default:
|
|
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 = (groupId: number) => {
|
|
setSelectedGroups(prev =>
|
|
prev.includes(groupId)
|
|
? prev.filter(id => id !== groupId)
|
|
: [...prev, groupId]
|
|
)
|
|
}
|
|
|
|
const handleClear = () => {
|
|
setSearchQuery('')
|
|
setSelectedGroups([])
|
|
setSearchParams({})
|
|
}
|
|
|
|
const handleSearch = () => {
|
|
// Filtering is automatic, but this could trigger a fresh API call
|
|
}
|
|
|
|
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: number) => {
|
|
navigate(`/requirements/${id}`)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if (!currentProject) {
|
|
setCreateError('No project selected')
|
|
return
|
|
}
|
|
|
|
try {
|
|
setCreateLoading(true)
|
|
setCreateError(null)
|
|
|
|
const data: RequirementCreateRequest = {
|
|
project_id: currentProject.id,
|
|
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 || projectLoading) {
|
|
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 (!currentProject) {
|
|
return (
|
|
<div className="min-h-screen bg-white flex items-center justify-center">
|
|
<div className="text-center">
|
|
<svg className="w-16 h-16 text-gray-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
|
</svg>
|
|
<h2 className="text-xl font-semibold text-gray-700 mb-2">No Project Selected</h2>
|
|
<p className="text-gray-500 mb-4">Please select a project from the dashboard to view requirements.</p>
|
|
<button
|
|
onClick={() => navigate('/dashboard')}
|
|
className="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700"
|
|
>
|
|
Go to Dashboard
|
|
</button>
|
|
</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 */}
|
|
<header className="py-6 text-center">
|
|
<h1 className="text-3xl font-semibold text-teal-700">
|
|
Digital Twin Requirements Tool
|
|
</h1>
|
|
</header>
|
|
|
|
{/* Top Bar */}
|
|
<div className="border-y border-gray-200 py-3 px-8">
|
|
<div className="flex items-center justify-between max-w-7xl mx-auto">
|
|
{/* Breadcrumb */}
|
|
<div className="text-sm">
|
|
<Link to="/dashboard" className="text-gray-600 hover:underline">Projects</Link>
|
|
<span className="mx-2 text-gray-400">»</span>
|
|
<Link to="/dashboard" className="text-gray-600 hover:underline">{currentProject.project_name}</Link>
|
|
<span className="mx-2 text-gray-400">»</span>
|
|
<span className="font-semibold text-gray-900">Search Requirements</span>
|
|
</div>
|
|
|
|
{/* Language Toggle */}
|
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
|
<span>English</span>
|
|
<div className="relative inline-flex h-5 w-10 items-center rounded-full bg-gray-300">
|
|
<span className="inline-block h-4 w-4 transform rounded-full bg-white shadow-sm translate-x-0.5" />
|
|
</div>
|
|
<span>Portuguese</span>
|
|
</div>
|
|
|
|
{/* User Info */}
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
|
|
</svg>
|
|
<span className="text-sm text-gray-700">
|
|
{user?.full_name || user?.preferred_username || 'User'}{' '}
|
|
<span className="text-gray-500">({user?.role || 'user'})</span>
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={logout}
|
|
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Logout
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="max-w-7xl mx-auto px-8 py-8">
|
|
<div className="flex gap-8">
|
|
{/* Main Panel */}
|
|
<div className="flex-1">
|
|
{/* New Requirement Button - Hidden for auditors */}
|
|
{!isAuditor && (
|
|
<div className="mb-6">
|
|
<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>
|
|
)}
|
|
|
|
{/* Search Bar */}
|
|
<div className="flex gap-2 mb-6">
|
|
<input
|
|
type="text"
|
|
placeholder="Search for a requirement tag or title"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="flex-1 px-4 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-transparent"
|
|
/>
|
|
<button
|
|
onClick={handleSearch}
|
|
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Search
|
|
</button>
|
|
<button
|
|
onClick={handleClear}
|
|
className="px-4 py-2 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Group */}
|
|
<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">
|
|
{groups.map((group) => (
|
|
<label key={group.id} className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
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.group_name}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Order By */}
|
|
<div className="flex items-center mb-6">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-gray-600">Order by:</span>
|
|
<select
|
|
value={orderBy}
|
|
onChange={(e) => setOrderBy(e.target.value as 'Date' | 'Priority' | 'Name')}
|
|
className="px-3 py-1 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500"
|
|
>
|
|
<option value="Date">Date</option>
|
|
<option value="Priority">Priority</option>
|
|
<option value="Name">Name</option>
|
|
</select>
|
|
<button className="px-4 py-1 border border-gray-400 rounded text-sm font-medium text-gray-700 hover:bg-gray-50">
|
|
Filter
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Requirements List */}
|
|
<div className="space-y-4">
|
|
{sortedRequirements.map((req) => {
|
|
const tagLabel = req.tag.tag_code
|
|
const priorityName = req.priority?.priority_name ?? 'None'
|
|
const validationStatus = req.validation_status || 'Not Validated'
|
|
const validationStyle = getValidationStatusStyle(validationStatus)
|
|
const isStale = req.validation_version !== null && req.validation_version !== req.version
|
|
|
|
return (
|
|
<div
|
|
key={req.id}
|
|
className="flex items-center rounded overflow-hidden border border-gray-300 bg-white"
|
|
>
|
|
{/* Tag and name section */}
|
|
<div className="px-4 py-4 min-w-[280px]">
|
|
<span className="font-bold text-gray-800">
|
|
{tagLabel} - {req.req_name}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Group chips */}
|
|
<div className="flex-1 px-4 py-4">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
{req.groups.length > 0 ? (
|
|
<>
|
|
{req.groups.slice(0, 2).map(group => (
|
|
<span
|
|
key={group.id}
|
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
|
style={{ backgroundColor: `${group.hex_color}25`, color: `${group.hex_color}` }}
|
|
>
|
|
{group.group_name}
|
|
</span>
|
|
))}
|
|
{req.groups.length > 2 && (
|
|
<span
|
|
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600"
|
|
title={req.groups.slice(2).map(g => g.group_name).join(', ')}
|
|
>
|
|
+{req.groups.length - 2} more
|
|
</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-500">
|
|
No groups
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Validation status */}
|
|
<div className="px-4 py-4 text-center">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${validationStyle.bgColor} ${validationStyle.textColor}`}>
|
|
{validationStatus}
|
|
</span>
|
|
{isStale && (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800" title="Requirement was modified after validation">
|
|
⚠ Stale
|
|
</span>
|
|
)}
|
|
</div>
|
|
{req.validated_by && (
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
by @{req.validated_by}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Priority and Version */}
|
|
<div className="px-4 py-4 text-right">
|
|
<p className="text-sm text-gray-700">Priority: {priorityName}</p>
|
|
<p className="text-sm text-gray-600">Version: {req.version}</p>
|
|
</div>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex gap-2 px-4 py-4">
|
|
<button
|
|
onClick={() => handleDetails(req.id)}
|
|
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Details
|
|
</button>
|
|
{!isAuditor && (
|
|
<button
|
|
onClick={() => handleRemove(req.id)}
|
|
className="px-3 py-1 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50"
|
|
>
|
|
Remove
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{sortedRequirements.length === 0 && (
|
|
<div className="text-center py-12 text-gray-500">
|
|
No requirements found matching your criteria.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Sidebar - Caption */}
|
|
<div className="w-64 flex-shrink-0">
|
|
<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">
|
|
{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>
|
|
<hr className="my-4 border-gray-300" />
|
|
</div>
|
|
</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>
|
|
)
|
|
}
|