QoL features to the dashboard

This commit is contained in:
gulimabr
2025-12-04 14:59:23 -03:00
parent 176ad101c7
commit 469ecc8054
2 changed files with 353 additions and 12 deletions

View File

@@ -111,6 +111,31 @@ export default function DashboardPage() {
return { total, validated, notValidated: total - validated } return { total, validated, notValidated: total - validated }
} }
// Get requirements needing attention (Denied or Partial/Partially Approved)
const getRequirementsNeedingAttention = () => {
return requirements.filter(req => {
const status = req.validation_status?.toLowerCase() || ''
return status === 'denied' || status.includes('partial')
})
}
// Get requirements validated in a previous version (for auditors)
// These are requirements where the current version is greater than the version that was validated
const getRequirementsNeedingRevalidation = () => {
return requirements.filter(req => {
// Must have been validated at least once
if (!req.validation_version) return false
// Current version must be greater than the version that was validated
return req.version > req.validation_version
})
}
const requirementsNeedingAttention = getRequirementsNeedingAttention()
const requirementsNeedingRevalidation = getRequirementsNeedingRevalidation()
// Determine if user is an auditor (role_id=2)
const isAuditor = user?.role_id === 2
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
@@ -150,6 +175,20 @@ export default function DashboardPage() {
navigate('/requirements', { state: { openCreateModal: true } }) navigate('/requirements', { state: { openCreateModal: true } })
} }
const handleNeedsAttentionClick = () => {
// Filter will be handled case-insensitively on RequirementsPage
navigate('/requirements', { state: { validationFilter: ['Denied', 'Partial'], needsAttention: true } })
}
const handleNeedsRevalidationClick = () => {
// Navigate to requirements page with a special filter for outdated validations
navigate('/requirements', { state: { needsRevalidation: true } })
}
const handleRequirementClick = (reqId: number) => {
navigate(`/requirements/${reqId}`)
}
const handleProjectSelect = (project: typeof currentProject) => { const handleProjectSelect = (project: typeof currentProject) => {
if (project) { if (project) {
setCurrentProject(project) setCurrentProject(project)
@@ -534,6 +573,192 @@ export default function DashboardPage() {
</div> </div>
)} )}
</div> </div>
{/* Needs Attention Section */}
{!loading && !error && currentProject && requirementsNeedingAttention.length > 0 && (
<div className="mt-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 rounded-lg">
<svg className="w-5 h-5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-800">
Needs Attention
</h2>
<p className="text-sm text-gray-500">
Requirements with denied or partial validation
</p>
</div>
</div>
<button
onClick={handleNeedsAttentionClick}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-amber-700 bg-amber-50 hover:bg-amber-100 rounded-lg transition-colors"
>
View All ({requirementsNeedingAttention.length})
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Requirements Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{requirementsNeedingAttention.slice(0, 6).map((req) => {
const status = req.validation_status?.toLowerCase() || ''
const isDenied = status === 'denied'
return (
<div
key={req.id}
onClick={() => handleRequirementClick(req.id)}
className={`p-4 bg-white border-l-4 rounded-lg shadow-sm hover:shadow-md cursor-pointer transition-all ${
isDenied ? 'border-l-red-500' : 'border-l-yellow-500'
}`}
>
<div className="flex items-start justify-between mb-2">
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
{req.tag.tag_code}
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
isDenied
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{req.validation_status}
</span>
</div>
<h4 className="font-medium text-gray-800 text-sm line-clamp-2 mb-2">
{req.req_name}
</h4>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>v{req.version}</span>
{req.validated_by && (
<span className="truncate max-w-[120px]" title={`Validated by ${req.validated_by}`}>
by {req.validated_by}
</span>
)}
</div>
</div>
)
})}
</div>
{/* Show more indicator */}
{requirementsNeedingAttention.length > 6 && (
<div className="mt-4 text-center">
<button
onClick={handleNeedsAttentionClick}
className="text-sm text-amber-600 hover:text-amber-700 font-medium"
>
+ {requirementsNeedingAttention.length - 6} more requirements need attention
</button>
</div>
)}
</div>
)}
{/* Needs Revalidation Section - Only for Auditors */}
{!loading && !error && currentProject && isAuditor && requirementsNeedingRevalidation.length > 0 && (
<div className="mt-10">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<div>
<h2 className="text-lg font-semibold text-gray-800">
Needs Revalidation
</h2>
<p className="text-sm text-gray-500">
Requirements updated since last validation
</p>
</div>
</div>
<button
onClick={handleNeedsRevalidationClick}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors"
>
View All ({requirementsNeedingRevalidation.length})
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
{/* Requirements Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{requirementsNeedingRevalidation.slice(0, 6).map((req) => {
const versionDiff = req.version - (req.validation_version || 0)
return (
<div
key={req.id}
onClick={() => handleRequirementClick(req.id)}
className="p-4 bg-white border-l-4 border-l-blue-500 rounded-lg shadow-sm hover:shadow-md cursor-pointer transition-all"
>
<div className="flex items-start justify-between mb-2">
<span className="text-xs font-medium text-gray-500 bg-gray-100 px-2 py-0.5 rounded">
{req.tag.tag_code}
</span>
<span className="text-xs font-medium px-2 py-0.5 rounded bg-blue-100 text-blue-700">
{versionDiff} version{versionDiff > 1 ? 's' : ''} behind
</span>
</div>
<h4 className="font-medium text-gray-800 text-sm line-clamp-2 mb-2">
{req.req_name}
</h4>
<div className="flex items-center justify-between text-xs text-gray-500">
<span>
v{req.validation_version} v{req.version}
</span>
{req.validated_by && (
<span className="truncate max-w-[120px]" title={`Last validated by ${req.validated_by}`}>
by {req.validated_by}
</span>
)}
</div>
</div>
)
})}
</div>
{/* Show more indicator */}
{requirementsNeedingRevalidation.length > 6 && (
<div className="mt-4 text-center">
<button
onClick={handleNeedsRevalidationClick}
className="text-sm text-blue-600 hover:text-blue-700 font-medium"
>
+ {requirementsNeedingRevalidation.length - 6} more requirements need revalidation
</button>
</div>
)}
</div>
)}
{/* Empty Attention State - Show positive message when no issues */}
{!loading && !error && currentProject && requirements.length > 0 && requirementsNeedingAttention.length === 0 && (
<div className="mt-10 p-6 bg-green-50 border border-green-200 rounded-xl">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 rounded-lg">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-green-800">All Clear!</h3>
<p className="text-sm text-green-600">
No requirements need attention. All validations are either approved or pending review.
</p>
</div>
</div>
</div>
)}
</main> </main>
</div> </div>

View File

@@ -10,17 +10,18 @@ import type { DeletedRequirement } from '@/types'
// Get validation status color // Get validation status color
const getValidationStatusStyle = (status: string): { bgColor: string; textColor: string } => { const getValidationStatusStyle = (status: string): { bgColor: string; textColor: string } => {
switch (status) { const statusLower = status.toLowerCase()
case 'Approved': if (statusLower === 'approved') {
return { bgColor: 'bg-green-100', textColor: 'text-green-800' } 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' }
} }
if (statusLower === 'denied') {
return { bgColor: 'bg-red-100', textColor: 'text-red-800' }
}
if (statusLower.includes('partial')) {
return { bgColor: 'bg-yellow-100', textColor: 'text-yellow-800' }
}
// 'Not Validated' or default
return { bgColor: 'bg-gray-100', textColor: 'text-gray-600' }
} }
// Check if requirement is in draft status // Check if requirement is in draft status
@@ -46,6 +47,8 @@ export default function RequirementsPage() {
// Filter state // Filter state
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedGroups, setSelectedGroups] = useState<number[]>([]) const [selectedGroups, setSelectedGroups] = useState<number[]>([])
const [selectedValidationStatuses, setSelectedValidationStatuses] = useState<string[]>([])
const [needsRevalidationFilter, setNeedsRevalidationFilter] = useState(false)
const [orderBy, setOrderBy] = useState<'Date' | 'Priority' | 'Name'>('Date') const [orderBy, setOrderBy] = useState<'Date' | 'Priority' | 'Name'>('Date')
// Modal state // Modal state
@@ -134,6 +137,21 @@ export default function RequirementsPage() {
// Clear the state so it doesn't reopen on refresh // Clear the state so it doesn't reopen on refresh
navigate(location.pathname, { replace: true, state: {} }) navigate(location.pathname, { replace: true, state: {} })
} }
// Handle needs revalidation filter from navigation state
if (location.state?.needsRevalidation) {
setNeedsRevalidationFilter(true)
navigate(location.pathname, { replace: true, state: {} })
}
// Handle validation status filter from navigation state (needsAttention mode)
else if (location.state?.needsAttention) {
// Special handling for "needs attention" - will filter Denied and Partial case-insensitively
setSelectedValidationStatuses(['__NEEDS_ATTENTION__'])
navigate(location.pathname, { replace: true, state: {} })
} else if (location.state?.validationFilter && Array.isArray(location.state.validationFilter)) {
setSelectedValidationStatuses(location.state.validationFilter)
// Clear the state so it doesn't persist on refresh
navigate(location.pathname, { replace: true, state: {} })
}
}, [location.state, isAuditor, projectLoading, currentProject, navigate, location.pathname]) }, [location.state, isAuditor, projectLoading, currentProject, navigate, location.pathname])
// Fetch deleted requirements when panel is opened // Fetch deleted requirements when panel is opened
@@ -159,7 +177,7 @@ export default function RequirementsPage() {
} }
} }
// Filter requirements based on search and selected groups // Filter requirements based on search, selected groups, validation status, and revalidation needs
const filteredRequirements = requirements.filter(req => { const filteredRequirements = requirements.filter(req => {
const matchesSearch = searchQuery === '' || const matchesSearch = searchQuery === '' ||
req.tag.tag_code.toLowerCase().includes(searchQuery.toLowerCase()) || req.tag.tag_code.toLowerCase().includes(searchQuery.toLowerCase()) ||
@@ -168,7 +186,29 @@ export default function RequirementsPage() {
const matchesGroup = selectedGroups.length === 0 || const matchesGroup = selectedGroups.length === 0 ||
req.groups.some(g => selectedGroups.includes(g.id)) req.groups.some(g => selectedGroups.includes(g.id))
return matchesSearch && matchesGroup // Handle needs revalidation filter (requirements with outdated validation)
const matchesRevalidation = !needsRevalidationFilter ||
(req.validation_version !== null && req.version > req.validation_version)
// Handle validation status filtering
let matchesValidationStatus = true
if (selectedValidationStatuses.length > 0) {
const reqStatus = req.validation_status || 'Not Validated'
const reqStatusLower = reqStatus.toLowerCase()
// Special case: filter for "needs attention" (Denied or Partial variants)
if (selectedValidationStatuses.includes('__NEEDS_ATTENTION__')) {
matchesValidationStatus = reqStatusLower === 'denied' || reqStatusLower.includes('partial')
} else {
// Normal case-insensitive matching
matchesValidationStatus = selectedValidationStatuses.some(
status => status.toLowerCase() === reqStatusLower ||
(status.toLowerCase() === 'partial' && reqStatusLower.includes('partial'))
)
}
}
return matchesSearch && matchesGroup && matchesValidationStatus && matchesRevalidation
}) })
// Sort requirements // Sort requirements
@@ -199,9 +239,19 @@ export default function RequirementsPage() {
const handleClear = () => { const handleClear = () => {
setSearchQuery('') setSearchQuery('')
setSelectedGroups([]) setSelectedGroups([])
setSelectedValidationStatuses([])
setNeedsRevalidationFilter(false)
setSearchParams({}) setSearchParams({})
} }
const handleValidationStatusToggle = (status: string) => {
setSelectedValidationStatuses(prev =>
prev.includes(status)
? prev.filter(s => s !== status)
: [...prev, status]
)
}
const handleSearch = () => { const handleSearch = () => {
// Filtering is automatic, but this could trigger a fresh API call // Filtering is automatic, but this could trigger a fresh API call
} }
@@ -506,6 +556,72 @@ export default function RequirementsPage() {
</div> </div>
</div> </div>
{/* Filter Validation Status */}
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<p className="text-sm text-gray-600">Filter Validation Status</p>
<div className="flex items-center gap-2">
{needsRevalidationFilter && (
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded-full">
Showing: Needs Revalidation
</span>
)}
{selectedValidationStatuses.includes('__NEEDS_ATTENTION__') && (
<span className="text-xs bg-amber-100 text-amber-700 px-2 py-1 rounded-full">
Showing: Needs Attention
</span>
)}
</div>
</div>
<div className="flex flex-wrap gap-3">
{/* Needs Revalidation button - only for auditors */}
{isAuditor && (
<button
onClick={() => setNeedsRevalidationFilter(!needsRevalidationFilter)}
className={`px-3 py-1.5 rounded-full text-sm font-medium border-2 transition-colors flex items-center gap-1.5 ${
needsRevalidationFilter
? 'bg-blue-100 border-blue-500 text-blue-800'
: 'bg-white border-gray-300 text-gray-600 hover:border-blue-400'
}`}
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Needs Revalidation
</button>
)}
{['Approved', 'Denied', 'Partially Approved', 'Not Validated'].map((status) => {
// Check if this status is selected (direct match or via needs attention mode)
const isNeedsAttentionMode = selectedValidationStatuses.includes('__NEEDS_ATTENTION__')
const statusLower = status.toLowerCase()
const isSelected = selectedValidationStatuses.some(s => s.toLowerCase() === statusLower) ||
(isNeedsAttentionMode && (status === 'Denied' || statusLower.includes('partial')))
const statusStyles: Record<string, string> = {
'Approved': isSelected ? 'bg-green-100 border-green-500 text-green-800' : 'bg-white border-gray-300 text-gray-600 hover:border-green-400',
'Denied': isSelected ? 'bg-red-100 border-red-500 text-red-800' : 'bg-white border-gray-300 text-gray-600 hover:border-red-400',
'Partially Approved': isSelected ? 'bg-yellow-100 border-yellow-500 text-yellow-800' : 'bg-white border-gray-300 text-gray-600 hover:border-yellow-400',
'Not Validated': isSelected ? 'bg-gray-200 border-gray-500 text-gray-800' : 'bg-white border-gray-300 text-gray-600 hover:border-gray-400',
}
return (
<button
key={status}
onClick={() => {
// If in needs attention mode, switch to regular mode first
if (isNeedsAttentionMode) {
setSelectedValidationStatuses([status])
} else {
handleValidationStatusToggle(status)
}
}}
className={`px-3 py-1.5 rounded-full text-sm font-medium border-2 transition-colors ${statusStyles[status]}`}
>
{status}
</button>
)
})}
</div>
</div>
{/* Order By */} {/* Order By */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">