Added auditor logic

This commit is contained in:
gulimabr
2025-12-01 11:36:43 -03:00
parent 07005788ed
commit f7bb62ea99
15 changed files with 888 additions and 72 deletions

View File

@@ -3,6 +3,7 @@ import {
useState,
useEffect,
useCallback,
useMemo,
type ReactNode,
} from 'react'
import type { User, AuthContextType } from '@/types'
@@ -51,10 +52,14 @@ export function AuthProvider({ children }: AuthProviderProps) {
refreshUser()
}, [refreshUser])
// Determine if user is an auditor (role_id = 2)
const isAuditor = useMemo(() => user?.role_id === 2, [user?.role_id])
const value: AuthContextType = {
user,
isLoading,
isAuthenticated: !!user,
isAuditor,
login,
logout,
refreshUser,

View File

@@ -246,7 +246,7 @@ export default function DashboardPage() {
</svg>
<span className="text-sm text-gray-700">
{user?.full_name || user?.preferred_username || 'User'}{' '}
<span className="text-gray-500">(admin)</span>
<span className="text-gray-500">({user?.role || 'user'})</span>
</span>
</div>
<button

View File

@@ -1,20 +1,30 @@
import { useState, useEffect } from 'react'
import { useAuth, useProject } from '@/hooks'
import { useParams, Link } from 'react-router-dom'
import { requirementService } from '@/services'
import { requirementService, validationService } from '@/services'
import type { Requirement } from '@/services/requirementService'
import type { ValidationStatus, ValidationHistory } from '@/types'
// Tab types
type TabType = 'description' | 'sub-requirements' | 'co-requirements' | 'acceptance-criteria' | 'shared-comments' | 'validate'
export default function RequirementDetailPage() {
const { user, logout } = useAuth()
const { user, logout, isAuditor } = useAuth()
const { currentProject } = useProject()
const { id } = useParams<{ id: string }>()
const [activeTab, setActiveTab] = useState<TabType>('description')
const [requirement, setRequirement] = useState<Requirement | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Validation state
const [validationStatuses, setValidationStatuses] = useState<ValidationStatus[]>([])
const [validationHistory, setValidationHistory] = useState<ValidationHistory[]>([])
const [selectedStatusId, setSelectedStatusId] = useState<number | ''>('')
const [validationComment, setValidationComment] = useState('')
const [validationLoading, setValidationLoading] = useState(false)
const [validationError, setValidationError] = useState<string | null>(null)
const [historyLoading, setHistoryLoading] = useState(false)
// Fetch requirement data on mount
useEffect(() => {
@@ -41,6 +51,78 @@ export default function RequirementDetailPage() {
fetchRequirement()
}, [id])
// Fetch validation statuses and history when validate tab is active
useEffect(() => {
const fetchValidationData = async () => {
if (activeTab !== 'validate' || !id) return
try {
setHistoryLoading(true)
const [statuses, history] = await Promise.all([
validationService.getStatuses(),
validationService.getValidationHistory(parseInt(id, 10))
])
setValidationStatuses(statuses.filter(s => s.id !== 4)) // Exclude "Not Validated" as option
setValidationHistory(history)
} catch (err) {
console.error('Failed to fetch validation data:', err)
} finally {
setHistoryLoading(false)
}
}
fetchValidationData()
}, [activeTab, id])
// Handle validation submission
const handleSubmitValidation = async () => {
if (!selectedStatusId || !id) {
setValidationError('Please select a validation status')
return
}
try {
setValidationLoading(true)
setValidationError(null)
const newValidation = await validationService.createValidation(parseInt(id, 10), {
status_id: selectedStatusId as number,
comment: validationComment.trim() || undefined
})
// Add to history and update requirement
setValidationHistory(prev => [newValidation, ...prev])
// Refresh requirement to get updated validation status
const updatedRequirement = await requirementService.getRequirement(parseInt(id, 10))
setRequirement(updatedRequirement)
// Reset form
setSelectedStatusId('')
setValidationComment('')
} catch (err) {
console.error('Failed to submit validation:', err)
setValidationError('Failed to submit validation. Please try again.')
} finally {
setValidationLoading(false)
}
}
// Get validation status style
const getValidationStatusStyle = (status: string): string => {
switch (status) {
case 'Approved':
return 'bg-green-100 text-green-800'
case 'Denied':
return 'bg-red-100 text-red-800'
case 'Partial':
return 'bg-yellow-100 text-yellow-800'
case 'Not Validated':
default:
return 'bg-gray-100 text-gray-600'
}
}
if (loading) {
return (
<div className="min-h-screen bg-white flex items-center justify-center">
@@ -91,7 +173,13 @@ export default function RequirementDetailPage() {
<span className="font-semibold">Version:</span> {requirement.version}
</p>
<p className="text-sm text-gray-700 mb-2">
<span className="font-semibold">Validation Status:</span> {validationStatus}
<span className="font-semibold">Validation Status:</span>{' '}
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getValidationStatusStyle(validationStatus)}`}>
{validationStatus}
</span>
{requirement.validated_by && (
<span className="text-gray-500 ml-2">by @{requirement.validated_by}</span>
)}
</p>
{requirement.created_at && (
<p className="text-sm text-gray-700 mb-2">
@@ -106,11 +194,14 @@ export default function RequirementDetailPage() {
<div className="bg-teal-50 border border-gray-300 rounded min-h-[300px] p-4">
<p className="text-gray-700">{requirement.req_desc || 'No description provided.'}</p>
</div>
<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">
Edit
</button>
</div>
{/* Hide Edit button for auditors */}
{!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">
Edit
</button>
</div>
)}
</div>
)
@@ -119,11 +210,13 @@ export default function RequirementDetailPage() {
<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>
<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
</button>
</div>
{!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
</button>
</div>
)}
</div>
)
@@ -132,11 +225,13 @@ export default function RequirementDetailPage() {
<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>
<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>
</div>
{!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>
</div>
)}
</div>
)
@@ -145,11 +240,13 @@ export default function RequirementDetailPage() {
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Acceptance Criteria</h3>
<p className="text-gray-500">No acceptance criteria defined yet.</p>
<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 Criterion
</button>
</div>
{!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 Criterion
</button>
</div>
)}
</div>
)
@@ -175,27 +272,159 @@ export default function RequirementDetailPage() {
return (
<div>
<h3 className="text-xl font-bold text-gray-800 mb-4">Validate Requirement</h3>
<div className="p-4 border border-gray-300 rounded bg-gray-50">
<p className="text-gray-700 mb-4">
Review all acceptance criteria and validate this requirement when ready.
</p>
<div className="space-y-2 mb-4">
<label className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
<span className="text-sm">All acceptance criteria have been met</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
<span className="text-sm">Documentation is complete</span>
</label>
<label className="flex items-center gap-2">
<input type="checkbox" className="w-4 h-4 rounded border-gray-300 text-teal-600" />
<span className="text-sm">Stakeholders have approved</span>
</label>
{/* Current Status */}
<div className="mb-6 p-4 bg-gray-50 border border-gray-200 rounded">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Current Status:</p>
<span className={`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${getValidationStatusStyle(validationStatus)}`}>
{validationStatus}
</span>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">Requirement Version:</p>
<span className="text-lg font-semibold text-gray-800">{requirement.version}</span>
</div>
</div>
<button className="px-4 py-2 bg-teal-600 text-white rounded text-sm font-medium hover:bg-teal-700">
Validate Requirement
</button>
{requirement.validated_by && (
<p className="text-sm text-gray-500 mt-2">
Last validated by @{requirement.validated_by}
{requirement.validated_at && ` on ${new Date(requirement.validated_at).toLocaleDateString()}`}
</p>
)}
{requirement.validation_version !== null && requirement.validation_version !== requirement.version && (
<div className="mt-3 p-2 bg-orange-100 border border-orange-300 rounded">
<p className="text-sm text-orange-800 flex items-center gap-2">
<span></span>
<span>
This requirement was modified after the last validation (validated at version {requirement.validation_version}, current version {requirement.version}).
</span>
</p>
</div>
)}
</div>
{/* Validation Form - Only for auditors and admins */}
{(isAuditor || user?.role_id === 1) && (
<div className="mb-6 p-4 border border-gray-300 rounded">
<h4 className="font-semibold text-gray-800 mb-4">Submit Validation</h4>
{validationError && (
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
{validationError}
</div>
)}
<div className="space-y-4">
{/* Status Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Validation Status <span className="text-red-500">*</span>
</label>
<select
value={selectedStatusId}
onChange={(e) => setSelectedStatusId(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={validationLoading}
>
<option value="">Select a status...</option>
{validationStatuses.map((status) => (
<option key={status.id} value={status.id}>
{status.status_name}
</option>
))}
</select>
</div>
{/* Comment */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Comment
</label>
<textarea
value={validationComment}
onChange={(e) => setValidationComment(e.target.value)}
placeholder="Add a comment explaining your decision (optional but recommended)"
rows={4}
className="w-full p-3 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-teal-500 resize-none"
disabled={validationLoading}
/>
</div>
{/* Submit Button */}
<button
onClick={handleSubmitValidation}
disabled={validationLoading || !selectedStatusId}
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"
>
{validationLoading ? 'Submitting...' : 'Submit Validation'}
</button>
</div>
</div>
)}
{/* Validation History */}
<div>
<h4 className="font-semibold text-gray-800 mb-4">Validation History</h4>
{historyLoading ? (
<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 history...</p>
</div>
) : validationHistory.length === 0 ? (
<p className="text-gray-500 text-center py-8">No validation history 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">Date</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Version</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Validator</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Comment</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{validationHistory.map((validation) => {
const isStale = validation.req_version_snapshot !== requirement.version
return (
<tr key={validation.id} className={isStale ? 'bg-orange-50' : ''}>
<td className="px-4 py-3 text-sm text-gray-700">
{validation.created_at
? new Date(validation.created_at).toLocaleString()
: 'N/A'}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getValidationStatusStyle(validation.status_name)}`}>
{validation.status_name}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700">
<span className="flex items-center gap-1">
v{validation.req_version_snapshot}
{isStale && (
<span className="text-orange-600" title="Requirement was modified after this validation">
</span>
)}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-700">
@{validation.validator_username}
</td>
<td className="px-4 py-3 text-sm text-gray-700">
{validation.comment || <span className="text-gray-400 italic">No comment</span>}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
@@ -244,8 +473,8 @@ export default function RequirementDetailPage() {
<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 || 'Ricardo Belo'}{' '}
<span className="text-gray-500">(admin)</span>
{user?.full_name || user?.preferred_username || 'User'}{' '}
<span className="text-gray-500">({user?.role || 'user'})</span>
</span>
</div>
<button

View File

@@ -7,6 +7,21 @@ 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' }
}
}
// Helper to lighten a hex color for backgrounds
function lightenColor(hex: string, percent: number): string {
const num = parseInt(hex.replace('#', ''), 16)
@@ -23,7 +38,7 @@ function lightenColor(hex: string, percent: number): string {
}
export default function RequirementsPage() {
const { user, logout } = useAuth()
const { user, logout, isAuditor } = useAuth()
const { currentProject, isLoading: projectLoading } = useProject()
const [searchParams, setSearchParams] = useSearchParams()
const navigate = useNavigate()
@@ -335,8 +350,8 @@ export default function RequirementsPage() {
<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 || 'Ricardo Belo'}{' '}
<span className="text-gray-500">(admin)</span>
{user?.full_name || user?.preferred_username || 'User'}{' '}
<span className="text-gray-500">({user?.role || 'user'})</span>
</span>
</div>
<button
@@ -354,15 +369,17 @@ export default function RequirementsPage() {
<div className="flex gap-8">
{/* Main Panel */}
<div className="flex-1">
{/* New Requirement Button */}
<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>
{/* 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">
@@ -432,6 +449,8 @@ export default function RequirementsPage() {
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
@@ -451,9 +470,21 @@ export default function RequirementsPage() {
{/* Validation status */}
<div className="flex-1 px-6 py-4 text-center">
<span className="text-sm text-gray-600">
Validation: {validationStatus}
</span>
<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 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 */}
@@ -470,12 +501,14 @@ export default function RequirementsPage() {
>
Details
</button>
<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>
{!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>
)

View File

@@ -9,3 +9,4 @@ export { requirementService } from './requirementService'
export type { Requirement, RequirementCreateRequest, RequirementUpdateRequest } from './requirementService'
export { projectService } from './projectService'
export type { Project, ProjectCreateRequest, ProjectUpdateRequest } from './projectService'
export { validationService } from './validationService'

View File

@@ -16,6 +16,9 @@ export interface Requirement {
priority: Priority | null
groups: Group[]
validation_status: string | null
validated_by: string | null
validated_at: string | null
validation_version: number | null
}
export interface RequirementCreateRequest {

View File

@@ -0,0 +1,83 @@
import type { ValidationStatus, ValidationHistory, ValidationCreateRequest } from '@/types'
const API_BASE_URL = '/api'
class ValidationService {
/**
* Get all validation statuses from the API.
*/
async getStatuses(): Promise<ValidationStatus[]> {
try {
const response = await fetch(`${API_BASE_URL}/validation-statuses`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const statuses: ValidationStatus[] = await response.json()
return statuses
} catch (error) {
console.error('Failed to fetch validation statuses:', error)
throw error
}
}
/**
* Create a validation for a requirement.
*/
async createValidation(requirementId: number, data: ValidationCreateRequest): Promise<ValidationHistory> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/validations`, {
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 validation: ValidationHistory = await response.json()
return validation
} catch (error) {
console.error('Failed to create validation:', error)
throw error
}
}
/**
* Get validation history for a requirement.
*/
async getValidationHistory(requirementId: number): Promise<ValidationHistory[]> {
try {
const response = await fetch(`${API_BASE_URL}/requirements/${requirementId}/validations`, {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const history: ValidationHistory[] = await response.json()
return history
} catch (error) {
console.error('Failed to fetch validation history:', error)
throw error
}
}
}
export const validationService = new ValidationService()

View File

@@ -2,12 +2,15 @@ export interface User {
preferred_username: string
email: string | null
full_name: string | null
role: string | null
role_id: number | null
}
export interface AuthContextType {
user: User | null
isLoading: boolean
isAuthenticated: boolean
isAuditor: boolean
login: () => void
logout: () => Promise<void>
refreshUser: () => Promise<void>

View File

@@ -1 +1,23 @@
export * from './auth'
// Validation types
export interface ValidationStatus {
id: number
status_name: string
}
export interface ValidationHistory {
id: number
status_name: string
status_id: number
req_version_snapshot: number
comment: string | null
created_at: string | null
validator_username: string
validator_id: number
}
export interface ValidationCreateRequest {
status_id: number
comment?: string
}