Added relationship between requirements
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth, useProject } from '@/hooks'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { requirementService, validationService } from '@/services'
|
||||
import { requirementService, validationService, relationshipService } from '@/services'
|
||||
import type { Requirement } from '@/services/requirementService'
|
||||
import type { RelationshipType, RequirementLink, RequirementSearchResult } from '@/services/relationshipService'
|
||||
import type { ValidationStatus, ValidationHistory } from '@/types'
|
||||
|
||||
// Tab types
|
||||
type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
||||
type TabType = 'description' | 'relationships' | 'acceptance-criteria' | 'shared-comments' | 'validate'
|
||||
|
||||
export default function RequirementDetailPage() {
|
||||
const { user, logout, isAuditor } = useAuth()
|
||||
@@ -26,6 +27,20 @@ export default function RequirementDetailPage() {
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [historyLoading, setHistoryLoading] = useState(false)
|
||||
|
||||
// Relationships state
|
||||
const [relationshipLinks, setRelationshipLinks] = useState<RequirementLink[]>([])
|
||||
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([])
|
||||
const [relationshipsLoading, setRelationshipsLoading] = useState(false)
|
||||
const [showAddRelationshipModal, setShowAddRelationshipModal] = useState(false)
|
||||
const [selectedRelationshipType, setSelectedRelationshipType] = useState<number | ''>('')
|
||||
const [targetSearchQuery, setTargetSearchQuery] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<RequirementSearchResult[]>([])
|
||||
const [selectedTarget, setSelectedTarget] = useState<RequirementSearchResult | null>(null)
|
||||
const [searchLoading, setSearchLoading] = useState(false)
|
||||
const [addLinkLoading, setAddLinkLoading] = useState(false)
|
||||
const [addLinkError, setAddLinkError] = useState<string | null>(null)
|
||||
const [deletingLinkId, setDeletingLinkId] = useState<number | null>(null)
|
||||
|
||||
// Fetch requirement data on mount
|
||||
useEffect(() => {
|
||||
const fetchRequirement = async () => {
|
||||
@@ -74,6 +89,56 @@ export default function RequirementDetailPage() {
|
||||
fetchValidationData()
|
||||
}, [activeTab, id])
|
||||
|
||||
// Fetch relationships data when relationships tab is active
|
||||
useEffect(() => {
|
||||
const fetchRelationshipsData = async () => {
|
||||
if (activeTab !== 'relationships' || !id || !currentProject) return
|
||||
|
||||
try {
|
||||
setRelationshipsLoading(true)
|
||||
const [links, types] = await Promise.all([
|
||||
relationshipService.getRequirementLinks(parseInt(id, 10)),
|
||||
relationshipService.getRelationshipTypes(currentProject.id)
|
||||
])
|
||||
setRelationshipLinks(links)
|
||||
setRelationshipTypes(types)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch relationships data:', err)
|
||||
} finally {
|
||||
setRelationshipsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchRelationshipsData()
|
||||
}, [activeTab, id, currentProject])
|
||||
|
||||
// Debounced search for target requirements
|
||||
useEffect(() => {
|
||||
if (!showAddRelationshipModal || !currentProject || !id) return
|
||||
if (targetSearchQuery.length < 1) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
setSearchLoading(true)
|
||||
const results = await relationshipService.searchRequirements(
|
||||
currentProject.id,
|
||||
targetSearchQuery,
|
||||
parseInt(id, 10) // Exclude current requirement
|
||||
)
|
||||
setSearchResults(results)
|
||||
} catch (err) {
|
||||
console.error('Failed to search requirements:', err)
|
||||
} finally {
|
||||
setSearchLoading(false)
|
||||
}
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [targetSearchQuery, showAddRelationshipModal, currentProject, id])
|
||||
|
||||
// Handle validation submission
|
||||
const handleSubmitValidation = async () => {
|
||||
if (!selectedStatusId || !id) {
|
||||
@@ -108,6 +173,80 @@ export default function RequirementDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle opening add relationship modal
|
||||
const openAddRelationshipModal = () => {
|
||||
setShowAddRelationshipModal(true)
|
||||
setSelectedRelationshipType('')
|
||||
setTargetSearchQuery('')
|
||||
setSearchResults([])
|
||||
setSelectedTarget(null)
|
||||
setAddLinkError(null)
|
||||
}
|
||||
|
||||
// Handle closing add relationship modal
|
||||
const closeAddRelationshipModal = () => {
|
||||
setShowAddRelationshipModal(false)
|
||||
setAddLinkError(null)
|
||||
}
|
||||
|
||||
// Handle selecting a target requirement from search
|
||||
const handleSelectTarget = (result: RequirementSearchResult) => {
|
||||
setSelectedTarget(result)
|
||||
setTargetSearchQuery(`${result.tag_code} - ${result.req_name}`)
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
// Handle creating a new relationship link
|
||||
const handleCreateLink = async () => {
|
||||
if (!selectedRelationshipType || !selectedTarget || !id) {
|
||||
setAddLinkError('Please select a relationship type and target requirement')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setAddLinkLoading(true)
|
||||
setAddLinkError(null)
|
||||
|
||||
const newLink = await relationshipService.createLink(parseInt(id, 10), {
|
||||
relationship_type_id: selectedRelationshipType as number,
|
||||
target_requirement_id: selectedTarget.id
|
||||
})
|
||||
|
||||
// Add to links list
|
||||
setRelationshipLinks(prev => [newLink, ...prev])
|
||||
|
||||
// Close modal
|
||||
closeAddRelationshipModal()
|
||||
} catch (err) {
|
||||
console.error('Failed to create link:', err)
|
||||
setAddLinkError(err instanceof Error ? err.message : 'Failed to create link. Please try again.')
|
||||
} finally {
|
||||
setAddLinkLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleting a relationship link
|
||||
const handleDeleteLink = async (linkId: number) => {
|
||||
if (!confirm('Are you sure you want to delete this relationship?')) return
|
||||
|
||||
try {
|
||||
setDeletingLinkId(linkId)
|
||||
await relationshipService.deleteLink(linkId)
|
||||
setRelationshipLinks(prev => prev.filter(link => link.id !== linkId))
|
||||
} catch (err) {
|
||||
console.error('Failed to delete link:', err)
|
||||
alert(err instanceof Error ? err.message : 'Failed to delete link')
|
||||
} finally {
|
||||
setDeletingLinkId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user can delete a link (creator or admin)
|
||||
const canDeleteLink = (link: RequirementLink): boolean => {
|
||||
if (!user) return false
|
||||
return user.role_id === 1 || link.created_by_id === user.db_user_id
|
||||
}
|
||||
|
||||
// Get validation status style
|
||||
const getValidationStatusStyle = (status: string): string => {
|
||||
switch (status) {
|
||||
@@ -151,8 +290,7 @@ export default function RequirementDetailPage() {
|
||||
|
||||
const tabs: { id: TabType; label: string }[] = [
|
||||
{ id: 'description', label: 'Description' },
|
||||
{ id: 'sub-requirements', label: 'Sub-Requirements' },
|
||||
{ id: 'co-requirements', label: 'Co-Requirements' },
|
||||
{ id: 'relationships', label: 'Relationships' },
|
||||
{ id: 'acceptance-criteria', label: 'Acceptance Criteria' },
|
||||
{ id: 'shared-comments', label: 'Shared Comments' },
|
||||
{ id: 'validate', label: 'Validate' },
|
||||
@@ -205,31 +343,100 @@ export default function RequirementDetailPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'sub-requirements':
|
||||
case 'relationships':
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Sub-Requirements</h3>
|
||||
<p className="text-gray-500">No sub-requirements defined yet.</p>
|
||||
{!isAuditor && (
|
||||
<div className="mt-4">
|
||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||
Add Sub-Requirement
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-gray-800">Relationships</h3>
|
||||
{!isAuditor && (
|
||||
<button
|
||||
onClick={openAddRelationshipModal}
|
||||
disabled={relationshipTypes.length === 0}
|
||||
className="px-4 py-1.5 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title={relationshipTypes.length === 0 ? 'No relationship types defined for this project. Contact an admin to set them up.' : ''}
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{relationshipTypes.length === 0 && !relationshipsLoading && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
|
||||
No relationship types have been defined for this project. Contact an administrator to set up relationship types.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'co-requirements':
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 mb-4">Co-Requirements</h3>
|
||||
<p className="text-gray-500">No co-requirements defined yet.</p>
|
||||
{!isAuditor && (
|
||||
<div className="mt-4">
|
||||
<button className="px-4 py-1.5 border border-gray-400 rounded text-sm text-gray-700 hover:bg-gray-50">
|
||||
Add Co-Requirement
|
||||
</button>
|
||||
{relationshipsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-600 mx-auto"></div>
|
||||
<p className="mt-2 text-gray-500 text-sm">Loading relationships...</p>
|
||||
</div>
|
||||
) : relationshipLinks.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No relationships defined yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border border-gray-200 rounded">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Direction</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Linked Requirement</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created By</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{relationshipLinks.map((link) => (
|
||||
<tr key={link.id}>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
link.direction === 'outgoing'
|
||||
? 'bg-blue-100 text-blue-800'
|
||||
: 'bg-purple-100 text-purple-800'
|
||||
}`}>
|
||||
{link.direction === 'outgoing' ? '→ Outgoing' : '← Incoming'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
{link.type_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<Link
|
||||
to={`/requirements/${link.linked_requirement.id}`}
|
||||
className="text-teal-600 hover:underline"
|
||||
>
|
||||
{link.linked_requirement.tag_code} - {link.linked_requirement.req_name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
{link.created_by_username ? `@${link.created_by_username}` : <span className="text-gray-400 italic">Unknown</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700">
|
||||
{link.created_at
|
||||
? new Date(link.created_at).toLocaleDateString()
|
||||
: 'N/A'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
{canDeleteLink(link) && (
|
||||
<button
|
||||
onClick={() => handleDeleteLink(link.id)}
|
||||
disabled={deletingLinkId === link.id}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50"
|
||||
title="Delete relationship"
|
||||
>
|
||||
{deletingLinkId === link.id ? (
|
||||
<span className="animate-spin inline-block">⏳</span>
|
||||
) : (
|
||||
'🗑️'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -552,6 +759,121 @@ export default function RequirementDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Relationship Modal */}
|
||||
{showAddRelationshipModal && (
|
||||
<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">Add Relationship</h2>
|
||||
<button
|
||||
onClick={closeAddRelationshipModal}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{addLinkError && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||
{addLinkError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Relationship Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Relationship Type <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedRelationshipType}
|
||||
onChange={(e) => setSelectedRelationshipType(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"
|
||||
disabled={addLinkLoading}
|
||||
>
|
||||
<option value="">Select a relationship type...</option>
|
||||
{relationshipTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.type_name}
|
||||
{type.inverse_type_name && ` (inverse: ${type.inverse_type_name})`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Target Requirement Search */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Target Requirement <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={targetSearchQuery}
|
||||
onChange={(e) => {
|
||||
setTargetSearchQuery(e.target.value)
|
||||
setSelectedTarget(null)
|
||||
}}
|
||||
placeholder="Search by tag code or 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"
|
||||
disabled={addLinkLoading}
|
||||
/>
|
||||
{searchLoading && (
|
||||
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-teal-600"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results Dropdown */}
|
||||
{searchResults.length > 0 && !selectedTarget && (
|
||||
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-48 overflow-y-auto">
|
||||
{searchResults.map((result) => (
|
||||
<button
|
||||
key={result.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectTarget(result)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
>
|
||||
<span className="font-medium text-teal-600">{result.tag_code}</span>
|
||||
<span className="text-gray-600"> - {result.req_name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{selectedTarget && (
|
||||
<p className="mt-1 text-sm text-green-600">
|
||||
✓ Selected: {selectedTarget.tag_code} - {selectedTarget.req_name}
|
||||
</p>
|
||||
)}
|
||||
</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={closeAddRelationshipModal}
|
||||
className="px-4 py-2 border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-100"
|
||||
disabled={addLinkLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateLink}
|
||||
disabled={addLinkLoading || !selectedRelationshipType || !selectedTarget}
|
||||
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"
|
||||
>
|
||||
{addLinkLoading ? 'Creating...' : 'Create Relationship'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,3 +10,10 @@ export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest }
|
||||
export { projectService } from './projectService'
|
||||
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'
|
||||
export { validationService } from './validationService'
|
||||
export { relationshipService } from './relationshipService'
|
||||
export type {
|
||||
RelationshipType,
|
||||
RequirementLink,
|
||||
RequirementSearchResult,
|
||||
RequirementLinkCreateRequest
|
||||
} from './relationshipService'
|
||||
|
||||
154
frontend/src/services/relationshipService.ts
Normal file
154
frontend/src/services/relationshipService.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// Types
|
||||
export interface RelationshipType {
|
||||
id: number
|
||||
project_id: number
|
||||
type_name: string
|
||||
type_description: string | null
|
||||
inverse_type_name: string | null
|
||||
}
|
||||
|
||||
export interface LinkedRequirementInfo {
|
||||
id: number
|
||||
req_name: string
|
||||
tag_code: string
|
||||
}
|
||||
|
||||
export interface RequirementLink {
|
||||
id: number
|
||||
direction: 'outgoing' | 'incoming'
|
||||
type_name: string
|
||||
type_id: number
|
||||
inverse_type_name: string | null
|
||||
linked_requirement: LinkedRequirementInfo
|
||||
created_by_username: string | null
|
||||
created_by_id: number | null
|
||||
created_at: string | null
|
||||
}
|
||||
|
||||
export interface RequirementSearchResult {
|
||||
id: number
|
||||
req_name: string
|
||||
tag_code: string
|
||||
}
|
||||
|
||||
export interface RequirementLinkCreateRequest {
|
||||
relationship_type_id: number
|
||||
target_requirement_id: number
|
||||
}
|
||||
|
||||
class RelationshipService {
|
||||
/**
|
||||
* Get all relationship types for a project.
|
||||
*/
|
||||
async getRelationshipTypes(projectId: number): Promise<RelationshipType[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/relationship-types`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search requirements by name or tag code (for autocomplete).
|
||||
*/
|
||||
async searchRequirements(
|
||||
projectId: number,
|
||||
query: string,
|
||||
excludeId?: number
|
||||
): Promise<RequirementSearchResult[]> {
|
||||
const params = new URLSearchParams({ q: query })
|
||||
if (excludeId !== undefined) {
|
||||
params.append('exclude_id', excludeId.toString())
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/requirements/search?${params}`,
|
||||
{
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all links for a requirement (both outgoing and incoming).
|
||||
*/
|
||||
async getRequirementLinks(requirementId: number): Promise<RequirementLink[]> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new link from a requirement to another.
|
||||
*/
|
||||
async createLink(
|
||||
requirementId: number,
|
||||
data: RequirementLinkCreateRequest
|
||||
): Promise<RequirementLink> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/links`, {
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a requirement link.
|
||||
*/
|
||||
async deleteLink(linkId: number): Promise<void> {
|
||||
const response = await fetch(`${API_BASE_URL}/requirement-links/${linkId}`, {
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const relationshipService = new RelationshipService()
|
||||
@@ -4,6 +4,7 @@ export interface User {
|
||||
full_name: string | null
|
||||
role: string | null
|
||||
role_id: number | null
|
||||
db_user_id: number | null
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
|
||||
Reference in New Issue
Block a user