i have made filter date consistent even after navigation
This commit is contained in:
parent
7358c3ff30
commit
a4abc2ab58
@ -89,14 +89,17 @@ export function UserManagement() {
|
|||||||
// Search users from Okta
|
// Search users from Okta
|
||||||
const searchUsers = useCallback(
|
const searchUsers = useCallback(
|
||||||
debounce(async (query: string) => {
|
debounce(async (query: string) => {
|
||||||
if (!query || query.length < 2) {
|
// Only trigger search when using @ sign
|
||||||
|
if (!query || !query.startsWith('@') || query.length < 2) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const response = await userApi.searchUsers(query, 20);
|
const term = query.slice(1); // Remove @ prefix
|
||||||
|
const response = await userApi.searchUsers(term, 20);
|
||||||
const users = response.data?.data || [];
|
const users = response.data?.data || [];
|
||||||
setSearchResults(users);
|
setSearchResults(users);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -397,7 +400,7 @@ export function UserManagement() {
|
|||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type name or email address..."
|
placeholder="Type @ to search users..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
@ -407,7 +410,7 @@ export function UserManagement() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">Start typing to search across all Okta users</p>
|
<p className="text-xs text-muted-foreground">Start with @ to search users (e.g., @john)</p>
|
||||||
|
|
||||||
{/* Search Results Dropdown */}
|
{/* Search Results Dropdown */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
|
|||||||
@ -100,14 +100,17 @@ export function UserRoleManager() {
|
|||||||
// Search users from Okta
|
// Search users from Okta
|
||||||
const searchUsers = useCallback(
|
const searchUsers = useCallback(
|
||||||
debounce(async (query: string) => {
|
debounce(async (query: string) => {
|
||||||
if (!query || query.length < 2) {
|
// Only trigger search when using @ sign
|
||||||
|
if (!query || !query.startsWith('@') || query.length < 2) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
|
setSearching(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const response = await userApi.searchUsers(query, 20);
|
const term = query.slice(1); // Remove @ prefix
|
||||||
|
const response = await userApi.searchUsers(term, 20);
|
||||||
|
|
||||||
// Backend returns { success: true, data: [...users], message, timestamp }
|
// Backend returns { success: true, data: [...users], message, timestamp }
|
||||||
// Axios response is in response.data, actual user array is in response.data.data
|
// Axios response is in response.data, actual user array is in response.data.data
|
||||||
@ -442,7 +445,7 @@ export function UserRoleManager() {
|
|||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Type name or email address..."
|
placeholder="Type @ to search users..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={handleSearchChange}
|
onChange={handleSearchChange}
|
||||||
className="pl-10 pr-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
className="pl-10 pr-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
@ -452,7 +455,7 @@ export function UserRoleManager() {
|
|||||||
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
|
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
|
<p className="text-xs text-gray-500">Start with @ to search users (e.g., @john)</p>
|
||||||
|
|
||||||
{/* Search Results Dropdown */}
|
{/* Search Results Dropdown */}
|
||||||
{searchResults.length > 0 && (
|
{searchResults.length > 0 && (
|
||||||
|
|||||||
@ -17,12 +17,14 @@ export interface SLAData {
|
|||||||
interface SLAProgressBarProps {
|
interface SLAProgressBarProps {
|
||||||
sla: SLAData | null;
|
sla: SLAData | null;
|
||||||
requestStatus: string;
|
requestStatus: string;
|
||||||
|
isPaused?: boolean;
|
||||||
testId?: string;
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SLAProgressBar({
|
export function SLAProgressBar({
|
||||||
sla,
|
sla,
|
||||||
requestStatus,
|
requestStatus,
|
||||||
|
isPaused = false,
|
||||||
testId = 'sla-progress'
|
testId = 'sla-progress'
|
||||||
}: SLAProgressBarProps) {
|
}: SLAProgressBarProps) {
|
||||||
// If request is closed/approved/rejected or no SLA data, show status message
|
// If request is closed/approved/rejected or no SLA data, show status message
|
||||||
@ -44,34 +46,49 @@ export function SLAProgressBar({
|
|||||||
|
|
||||||
// Use percentage-based colors to match approver SLA tracker
|
// Use percentage-based colors to match approver SLA tracker
|
||||||
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||||
|
// Grey: When paused (frozen state)
|
||||||
const percentageUsed = sla.percentageUsed || 0;
|
const percentageUsed = sla.percentageUsed || 0;
|
||||||
const rawStatus = sla.status || 'on_track';
|
const rawStatus = sla.status || 'on_track';
|
||||||
|
|
||||||
// Determine colors based on percentage (matching ApprovalStepCard logic)
|
// Determine colors based on percentage (matching ApprovalStepCard logic)
|
||||||
const getStatusColors = () => {
|
const getStatusColors = () => {
|
||||||
|
// If paused, use grey colors regardless of percentage
|
||||||
|
if (isPaused) {
|
||||||
|
return {
|
||||||
|
badge: 'bg-gray-500 text-white',
|
||||||
|
progress: 'bg-gray-500',
|
||||||
|
text: 'text-gray-600',
|
||||||
|
icon: 'text-gray-500'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (percentageUsed >= 100) {
|
if (percentageUsed >= 100) {
|
||||||
return {
|
return {
|
||||||
badge: 'bg-red-600 text-white animate-pulse',
|
badge: 'bg-red-600 text-white animate-pulse',
|
||||||
progress: 'bg-red-600',
|
progress: 'bg-red-600',
|
||||||
text: 'text-red-600'
|
text: 'text-red-600',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
} else if (percentageUsed >= 75) {
|
} else if (percentageUsed >= 75) {
|
||||||
return {
|
return {
|
||||||
badge: 'bg-orange-500 text-white',
|
badge: 'bg-orange-500 text-white',
|
||||||
progress: 'bg-orange-500',
|
progress: 'bg-orange-500',
|
||||||
text: 'text-orange-600'
|
text: 'text-orange-600',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
} else if (percentageUsed >= 50) {
|
} else if (percentageUsed >= 50) {
|
||||||
return {
|
return {
|
||||||
badge: 'bg-amber-500 text-white',
|
badge: 'bg-amber-500 text-white',
|
||||||
progress: 'bg-amber-500',
|
progress: 'bg-amber-500',
|
||||||
text: 'text-amber-600'
|
text: 'text-amber-600',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
badge: 'bg-green-600 text-white',
|
badge: 'bg-green-600 text-white',
|
||||||
progress: 'bg-green-600',
|
progress: 'bg-green-600',
|
||||||
text: 'text-gray-700'
|
text: 'text-gray-700',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -87,14 +104,20 @@ export function SLAProgressBar({
|
|||||||
<div data-testid={testId}>
|
<div data-testid={testId}>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Clock className="h-4 w-4 text-blue-600" />
|
{isPaused ? (
|
||||||
<span className="text-sm font-semibold text-gray-900">SLA Progress</span>
|
<Lock className={`h-4 w-4 ${colors.icon}`} />
|
||||||
|
) : (
|
||||||
|
<Clock className={`h-4 w-4 ${colors.icon}`} />
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
{isPaused ? 'SLA Progress (Paused)' : 'SLA Progress'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
className={`text-xs ${colors.badge}`}
|
className={`text-xs ${colors.badge}`}
|
||||||
data-testid={`${testId}-badge`}
|
data-testid={`${testId}-badge`}
|
||||||
>
|
>
|
||||||
{sla.percentageUsed || 0}% elapsed
|
{sla.percentageUsed || 0}% elapsed {isPaused && '(frozen)'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -369,6 +369,7 @@ export function ApprovalStepCard({
|
|||||||
|
|
||||||
{/* Current Approver - Time Tracking */}
|
{/* Current Approver - Time Tracking */}
|
||||||
<div className={`border rounded-lg p-3 ${
|
<div className={`border rounded-lg p-3 ${
|
||||||
|
isPaused ? 'bg-gray-100 border-gray-300' :
|
||||||
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
||||||
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
||||||
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
||||||
@ -376,7 +377,7 @@ export function ApprovalStepCard({
|
|||||||
}`}>
|
}`}>
|
||||||
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
Current Approver - Time Tracking
|
Current Approver - Time Tracking {isPaused && '(Paused)'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-2 text-xs mb-3">
|
<div className="space-y-2 text-xs mb-3">
|
||||||
@ -395,14 +396,17 @@ export function ApprovalStepCard({
|
|||||||
{(() => {
|
{(() => {
|
||||||
// Determine color based on percentage used
|
// Determine color based on percentage used
|
||||||
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||||
|
// Grey: When paused (frozen state)
|
||||||
const percentUsed = approval.sla.percentageUsed || 0;
|
const percentUsed = approval.sla.percentageUsed || 0;
|
||||||
const getActiveIndicatorColor = () => {
|
const getActiveIndicatorColor = () => {
|
||||||
|
if (isPaused) return 'bg-gray-500'; // Grey when paused
|
||||||
if (percentUsed >= 100) return 'bg-red-600';
|
if (percentUsed >= 100) return 'bg-red-600';
|
||||||
if (percentUsed >= 75) return 'bg-orange-500';
|
if (percentUsed >= 75) return 'bg-orange-500';
|
||||||
if (percentUsed >= 50) return 'bg-amber-500';
|
if (percentUsed >= 50) return 'bg-amber-500';
|
||||||
return 'bg-green-600';
|
return 'bg-green-600';
|
||||||
};
|
};
|
||||||
const getActiveTextColor = () => {
|
const getActiveTextColor = () => {
|
||||||
|
if (isPaused) return 'text-gray-600'; // Grey when paused
|
||||||
if (percentUsed >= 100) return 'text-red-600';
|
if (percentUsed >= 100) return 'text-red-600';
|
||||||
if (percentUsed >= 75) return 'text-orange-600';
|
if (percentUsed >= 75) return 'text-orange-600';
|
||||||
if (percentUsed >= 50) return 'text-amber-600';
|
if (percentUsed >= 50) return 'text-amber-600';
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
import { getSocket } from '@/utils/socket';
|
import { getSocket } from '@/utils/socket';
|
||||||
@ -223,7 +223,6 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
try {
|
try {
|
||||||
const { getPauseDetails } = await import('@/services/workflowApi');
|
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Pause info not available or request not paused - ignore
|
// Pause info not available or request not paused - ignore
|
||||||
@ -436,7 +435,6 @@ export function useRequestDetails(
|
|||||||
// Fetch pause details
|
// Fetch pause details
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
try {
|
try {
|
||||||
const { getPauseDetails } = await import('@/services/workflowApi');
|
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Pause info not available or request not paused - ignore
|
// Pause info not available or request not paused - ignore
|
||||||
|
|||||||
@ -50,13 +50,6 @@ export function useApproverPerformanceData({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const dateRangeToSend = dateRange === 'all' ? undefined : dateRange;
|
const dateRangeToSend = dateRange === 'all' ? undefined : dateRange;
|
||||||
console.log('[Stats] Fetching with filters:', {
|
|
||||||
dateRange: dateRangeToSend,
|
|
||||||
customStartDate,
|
|
||||||
customEndDate,
|
|
||||||
priority: priorityFilter,
|
|
||||||
sla: slaComplianceFilter
|
|
||||||
});
|
|
||||||
|
|
||||||
const stats = await dashboardService.getSingleApproverStats(
|
const stats = await dashboardService.getSingleApproverStats(
|
||||||
approverId,
|
approverId,
|
||||||
@ -67,7 +60,6 @@ export function useApproverPerformanceData({
|
|||||||
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined
|
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log('[Stats] Received stats:', stats);
|
|
||||||
setApproverStats(stats);
|
setApproverStats(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ApproverPerformance] Failed to fetch approver stats:', error);
|
console.error('[ApproverPerformance] Failed to fetch approver stats:', error);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef } from 'react';
|
import { useCallback, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
||||||
@ -12,7 +12,7 @@ import { useClosedRequests } from './hooks/useClosedRequests';
|
|||||||
import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { ClosedRequestsProps, ClosedRequestsFilters } from './types/closedRequests.types';
|
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
||||||
|
|
||||||
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||||
// Data fetching hook
|
// Data fetching hook
|
||||||
@ -22,26 +22,74 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
const fetchRef = useRef(closedRequests.fetchRequests);
|
const fetchRef = useRef(closedRequests.fetchRequests);
|
||||||
fetchRef.current = closedRequests.fetchRequests;
|
fetchRef.current = closedRequests.fetchRequests;
|
||||||
|
|
||||||
const filters = useClosedRequestsFilters({
|
const filters = useClosedRequestsFilters();
|
||||||
onFiltersChange: useCallback(
|
const prevFiltersRef = useRef({
|
||||||
(filters: ClosedRequestsFilters) => {
|
searchTerm: filters.searchTerm,
|
||||||
// Reset to page 1 when filters change
|
statusFilter: filters.statusFilter,
|
||||||
fetchRef.current(1, {
|
priorityFilter: filters.priorityFilter,
|
||||||
search: filters.search || undefined,
|
|
||||||
status: filters.status !== 'all' ? filters.status : undefined,
|
|
||||||
priority: filters.priority !== 'all' ? filters.priority : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
},
|
const hasInitialFetchRun = useRef(false);
|
||||||
[]
|
|
||||||
),
|
// Initial fetch on mount - use stored page from Redux
|
||||||
|
useEffect(() => {
|
||||||
|
const storedPage = filters.currentPage || 1;
|
||||||
|
fetchRef.current(storedPage, {
|
||||||
|
search: filters.searchTerm || undefined,
|
||||||
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
|
hasInitialFetchRun.current = true;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Only on mount
|
||||||
|
|
||||||
|
// Track filter changes and refetch
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialFetchRun.current) return;
|
||||||
|
|
||||||
|
const prev = prevFiltersRef.current;
|
||||||
|
const hasChanged =
|
||||||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
|
prev.sortBy !== filters.sortBy ||
|
||||||
|
prev.sortOrder !== filters.sortOrder;
|
||||||
|
|
||||||
|
if (!hasChanged) return; // No actual change, skip
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
|
fetchRef.current(1, {
|
||||||
|
search: filters.searchTerm || undefined,
|
||||||
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update previous values
|
||||||
|
prevFiltersRef.current = {
|
||||||
|
searchTerm: filters.searchTerm,
|
||||||
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
|
};
|
||||||
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.sortBy, filters.sortOrder]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= closedRequests.pagination.totalPages) {
|
if (newPage >= 1 && newPage <= closedRequests.pagination.totalPages) {
|
||||||
|
filters.setCurrentPage(newPage); // Update page in Redux
|
||||||
closedRequests.fetchRequests(newPage, {
|
closedRequests.fetchRequests(newPage, {
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
|
|||||||
@ -106,20 +106,20 @@ export function ClosedRequestsFilters({
|
|||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<SelectValue placeholder="Closure Type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
<SelectItem value="all">All Closures</SelectItem>
|
||||||
<SelectItem value="closed">
|
<SelectItem value="approved">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CheckCircle className="w-4 h-4 text-gray-600" />
|
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||||
<span>Closed</span>
|
<span>Closed After Approval</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="rejected">
|
<SelectItem value="rejected">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<XCircle className="w-4 h-4 text-red-600" />
|
<XCircle className="w-4 h-4 text-red-600" />
|
||||||
<span>Rejected</span>
|
<span>Closed After Rejection</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Hook for fetching and managing Closed Requests data
|
* Hook for fetching and managing Closed Requests data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { ClosedRequest, PaginationState } from '../types/closedRequests.types';
|
import { ClosedRequest, PaginationState } from '../types/closedRequests.types';
|
||||||
import { transformClosedRequests } from '../utils/requestTransformers';
|
import { transformClosedRequests } from '../utils/requestTransformers';
|
||||||
@ -18,7 +18,7 @@ interface UseClosedRequestsOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClosedRequestsOptions = {}) {
|
export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOptions = {}) {
|
||||||
const [requests, setRequests] = useState<ClosedRequest[]>([]);
|
const [requests, setRequests] = useState<ClosedRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@ -28,7 +28,6 @@ export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClos
|
|||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
});
|
});
|
||||||
const isInitialMount = useRef(true);
|
|
||||||
|
|
||||||
const fetchRequests = useCallback(
|
const fetchRequests = useCallback(
|
||||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
@ -74,17 +73,9 @@ export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClos
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
const mapped = transformClosedRequests(data);
|
const mapped = transformClosedRequests(data);
|
||||||
// Filter out approved requests - only show rejected and closed
|
setRequests(mapped); // No client-side filtering - backend returns only CLOSED requests
|
||||||
const filtered = mapped.filter(request =>
|
|
||||||
request.status === 'rejected' || request.status === 'closed'
|
|
||||||
);
|
|
||||||
setRequests(filtered);
|
|
||||||
|
|
||||||
// Set pagination data
|
// Set pagination data from backend
|
||||||
// Note: Since we're filtering out approved requests client-side,
|
|
||||||
// the pagination count from backend may include approved requests.
|
|
||||||
// We'll use the filtered count for this page, but total records
|
|
||||||
// from backend is approximate.
|
|
||||||
const paginationData = (result as any)?.pagination;
|
const paginationData = (result as any)?.pagination;
|
||||||
if (paginationData) {
|
if (paginationData) {
|
||||||
// If we filtered out some requests on this page, we need to adjust pagination
|
// If we filtered out some requests on this page, we need to adjust pagination
|
||||||
@ -108,14 +99,8 @@ export function useClosedRequests({ itemsPerPage = 10, initialFilters }: UseClos
|
|||||||
[itemsPerPage]
|
[itemsPerPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial fetch on mount
|
// Initial fetch removed - component handles initial fetch using Redux stored page
|
||||||
useEffect(() => {
|
// This prevents duplicate fetches and allows page persistence
|
||||||
if (isInitialMount.current) {
|
|
||||||
fetchRequests(1, initialFilters);
|
|
||||||
isInitialMount.current = false;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []); // Only run on mount
|
|
||||||
|
|
||||||
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
|
|||||||
@ -1,9 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for managing Closed Requests filters and sorting
|
* Hook for managing Closed Requests filters and sorting using Redux
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||||
import { ClosedRequestsFilters } from '../types/closedRequests.types';
|
import { ClosedRequestsFilters } from '../types/closedRequests.types';
|
||||||
|
import {
|
||||||
|
setSearchTerm as setSearchTermAction,
|
||||||
|
setStatusFilter as setStatusFilterAction,
|
||||||
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
|
setSortBy as setSortByAction,
|
||||||
|
setSortOrder as setSortOrderAction,
|
||||||
|
setCurrentPage as setCurrentPageAction,
|
||||||
|
clearFilters as clearFiltersAction,
|
||||||
|
} from '../redux/closedRequestsSlice';
|
||||||
|
|
||||||
interface UseClosedRequestsFiltersOptions {
|
interface UseClosedRequestsFiltersOptions {
|
||||||
onFiltersChange?: (filters: ClosedRequestsFilters) => void;
|
onFiltersChange?: (filters: ClosedRequestsFilters) => void;
|
||||||
@ -11,14 +21,21 @@ interface UseClosedRequestsFiltersOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseClosedRequestsFiltersOptions = {}) {
|
export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseClosedRequestsFiltersOptions = {}) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const dispatch = useAppDispatch();
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
|
||||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
|
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
// Get filters from Redux
|
||||||
|
const { searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
|
||||||
|
|
||||||
|
// Create setters that dispatch Redux actions
|
||||||
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
|
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
||||||
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
|
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
|
||||||
|
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
|
||||||
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
|
|
||||||
const getFilters = useCallback((): ClosedRequestsFilters => {
|
const getFilters = useCallback((): ClosedRequestsFilters => {
|
||||||
return {
|
return {
|
||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
@ -57,10 +74,8 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setSearchTerm('');
|
dispatch(clearFiltersAction());
|
||||||
setPriorityFilter('all');
|
}, [dispatch]);
|
||||||
setStatusFilter('all');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const activeFiltersCount = [
|
const activeFiltersCount = [
|
||||||
searchTerm,
|
searchTerm,
|
||||||
@ -74,11 +89,13 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
statusFilter,
|
statusFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
currentPage,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
|
setCurrentPage,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
getFilters,
|
getFilters,
|
||||||
|
|||||||
63
src/pages/ClosedRequests/redux/closedRequestsSlice.ts
Normal file
63
src/pages/ClosedRequests/redux/closedRequestsSlice.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface ClosedRequestsFiltersState {
|
||||||
|
searchTerm: string;
|
||||||
|
statusFilter: string;
|
||||||
|
priorityFilter: string;
|
||||||
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
|
sortOrder: 'asc' | 'desc';
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: ClosedRequestsFiltersState = {
|
||||||
|
searchTerm: '',
|
||||||
|
statusFilter: 'all',
|
||||||
|
priorityFilter: 'all',
|
||||||
|
sortBy: 'created',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
currentPage: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const closedRequestsSlice = createSlice({
|
||||||
|
name: 'closedRequests',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setSearchTerm: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchTerm = action.payload;
|
||||||
|
},
|
||||||
|
setStatusFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.statusFilter = action.payload;
|
||||||
|
},
|
||||||
|
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.priorityFilter = action.payload;
|
||||||
|
},
|
||||||
|
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
|
||||||
|
state.sortBy = action.payload;
|
||||||
|
},
|
||||||
|
setSortOrder: (state, action: PayloadAction<'asc' | 'desc'>) => {
|
||||||
|
state.sortOrder = action.payload;
|
||||||
|
},
|
||||||
|
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.currentPage = action.payload;
|
||||||
|
},
|
||||||
|
clearFilters: (state) => {
|
||||||
|
state.searchTerm = '';
|
||||||
|
state.statusFilter = 'all';
|
||||||
|
state.priorityFilter = 'all';
|
||||||
|
state.currentPage = 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setSearchTerm,
|
||||||
|
setStatusFilter,
|
||||||
|
setPriorityFilter,
|
||||||
|
setSortBy,
|
||||||
|
setSortOrder,
|
||||||
|
setCurrentPage,
|
||||||
|
clearFilters,
|
||||||
|
} = closedRequestsSlice.actions;
|
||||||
|
|
||||||
|
export default closedRequestsSlice;
|
||||||
|
|
||||||
@ -17,9 +17,6 @@ import { useMyRequestsFilters } from './hooks/useMyRequestsFilters';
|
|||||||
// Utils
|
// Utils
|
||||||
import { transformRequests } from './utils/requestTransformers';
|
import { transformRequests } from './utils/requestTransformers';
|
||||||
|
|
||||||
// Types
|
|
||||||
import type { MyRequestsFilters } from './types/myRequests.types';
|
|
||||||
|
|
||||||
interface MyRequestsProps {
|
interface MyRequestsProps {
|
||||||
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
|
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
|
||||||
dynamicRequests?: any[];
|
dynamicRequests?: any[];
|
||||||
@ -35,20 +32,59 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
const fetchRef = useRef(myRequests.fetchMyRequests);
|
const fetchRef = useRef(myRequests.fetchMyRequests);
|
||||||
fetchRef.current = myRequests.fetchMyRequests;
|
fetchRef.current = myRequests.fetchMyRequests;
|
||||||
|
|
||||||
const filters = useMyRequestsFilters({
|
const filters = useMyRequestsFilters();
|
||||||
onFiltersChange: useCallback(
|
const prevFiltersRef = useRef({
|
||||||
(filters: MyRequestsFilters) => {
|
searchTerm: filters.searchTerm,
|
||||||
// Reset to page 1 when filters change
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
});
|
||||||
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
|
// Initial fetch on mount - use stored page from Redux
|
||||||
|
useEffect(() => {
|
||||||
|
const storedPage = filters.currentPage || 1;
|
||||||
|
fetchRef.current(storedPage, {
|
||||||
|
search: filters.searchTerm || undefined,
|
||||||
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
});
|
||||||
|
hasInitialFetchRun.current = true;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Only on mount
|
||||||
|
|
||||||
|
// Track filter changes and refetch
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialFetchRun.current) return;
|
||||||
|
|
||||||
|
const prev = prevFiltersRef.current;
|
||||||
|
const hasChanged =
|
||||||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
|
prev.priorityFilter !== filters.priorityFilter;
|
||||||
|
|
||||||
|
if (!hasChanged) return; // No actual change, skip
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
fetchRef.current(1, {
|
fetchRef.current(1, {
|
||||||
search: filters.search || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.status !== 'all' ? filters.status : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priority !== 'all' ? filters.priority : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
});
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update previous values
|
||||||
|
prevFiltersRef.current = {
|
||||||
|
searchTerm: filters.searchTerm,
|
||||||
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
};
|
||||||
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter]);
|
||||||
|
|
||||||
// State for backend stats (calculated from entire dataset via SQL queries)
|
// State for backend stats (calculated from entire dataset via SQL queries)
|
||||||
const [backendStats, setBackendStats] = useState<{
|
const [backendStats, setBackendStats] = useState<{
|
||||||
total: number;
|
total: number;
|
||||||
@ -155,6 +191,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(newPage: number) => {
|
(newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= myRequests.pagination.totalPages) {
|
if (newPage >= 1 && newPage <= myRequests.pagination.totalPages) {
|
||||||
|
filters.setCurrentPage(newPage); // Update page in Redux
|
||||||
myRequests.fetchMyRequests(newPage, {
|
myRequests.fetchMyRequests(newPage, {
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
@ -210,7 +247,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={myRequests.pagination.currentPage}
|
currentPage={filters.currentPage || myRequests.pagination.currentPage}
|
||||||
totalPages={myRequests.pagination.totalPages}
|
totalPages={myRequests.pagination.totalPages}
|
||||||
totalRecords={myRequests.pagination.totalRecords}
|
totalRecords={myRequests.pagination.totalRecords}
|
||||||
itemsPerPage={myRequests.pagination.itemsPerPage}
|
itemsPerPage={myRequests.pagination.itemsPerPage}
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* Hook for fetching and managing My Requests data
|
* Hook for fetching and managing My Requests data
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { MyRequest, PaginationState } from '../types/myRequests.types';
|
import { MyRequest, PaginationState } from '../types/myRequests.types';
|
||||||
import { transformRequests } from '../utils/requestTransformers';
|
import { transformRequests } from '../utils/requestTransformers';
|
||||||
@ -16,7 +16,7 @@ interface UseMyRequestsOptions {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMyRequests({ itemsPerPage = 10, initialFilters }: UseMyRequestsOptions = {}) {
|
export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {}) {
|
||||||
const [requests, setRequests] = useState<MyRequest[]>([]);
|
const [requests, setRequests] = useState<MyRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
||||||
@ -26,7 +26,6 @@ export function useMyRequests({ itemsPerPage = 10, initialFilters }: UseMyReques
|
|||||||
totalRecords: 0,
|
totalRecords: 0,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
});
|
});
|
||||||
const isInitialMount = useRef(true);
|
|
||||||
|
|
||||||
const fetchMyRequests = useCallback(
|
const fetchMyRequests = useCallback(
|
||||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
|
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
|
||||||
@ -72,14 +71,8 @@ export function useMyRequests({ itemsPerPage = 10, initialFilters }: UseMyReques
|
|||||||
[itemsPerPage]
|
[itemsPerPage]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial fetch on mount
|
// Initial fetch removed - component handles initial fetch using Redux stored page
|
||||||
useEffect(() => {
|
// This prevents duplicate fetches and allows page persistence
|
||||||
if (isInitialMount.current) {
|
|
||||||
fetchMyRequests(1, initialFilters);
|
|
||||||
isInitialMount.current = false;
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []); // Only run on mount
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
|
|||||||
@ -1,9 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for managing My Requests filters and search
|
* Hook for managing My Requests filters and search using Redux
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||||
import { MyRequestsFilters } from '../types/myRequests.types';
|
import { MyRequestsFilters } from '../types/myRequests.types';
|
||||||
|
import {
|
||||||
|
setSearchTerm as setSearchTermAction,
|
||||||
|
setStatusFilter as setStatusFilterAction,
|
||||||
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
|
setCurrentPage as setCurrentPageAction,
|
||||||
|
clearFilters as clearFiltersAction,
|
||||||
|
} from '../redux/myRequestsSlice';
|
||||||
|
|
||||||
interface UseMyRequestsFiltersOptions {
|
interface UseMyRequestsFiltersOptions {
|
||||||
onFiltersChange?: (filters: MyRequestsFilters) => void;
|
onFiltersChange?: (filters: MyRequestsFilters) => void;
|
||||||
@ -11,10 +19,18 @@ interface UseMyRequestsFiltersOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseMyRequestsFiltersOptions = {}) {
|
export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseMyRequestsFiltersOptions = {}) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const dispatch = useAppDispatch();
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
||||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
|
// Get filters from Redux
|
||||||
|
const { searchTerm, statusFilter, priorityFilter, currentPage } = useAppSelector((state) => state.myRequests);
|
||||||
|
|
||||||
|
// Create setters that dispatch Redux actions
|
||||||
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
|
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
||||||
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
|
|
||||||
const getFilters = useCallback((): MyRequestsFilters => {
|
const getFilters = useCallback((): MyRequestsFilters => {
|
||||||
return {
|
return {
|
||||||
@ -26,6 +42,12 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
|
|
||||||
// Debounced filter change handler
|
// Debounced filter change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip initial mount - let component handle initial fetch
|
||||||
|
if (isInitialMount.current) {
|
||||||
|
isInitialMount.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (debounceTimeoutRef.current) {
|
if (debounceTimeoutRef.current) {
|
||||||
clearTimeout(debounceTimeoutRef.current);
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
}
|
}
|
||||||
@ -46,18 +68,18 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
}, [searchTerm, statusFilter, priorityFilter, onFiltersChange, getFilters, debounceMs]);
|
}, [searchTerm, statusFilter, priorityFilter, onFiltersChange, getFilters, debounceMs]);
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
setSearchTerm('');
|
dispatch(clearFiltersAction());
|
||||||
setStatusFilter('all');
|
}, [dispatch]);
|
||||||
setPriorityFilter('all');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
|
currentPage,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
|
setCurrentPage,
|
||||||
getFilters,
|
getFilters,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
};
|
};
|
||||||
|
|||||||
52
src/pages/MyRequests/redux/myRequestsSlice.ts
Normal file
52
src/pages/MyRequests/redux/myRequestsSlice.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface MyRequestsFiltersState {
|
||||||
|
searchTerm: string;
|
||||||
|
statusFilter: string;
|
||||||
|
priorityFilter: string;
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: MyRequestsFiltersState = {
|
||||||
|
searchTerm: '',
|
||||||
|
statusFilter: 'all',
|
||||||
|
priorityFilter: 'all',
|
||||||
|
currentPage: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const myRequestsSlice = createSlice({
|
||||||
|
name: 'myRequests',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setSearchTerm: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchTerm = action.payload;
|
||||||
|
},
|
||||||
|
setStatusFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.statusFilter = action.payload;
|
||||||
|
},
|
||||||
|
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.priorityFilter = action.payload;
|
||||||
|
state.currentPage = 1; // Reset to page 1 when filter changes
|
||||||
|
},
|
||||||
|
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.currentPage = action.payload;
|
||||||
|
},
|
||||||
|
clearFilters: (state) => {
|
||||||
|
state.searchTerm = '';
|
||||||
|
state.statusFilter = 'all';
|
||||||
|
state.priorityFilter = 'all';
|
||||||
|
state.currentPage = 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setSearchTerm,
|
||||||
|
setStatusFilter,
|
||||||
|
setPriorityFilter,
|
||||||
|
setCurrentPage,
|
||||||
|
clearFilters,
|
||||||
|
} = myRequestsSlice.actions;
|
||||||
|
|
||||||
|
export default myRequestsSlice;
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useOpenRequestsFilters } from './hooks/useOpenRequestsFilters';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -7,14 +7,14 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, CheckCircle, XCircle } from 'lucide-react';
|
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, CheckCircle, XCircle, Lock } from 'lucide-react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
interface Request {
|
interface Request {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: 'pending' | 'approved' | 'rejected' | 'closed';
|
status: 'pending' | 'approved' | 'rejected' | 'closed' | 'paused';
|
||||||
priority: 'express' | 'standard';
|
priority: 'express' | 'standard';
|
||||||
initiator: { name: string; avatar: string };
|
initiator: { name: string; avatar: string };
|
||||||
currentApprover?: {
|
currentApprover?: {
|
||||||
@ -26,6 +26,8 @@ interface Request {
|
|||||||
approvalStep?: string;
|
approvalStep?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
currentLevelSLA?: any; // Backend-provided SLA for current level
|
currentLevelSLA?: any; // Backend-provided SLA for current level
|
||||||
|
isPaused?: boolean; // Pause status
|
||||||
|
pauseInfo?: any; // Pause details
|
||||||
}
|
}
|
||||||
|
|
||||||
interface OpenRequestsProps {
|
interface OpenRequestsProps {
|
||||||
@ -99,27 +101,18 @@ const getStatusConfig = (status: string) => {
|
|||||||
// getSLAUrgency removed - now using SLATracker component for real-time SLA display
|
// getSLAUrgency removed - now using SLATracker component for real-time SLA display
|
||||||
|
|
||||||
export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// Initialize filters from URL params
|
|
||||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
|
||||||
const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || 'all');
|
|
||||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all');
|
|
||||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>(
|
|
||||||
(searchParams.get('sortBy') as 'created' | 'due' | 'priority' | 'sla') || 'created'
|
|
||||||
);
|
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(
|
|
||||||
(searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc'
|
|
||||||
);
|
|
||||||
const [items, setItems] = useState<Request[]>([]);
|
const [items, setItems] = useState<Request[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
// Pagination states
|
// Pagination states (currentPage now in Redux)
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalRecords, setTotalRecords] = useState(0);
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
|
const fetchRequestsRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Use Redux for filters with callback (persists during navigation)
|
||||||
|
const filters = useOpenRequestsFilters();
|
||||||
|
|
||||||
// Fetch open requests for the current user only (user-scoped, not organization-wide)
|
// Fetch open requests for the current user only (user-scoped, not organization-wide)
|
||||||
// Note: This endpoint returns only requests where the user is:
|
// Note: This endpoint returns only requests where the user is:
|
||||||
@ -128,7 +121,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
// - An initiator (for approved requests awaiting closure)
|
// - An initiator (for approved requests awaiting closure)
|
||||||
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
|
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
|
||||||
// For organization-wide view, users should use the "All Requests" screen (/requests)
|
// For organization-wide view, users should use the "All Requests" screen (/requests)
|
||||||
const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
const fetchRequests = useCallback(async (page: number = 1, filterParams?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -141,11 +134,11 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
const result = await workflowApi.listOpenForMe({
|
const result = await workflowApi.listOpenForMe({
|
||||||
page,
|
page,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
search: filters?.search,
|
search: filterParams?.search,
|
||||||
status: filters?.status,
|
status: filterParams?.status,
|
||||||
priority: filters?.priority,
|
priority: filterParams?.priority,
|
||||||
sortBy: filters?.sortBy,
|
sortBy: filterParams?.sortBy,
|
||||||
sortOrder: filters?.sortOrder
|
sortOrder: filterParams?.sortOrder
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||||
@ -156,7 +149,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
// Set pagination data
|
// Set pagination data
|
||||||
const pagination = (result as any)?.pagination;
|
const pagination = (result as any)?.pagination;
|
||||||
if (pagination) {
|
if (pagination) {
|
||||||
setCurrentPage(pagination.page || 1);
|
filters.setCurrentPage(pagination.page || 1);
|
||||||
setTotalPages(pagination.totalPages || 1);
|
setTotalPages(pagination.totalPages || 1);
|
||||||
setTotalRecords(pagination.total || 0);
|
setTotalRecords(pagination.total || 0);
|
||||||
}
|
}
|
||||||
@ -192,28 +185,30 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
}, [itemsPerPage]);
|
}, [itemsPerPage, filters]);
|
||||||
|
|
||||||
|
fetchRequestsRef.current = fetchRequests;
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(currentPage, {
|
fetchRequests(filters.currentPage, {
|
||||||
search: searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder
|
sortOrder: filters.sortOrder
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
setCurrentPage(newPage);
|
filters.setCurrentPage(newPage);
|
||||||
fetchRequests(newPage, {
|
fetchRequests(newPage, {
|
||||||
search: searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder
|
sortOrder: filters.sortOrder
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -221,7 +216,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const maxPagesToShow = 5;
|
const maxPagesToShow = 5;
|
||||||
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
|
let startPage = Math.max(1, filters.currentPage - Math.floor(maxPagesToShow / 2));
|
||||||
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
|
||||||
|
|
||||||
if (endPage - startPage < maxPagesToShow - 1) {
|
if (endPage - startPage < maxPagesToShow - 1) {
|
||||||
@ -235,59 +230,50 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Track if this is the initial mount
|
// Track if this is initial mount
|
||||||
const isInitialMount = useRef(true);
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
// Initial fetch on mount with URL params
|
// Initial fetch on mount - use stored page from Redux
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialMount.current) {
|
if (!hasInitialFetchRun.current) {
|
||||||
isInitialMount.current = false;
|
hasInitialFetchRun.current = true;
|
||||||
fetchRequests(1, {
|
const storedPage = filters.currentPage || 1;
|
||||||
search: searchTerm || undefined,
|
fetchRequests(storedPage, {
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
search: filters.searchTerm || undefined,
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
sortBy,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
sortOrder
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // Only run on mount to use URL params
|
}, []); // Only on mount
|
||||||
|
|
||||||
// Fetch when filters or sorting change (with debouncing for search)
|
// Track filter changes and refetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip initial mount to avoid double fetch
|
// Skip until initial fetch has completed
|
||||||
if (isInitialMount.current) return;
|
if (!hasInitialFetchRun.current) return;
|
||||||
|
|
||||||
// Debounce search: wait 500ms after user stops typing
|
// Debounce search
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setCurrentPage(1); // Reset to page 1 when filters change
|
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
fetchRequests(1, {
|
fetchRequests(1, {
|
||||||
search: searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
}, filters.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.sortBy, filters.sortOrder]);
|
||||||
|
|
||||||
// Backend handles both filtering and sorting - use items directly
|
// Backend handles both filtering and sorting - use items directly
|
||||||
// No client-side sorting needed anymore
|
// No client-side sorting needed anymore
|
||||||
const filteredAndSortedRequests = items;
|
const filteredAndSortedRequests = items;
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setSearchTerm('');
|
|
||||||
setPriorityFilter('all');
|
|
||||||
setStatusFilter('all');
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeFiltersCount = [
|
|
||||||
searchTerm,
|
|
||||||
priorityFilter !== 'all' ? priorityFilter : null,
|
|
||||||
statusFilter !== 'all' ? statusFilter : null
|
|
||||||
].filter(Boolean).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto">
|
||||||
@ -334,19 +320,19 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
<div>
|
<div>
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
<CardDescription className="text-xs sm:text-sm">
|
||||||
{activeFiltersCount > 0 && (
|
{filters.activeFiltersCount > 0 && (
|
||||||
<span className="text-blue-600 font-medium">
|
<span className="text-blue-600 font-medium">
|
||||||
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
|
{filters.activeFiltersCount} filter{filters.activeFiltersCount > 1 ? 's' : ''} active
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeFiltersCount > 0 && (
|
{filters.activeFiltersCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={clearFilters}
|
onClick={filters.clearFilters}
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||||
@ -362,13 +348,13 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search requests, IDs..."
|
placeholder="Search requests, IDs..."
|
||||||
value={searchTerm}
|
value={filters.searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => filters.setSearchTerm(e.target.value)}
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
<Select value={filters.priorityFilter} onValueChange={filters.setPriorityFilter}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="All Priorities" />
|
<SelectValue placeholder="All Priorities" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -389,7 +375,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<SelectValue placeholder="All Statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -401,7 +387,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
<Select value={filters.sortBy} onValueChange={(value: any) => filters.setSortBy(value)}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Sort by" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -416,10 +402,10 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
onClick={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
||||||
>
|
>
|
||||||
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
{filters.sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -481,34 +467,55 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
|
|
||||||
{/* SLA Display - Compact Version */}
|
{/* SLA Display - Compact Version */}
|
||||||
{request.currentLevelSLA && (() => {
|
{request.currentLevelSLA && (() => {
|
||||||
|
// Check pause status from isPaused field, pauseInfo, OR status field
|
||||||
|
const isPaused = Boolean(
|
||||||
|
request.isPaused ||
|
||||||
|
request.pauseInfo?.isPaused ||
|
||||||
|
request.status === 'paused'
|
||||||
|
);
|
||||||
// Use percentage-based colors to match approver SLA tracker
|
// Use percentage-based colors to match approver SLA tracker
|
||||||
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||||
|
// Grey: When paused (frozen state)
|
||||||
const percentUsed = request.currentLevelSLA.percentageUsed || 0;
|
const percentUsed = request.currentLevelSLA.percentageUsed || 0;
|
||||||
|
|
||||||
const getSLAColors = () => {
|
const getSLAColors = () => {
|
||||||
|
// If paused, always use grey colors (frozen state)
|
||||||
|
if (isPaused) {
|
||||||
|
return {
|
||||||
|
bg: 'bg-gray-100 border border-gray-300',
|
||||||
|
progress: 'bg-gray-500',
|
||||||
|
text: 'text-gray-600',
|
||||||
|
icon: 'text-gray-600'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (percentUsed >= 100) {
|
if (percentUsed >= 100) {
|
||||||
return {
|
return {
|
||||||
bg: 'bg-red-50 border border-red-200',
|
bg: 'bg-red-50 border border-red-200',
|
||||||
progress: 'bg-red-600',
|
progress: 'bg-red-600',
|
||||||
text: 'text-red-600'
|
text: 'text-red-600',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
} else if (percentUsed >= 75) {
|
} else if (percentUsed >= 75) {
|
||||||
return {
|
return {
|
||||||
bg: 'bg-orange-50 border border-orange-200',
|
bg: 'bg-orange-50 border border-orange-200',
|
||||||
progress: 'bg-orange-500',
|
progress: 'bg-orange-500',
|
||||||
text: 'text-orange-600'
|
text: 'text-orange-600',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
} else if (percentUsed >= 50) {
|
} else if (percentUsed >= 50) {
|
||||||
return {
|
return {
|
||||||
bg: 'bg-amber-50 border border-amber-200',
|
bg: 'bg-amber-50 border border-amber-200',
|
||||||
progress: 'bg-amber-500',
|
progress: 'bg-amber-500',
|
||||||
text: 'text-amber-600'
|
text: 'text-amber-600',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
bg: 'bg-green-50 border border-green-200',
|
bg: 'bg-green-50 border border-green-200',
|
||||||
progress: 'bg-green-600',
|
progress: 'bg-green-600',
|
||||||
text: 'text-gray-700'
|
text: 'text-gray-700',
|
||||||
|
icon: 'text-blue-600'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -519,17 +526,18 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
<div className={`p-2 rounded-md ${colors.bg}`}>
|
<div className={`p-2 rounded-md ${colors.bg}`}>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5 text-gray-600" />
|
{isPaused ? (
|
||||||
<span className="text-xs font-medium text-gray-900">TAT: {percentUsed}%</span>
|
<Lock className={`w-3.5 h-3.5 ${colors.icon}`} />
|
||||||
|
) : (
|
||||||
|
<Clock className={`w-3.5 h-3.5 ${colors.icon}`} />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-gray-900">
|
||||||
|
TAT: {percentUsed}% {isPaused && '(paused)'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="text-gray-600">{request.currentLevelSLA.elapsedText}</span>
|
<span className="text-gray-600">{request.currentLevelSLA.elapsedText}</span>
|
||||||
<span className={`font-semibold ${
|
<span className={`font-semibold ${colors.text}`}>
|
||||||
percentUsed >= 100 ? 'text-red-600' :
|
|
||||||
percentUsed >= 75 ? 'text-orange-600' :
|
|
||||||
percentUsed >= 50 ? 'text-amber-600' :
|
|
||||||
'text-gray-700'
|
|
||||||
}`}>
|
|
||||||
{request.currentLevelSLA.remainingText} left
|
{request.currentLevelSLA.remainingText} left
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -599,16 +607,16 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">No requests found</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No requests found</h3>
|
||||||
<p className="text-gray-600 text-center max-w-md">
|
<p className="text-gray-600 text-center max-w-md">
|
||||||
{searchTerm || activeFiltersCount > 0
|
{filters.searchTerm || filters.activeFiltersCount > 0
|
||||||
? 'Try adjusting your filters or search terms to see more results.'
|
? 'Try adjusting your filters or search terms to see more results.'
|
||||||
: 'No open requests available at the moment.'
|
: 'No open requests available at the moment.'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
{activeFiltersCount > 0 && (
|
{filters.activeFiltersCount > 0 && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
onClick={clearFilters}
|
onClick={filters.clearFilters}
|
||||||
>
|
>
|
||||||
Clear all filters
|
Clear all filters
|
||||||
</Button>
|
</Button>
|
||||||
@ -623,21 +631,21 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
<div className="text-xs sm:text-sm text-muted-foreground">
|
<div className="text-xs sm:text-sm text-muted-foreground">
|
||||||
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} open requests
|
Showing {((filters.currentPage - 1) * itemsPerPage) + 1} to {Math.min(filters.currentPage * itemsPerPage, totalRecords)} of {totalRecords} open requests
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage - 1)}
|
onClick={() => handlePageChange(filters.currentPage - 1)}
|
||||||
disabled={currentPage === 1}
|
disabled={filters.currentPage === 1}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4 rotate-180" />
|
<ArrowRight className="h-4 w-4 rotate-180" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{currentPage > 3 && totalPages > 5 && (
|
{filters.currentPage > 3 && totalPages > 5 && (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} className="h-8 w-8 p-0">1</Button>
|
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} className="h-8 w-8 p-0">1</Button>
|
||||||
<span className="text-muted-foreground">...</span>
|
<span className="text-muted-foreground">...</span>
|
||||||
@ -647,16 +655,16 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
{getPageNumbers().map((pageNum) => (
|
{getPageNumbers().map((pageNum) => (
|
||||||
<Button
|
<Button
|
||||||
key={pageNum}
|
key={pageNum}
|
||||||
variant={pageNum === currentPage ? "default" : "outline"}
|
variant={pageNum === filters.currentPage ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
className={`h-8 w-8 p-0 ${pageNum === filters.currentPage ? 'bg-re-green text-white hover:bg-re-green/90' : ''}`}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{currentPage < totalPages - 2 && totalPages > 5 && (
|
{filters.currentPage < totalPages - 2 && totalPages > 5 && (
|
||||||
<>
|
<>
|
||||||
<span className="text-muted-foreground">...</span>
|
<span className="text-muted-foreground">...</span>
|
||||||
<Button variant="outline" size="sm" onClick={() => handlePageChange(totalPages)} className="h-8 w-8 p-0">{totalPages}</Button>
|
<Button variant="outline" size="sm" onClick={() => handlePageChange(totalPages)} className="h-8 w-8 p-0">{totalPages}</Button>
|
||||||
@ -666,8 +674,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(currentPage + 1)}
|
onClick={() => handlePageChange(filters.currentPage + 1)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={filters.currentPage === totalPages}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0"
|
||||||
>
|
>
|
||||||
<ArrowRight className="h-4 w-4" />
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
|||||||
55
src/pages/OpenRequests/hooks/useOpenRequestsFilters.ts
Normal file
55
src/pages/OpenRequests/hooks/useOpenRequestsFilters.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
/**
|
||||||
|
* Hook for managing Open Requests filters and sorting using Redux
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||||
|
import {
|
||||||
|
setSearchTerm as setSearchTermAction,
|
||||||
|
setStatusFilter as setStatusFilterAction,
|
||||||
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
|
setSortBy as setSortByAction,
|
||||||
|
setSortOrder as setSortOrderAction,
|
||||||
|
setCurrentPage as setCurrentPageAction,
|
||||||
|
clearFilters as clearFiltersAction,
|
||||||
|
} from '../redux/openRequestsSlice';
|
||||||
|
|
||||||
|
export function useOpenRequestsFilters() {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get filters from Redux
|
||||||
|
const { searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.openRequests);
|
||||||
|
|
||||||
|
// Create setters that dispatch Redux actions
|
||||||
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
|
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
||||||
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
|
const setSortBy = useCallback((value: 'created' | 'due' | 'priority' | 'sla') => dispatch(setSortByAction(value)), [dispatch]);
|
||||||
|
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
|
||||||
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
|
const clearFilters = useCallback(() => dispatch(clearFiltersAction()), [dispatch]);
|
||||||
|
|
||||||
|
const activeFiltersCount = [
|
||||||
|
searchTerm,
|
||||||
|
priorityFilter !== 'all' ? priorityFilter : null,
|
||||||
|
statusFilter !== 'all' ? statusFilter : null
|
||||||
|
].filter(Boolean).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
searchTerm,
|
||||||
|
statusFilter,
|
||||||
|
priorityFilter,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
currentPage,
|
||||||
|
setSearchTerm,
|
||||||
|
setStatusFilter,
|
||||||
|
setPriorityFilter,
|
||||||
|
setSortBy,
|
||||||
|
setSortOrder,
|
||||||
|
setCurrentPage,
|
||||||
|
clearFilters,
|
||||||
|
activeFiltersCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
63
src/pages/OpenRequests/redux/openRequestsSlice.ts
Normal file
63
src/pages/OpenRequests/redux/openRequestsSlice.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
export interface OpenRequestsFiltersState {
|
||||||
|
searchTerm: string;
|
||||||
|
statusFilter: string;
|
||||||
|
priorityFilter: string;
|
||||||
|
sortBy: 'created' | 'due' | 'priority' | 'sla';
|
||||||
|
sortOrder: 'asc' | 'desc';
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: OpenRequestsFiltersState = {
|
||||||
|
searchTerm: '',
|
||||||
|
statusFilter: 'all',
|
||||||
|
priorityFilter: 'all',
|
||||||
|
sortBy: 'created',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
currentPage: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRequestsSlice = createSlice({
|
||||||
|
name: 'openRequests',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setSearchTerm: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchTerm = action.payload;
|
||||||
|
},
|
||||||
|
setStatusFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.statusFilter = action.payload;
|
||||||
|
},
|
||||||
|
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.priorityFilter = action.payload;
|
||||||
|
},
|
||||||
|
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority' | 'sla'>) => {
|
||||||
|
state.sortBy = action.payload;
|
||||||
|
},
|
||||||
|
setSortOrder: (state, action: PayloadAction<'asc' | 'desc'>) => {
|
||||||
|
state.sortOrder = action.payload;
|
||||||
|
},
|
||||||
|
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.currentPage = action.payload;
|
||||||
|
},
|
||||||
|
clearFilters: (state) => {
|
||||||
|
state.searchTerm = '';
|
||||||
|
state.statusFilter = 'all';
|
||||||
|
state.priorityFilter = 'all';
|
||||||
|
state.currentPage = 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setSearchTerm,
|
||||||
|
setStatusFilter,
|
||||||
|
setPriorityFilter,
|
||||||
|
setSortBy,
|
||||||
|
setSortOrder,
|
||||||
|
setCurrentPage,
|
||||||
|
clearFilters,
|
||||||
|
} = openRequestsSlice.actions;
|
||||||
|
|
||||||
|
export default openRequestsSlice;
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
|
|||||||
// Components
|
// Components
|
||||||
import { RequestDetailHeader } from './components/RequestDetailHeader';
|
import { RequestDetailHeader } from './components/RequestDetailHeader';
|
||||||
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
||||||
import { getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
|
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { OverviewTab } from './components/tabs/OverviewTab';
|
import { OverviewTab } from './components/tabs/OverviewTab';
|
||||||
import { WorkflowTab } from './components/tabs/WorkflowTab';
|
import { WorkflowTab } from './components/tabs/WorkflowTab';
|
||||||
@ -257,7 +257,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
setLoadingSummary(true);
|
setLoadingSummary(true);
|
||||||
// Just fetch the summary by requestId - don't try to create it
|
// Just fetch the summary by requestId - don't try to create it
|
||||||
// Summary is auto-created by backend on final approval/rejection
|
// Summary is auto-created by backend on final approval/rejection
|
||||||
const { getSummaryByRequestId } = await import('@/services/summaryApi');
|
|
||||||
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
||||||
|
|
||||||
if (summary?.summaryId) {
|
if (summary?.summaryId) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
||||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import notificationApi, { type Notification } from '@/services/notificationApi';
|
||||||
|
|
||||||
interface QuickActionsSidebarProps {
|
interface QuickActionsSidebarProps {
|
||||||
request: any;
|
request: any;
|
||||||
@ -46,6 +47,7 @@ export function QuickActionsSidebar({
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||||
|
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
||||||
const isClosed = request?.status === 'closed';
|
const isClosed = request?.status === 'closed';
|
||||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||||
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId;
|
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId;
|
||||||
@ -60,6 +62,38 @@ export function QuickActionsSidebar({
|
|||||||
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
||||||
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
||||||
|
|
||||||
|
// Check for retrigger notification (initiator requested resume)
|
||||||
|
// ONLY check when: 1) Request is paused, 2) Current user is an approver
|
||||||
|
// This avoids unnecessary API calls for non-paused requests or initiators
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip check if request is not paused or user is not an approver
|
||||||
|
if (!isPaused || !currentApprovalLevel || !request?.requestId) {
|
||||||
|
setHasRetriggerNotification(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkRetriggerNotification = async () => {
|
||||||
|
try {
|
||||||
|
const response = await notificationApi.list({ page: 1, limit: 50, unreadOnly: true }); // Only unread
|
||||||
|
const notifications: Notification[] = response.data?.notifications || [];
|
||||||
|
|
||||||
|
// Check if there's an UNREAD pause_retrigger_request notification for this request
|
||||||
|
const hasRetrigger = notifications.some(
|
||||||
|
(notif: Notification) =>
|
||||||
|
notif.requestId === request.requestId &&
|
||||||
|
notif.notificationType === 'pause_retrigger_request'
|
||||||
|
);
|
||||||
|
|
||||||
|
setHasRetriggerNotification(hasRetrigger);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check retrigger notifications:', error);
|
||||||
|
setHasRetriggerNotification(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkRetriggerNotification();
|
||||||
|
}, [isPaused, currentApprovalLevel, request?.requestId, refreshTrigger]); // Only when paused state or approver status changes
|
||||||
|
|
||||||
// Fetch shared recipients when request is closed and summaryId is available
|
// Fetch shared recipients when request is closed and summaryId is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchSharedRecipients = async () => {
|
const fetchSharedRecipients = async () => {
|
||||||
@ -180,8 +214,48 @@ export function QuickActionsSidebar({
|
|||||||
)}
|
)}
|
||||||
{isPaused && (
|
{isPaused && (
|
||||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 text-center">
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 text-center">
|
||||||
|
{/* Different messages based on who paused, who is viewing, and if retrigger was sent */}
|
||||||
|
{pausedByUserId === currentUserId ? (
|
||||||
|
// User viewing is the one who paused
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-orange-800 font-medium flex items-center justify-center gap-1.5">
|
||||||
|
{hasRetriggerNotification && <AlertCircle className="w-3.5 h-3.5" />}
|
||||||
|
{hasRetriggerNotification ? 'Initiator has requested you to resume' : 'You paused this workflow'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">
|
||||||
|
{hasRetriggerNotification ? 'Please review and resume if appropriate' : 'Click "Resume Workflow" to continue'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : currentApprovalLevel && pausedByUserId !== currentUserId && hasRetriggerNotification ? (
|
||||||
|
// Approver viewing, and initiator sent retrigger
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-orange-800 font-medium flex items-center justify-center gap-1.5">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5" />
|
||||||
|
Initiator has requested resume
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">Please review and resume if appropriate</p>
|
||||||
|
</>
|
||||||
|
) : currentApprovalLevel && pausedByUserId !== currentUserId ? (
|
||||||
|
// Approver viewing but someone else paused (initiator or another approver)
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">You can resume to continue approval</p>
|
||||||
|
</>
|
||||||
|
) : isInitiator && pausedByUserId && pausedByUserId !== currentUserId ? (
|
||||||
|
// Initiator viewing, approver paused
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-orange-800 font-medium">Approver has paused this workflow</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">
|
||||||
|
{canRetrigger ? 'Click "Request Resume" to notify approver' : 'Resume request sent - Waiting for approver'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Default message
|
||||||
|
<>
|
||||||
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
|
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
|
||||||
<p className="text-xs text-orange-600 mt-1">Actions are disabled until resumed</p>
|
<p className="text-xs text-orange-600 mt-1">Actions are disabled until resumed</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ interface RequestDetailHeaderProps {
|
|||||||
export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, onShareSummary, isInitiator }: RequestDetailHeaderProps) {
|
export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, onShareSummary, isInitiator }: RequestDetailHeaderProps) {
|
||||||
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
|
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
|
||||||
const statusConfig = getStatusConfig(request?.status || 'pending');
|
const statusConfig = getStatusConfig(request?.status || 'pending');
|
||||||
|
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
|
||||||
@ -109,8 +110,15 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SLA Progress Section */}
|
{/* SLA Progress Section */}
|
||||||
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200" data-testid="sla-section">
|
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
|
||||||
<SLAProgressBar sla={request.summary?.sla || request.sla} requestStatus={request.status} testId="request-sla" />
|
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
||||||
|
}`} data-testid="sla-section">
|
||||||
|
<SLAProgressBar
|
||||||
|
sla={request.summary?.sla || request.sla}
|
||||||
|
requestStatus={request.status}
|
||||||
|
isPaused={isPaused}
|
||||||
|
testId="request-sla"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -71,8 +71,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination (currentPage now in Redux)
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalRecords, setTotalRecords] = useState(0);
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
@ -270,7 +269,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Note: Stats come from backend stats API (always unfiltered), not from allData
|
// Note: Stats come from backend stats API (always unfiltered), not from allData
|
||||||
|
|
||||||
// Update pagination
|
// Update pagination
|
||||||
setCurrentPage(result.pagination.page);
|
filters.setCurrentPage(result.pagination.page);
|
||||||
setTotalPages(result.pagination.totalPages);
|
setTotalPages(result.pagination.totalPages);
|
||||||
setTotalRecords(result.pagination.total);
|
setTotalRecords(result.pagination.total);
|
||||||
|
|
||||||
@ -347,18 +346,76 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch requests on mount and when filters change
|
// Track previous filter values to detect changes
|
||||||
// Also refetch when isOrgLevel changes (when admin toggles between Org/Personal in Dashboard)
|
const prevFiltersRef = useRef({
|
||||||
|
searchTerm: filters.searchTerm,
|
||||||
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
slaComplianceFilter: filters.slaComplianceFilter,
|
||||||
|
departmentFilter: filters.departmentFilter,
|
||||||
|
initiatorFilter: filters.initiatorFilter,
|
||||||
|
approverFilter: filters.approverFilter,
|
||||||
|
approverFilterType: filters.approverFilterType,
|
||||||
|
dateRange: filters.dateRange,
|
||||||
|
customStartDate: filters.customStartDate,
|
||||||
|
customEndDate: filters.customEndDate,
|
||||||
|
isOrgLevel,
|
||||||
|
});
|
||||||
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
|
// Initial fetch on mount - use stored page from Redux
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const storedPage = filters.currentPage || 1;
|
||||||
|
fetchRequests(storedPage);
|
||||||
|
hasInitialFetchRun.current = true;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Only on mount
|
||||||
|
|
||||||
|
// Fetch when filters change or isOrgLevel changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialFetchRun.current) return;
|
||||||
|
|
||||||
|
const prev = prevFiltersRef.current;
|
||||||
|
const hasChanged =
|
||||||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
|
prev.slaComplianceFilter !== filters.slaComplianceFilter ||
|
||||||
|
prev.departmentFilter !== filters.departmentFilter ||
|
||||||
|
prev.initiatorFilter !== filters.initiatorFilter ||
|
||||||
|
prev.approverFilter !== filters.approverFilter ||
|
||||||
|
prev.approverFilterType !== filters.approverFilterType ||
|
||||||
|
prev.dateRange !== filters.dateRange ||
|
||||||
|
prev.customStartDate !== filters.customStartDate ||
|
||||||
|
prev.customEndDate !== filters.customEndDate ||
|
||||||
|
prev.isOrgLevel !== isOrgLevel;
|
||||||
|
|
||||||
|
if (!hasChanged) return;
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setCurrentPage(1);
|
filters.setCurrentPage(1);
|
||||||
fetchRequests(1);
|
fetchRequests(1);
|
||||||
}, filters.searchTerm ? 500 : 0);
|
|
||||||
|
prevFiltersRef.current = {
|
||||||
|
searchTerm: filters.searchTerm,
|
||||||
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
slaComplianceFilter: filters.slaComplianceFilter,
|
||||||
|
departmentFilter: filters.departmentFilter,
|
||||||
|
initiatorFilter: filters.initiatorFilter,
|
||||||
|
approverFilter: filters.approverFilter,
|
||||||
|
approverFilterType: filters.approverFilterType,
|
||||||
|
dateRange: filters.dateRange,
|
||||||
|
customStartDate: filters.customStartDate,
|
||||||
|
customEndDate: filters.customEndDate,
|
||||||
|
isOrgLevel,
|
||||||
|
};
|
||||||
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
isOrgLevel, // Re-fetch when org/personal toggle changes
|
isOrgLevel,
|
||||||
filters.searchTerm,
|
filters.searchTerm,
|
||||||
filters.statusFilter,
|
filters.statusFilter,
|
||||||
filters.priorityFilter,
|
filters.priorityFilter,
|
||||||
@ -370,16 +427,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
filters.dateRange,
|
filters.dateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate
|
filters.customEndDate
|
||||||
// fetchRequests excluded to prevent infinite loops
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
const handlePageChange = useCallback((newPage: number) => {
|
const handlePageChange = useCallback((newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
setCurrentPage(newPage);
|
filters.setCurrentPage(newPage);
|
||||||
fetchRequests(newPage);
|
fetchRequests(newPage);
|
||||||
}
|
}
|
||||||
}, [totalPages, fetchRequests]);
|
}, [totalPages, fetchRequests, filters]);
|
||||||
|
|
||||||
// Transform requests
|
// Transform requests
|
||||||
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
||||||
@ -768,7 +824,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={filters.currentPage || 1}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={totalRecords}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
|
|||||||
@ -57,8 +57,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
||||||
|
|
||||||
// Pagination
|
// Pagination (currentPage now in Redux)
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalRecords, setTotalRecords] = useState(0);
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
@ -181,7 +180,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
setApiRequests(result.data); // Paginated data (10 records)
|
setApiRequests(result.data); // Paginated data (10 records)
|
||||||
|
|
||||||
// Update pagination
|
// Update pagination
|
||||||
setCurrentPage(result.pagination.page);
|
filters.setCurrentPage(result.pagination.page);
|
||||||
setTotalPages(result.pagination.totalPages);
|
setTotalPages(result.pagination.totalPages);
|
||||||
setTotalRecords(result.pagination.total);
|
setTotalRecords(result.pagination.total);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -250,12 +249,68 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch requests on mount and when filters change (for list display)
|
// Track previous filter values to detect changes
|
||||||
|
const prevFiltersRef = useRef({
|
||||||
|
searchTerm: filters.searchTerm,
|
||||||
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
slaComplianceFilter: filters.slaComplianceFilter,
|
||||||
|
departmentFilter: filters.departmentFilter,
|
||||||
|
initiatorFilter: filters.initiatorFilter,
|
||||||
|
approverFilter: filters.approverFilter,
|
||||||
|
approverFilterType: filters.approverFilterType,
|
||||||
|
dateRange: filters.dateRange,
|
||||||
|
customStartDate: filters.customStartDate,
|
||||||
|
customEndDate: filters.customEndDate,
|
||||||
|
});
|
||||||
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
|
// Initial fetch on mount - use stored page from Redux
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const storedPage = filters.currentPage || 1;
|
||||||
|
fetchRequests(storedPage);
|
||||||
|
hasInitialFetchRun.current = true;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []); // Only on mount
|
||||||
|
|
||||||
|
// Fetch when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasInitialFetchRun.current) return;
|
||||||
|
|
||||||
|
const prev = prevFiltersRef.current;
|
||||||
|
const hasChanged =
|
||||||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
|
prev.slaComplianceFilter !== filters.slaComplianceFilter ||
|
||||||
|
prev.departmentFilter !== filters.departmentFilter ||
|
||||||
|
prev.initiatorFilter !== filters.initiatorFilter ||
|
||||||
|
prev.approverFilter !== filters.approverFilter ||
|
||||||
|
prev.approverFilterType !== filters.approverFilterType ||
|
||||||
|
prev.dateRange !== filters.dateRange ||
|
||||||
|
prev.customStartDate !== filters.customStartDate ||
|
||||||
|
prev.customEndDate !== filters.customEndDate;
|
||||||
|
|
||||||
|
if (!hasChanged) return;
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setCurrentPage(1);
|
filters.setCurrentPage(1);
|
||||||
fetchRequests(1);
|
fetchRequests(1);
|
||||||
}, filters.searchTerm ? 500 : 0);
|
|
||||||
|
prevFiltersRef.current = {
|
||||||
|
searchTerm: filters.searchTerm,
|
||||||
|
statusFilter: filters.statusFilter,
|
||||||
|
priorityFilter: filters.priorityFilter,
|
||||||
|
slaComplianceFilter: filters.slaComplianceFilter,
|
||||||
|
departmentFilter: filters.departmentFilter,
|
||||||
|
initiatorFilter: filters.initiatorFilter,
|
||||||
|
approverFilter: filters.approverFilter,
|
||||||
|
approverFilterType: filters.approverFilterType,
|
||||||
|
dateRange: filters.dateRange,
|
||||||
|
customStartDate: filters.customStartDate,
|
||||||
|
customEndDate: filters.customEndDate,
|
||||||
|
};
|
||||||
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -271,16 +326,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
filters.dateRange,
|
filters.dateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate
|
filters.customEndDate
|
||||||
// fetchRequests excluded to prevent infinite loops
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
const handlePageChange = useCallback((newPage: number) => {
|
const handlePageChange = useCallback((newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
setCurrentPage(newPage);
|
filters.setCurrentPage(newPage);
|
||||||
fetchRequests(newPage);
|
fetchRequests(newPage);
|
||||||
}
|
}
|
||||||
}, [totalPages, fetchRequests]);
|
}, [totalPages, fetchRequests, filters]);
|
||||||
|
|
||||||
// Transform requests
|
// Transform requests
|
||||||
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
||||||
@ -688,7 +742,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={filters.currentPage || 1}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecords}
|
totalRecords={totalRecords}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
|
|||||||
@ -1,55 +1,34 @@
|
|||||||
/**
|
/**
|
||||||
* Hook for managing Requests filters
|
* Hook for managing Requests filters using Redux
|
||||||
|
* Filters persist in Redux (in-memory) during navigation, but reset on page refresh
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
import type { RequestFilters } from '../types/requests.types';
|
import type { RequestFilters } from '../types/requests.types';
|
||||||
|
import {
|
||||||
|
setSearchTerm as setSearchTermAction,
|
||||||
|
setStatusFilter as setStatusFilterAction,
|
||||||
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
|
setSlaComplianceFilter as setSlaComplianceFilterAction,
|
||||||
|
setDepartmentFilter as setDepartmentFilterAction,
|
||||||
|
setInitiatorFilter as setInitiatorFilterAction,
|
||||||
|
setApproverFilter as setApproverFilterAction,
|
||||||
|
setApproverFilterType as setApproverFilterTypeAction,
|
||||||
|
setDateRange as setDateRangeAction,
|
||||||
|
setCustomStartDate as setCustomStartDateAction,
|
||||||
|
setCustomEndDate as setCustomEndDateAction,
|
||||||
|
setShowCustomDatePicker as setShowCustomDatePickerAction,
|
||||||
|
setCurrentPage as setCurrentPageAction,
|
||||||
|
clearFilters as clearFiltersAction,
|
||||||
|
} from '../redux/requestsSlice';
|
||||||
|
|
||||||
export function useRequestsFilters() {
|
export function useRequestsFilters() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState(searchParams.get('search') || '');
|
// Get all filter state from Redux
|
||||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || 'all');
|
const {
|
||||||
const [priorityFilter, setPriorityFilter] = useState(searchParams.get('priority') || 'all');
|
|
||||||
const [slaComplianceFilter, setSlaComplianceFilter] = useState(searchParams.get('slaCompliance') || 'all');
|
|
||||||
const [departmentFilter, setDepartmentFilter] = useState(searchParams.get('department') || 'all');
|
|
||||||
const [initiatorFilter, setInitiatorFilter] = useState(searchParams.get('initiator') || 'all');
|
|
||||||
const [approverFilter, setApproverFilter] = useState(searchParams.get('approver') || 'all');
|
|
||||||
const [approverFilterType, setApproverFilterType] = useState<'current' | 'any'>(
|
|
||||||
searchParams.get('approverType') === 'any' ? 'any' : 'current'
|
|
||||||
);
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'all');
|
|
||||||
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
|
|
||||||
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
|
|
||||||
);
|
|
||||||
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
|
|
||||||
searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined
|
|
||||||
);
|
|
||||||
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
|
|
||||||
|
|
||||||
// Update URL params when filters change
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (searchTerm) params.set('search', searchTerm);
|
|
||||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
|
||||||
if (priorityFilter !== 'all') params.set('priority', priorityFilter);
|
|
||||||
if (slaComplianceFilter !== 'all') params.set('slaCompliance', slaComplianceFilter);
|
|
||||||
if (departmentFilter !== 'all') params.set('department', departmentFilter);
|
|
||||||
if (initiatorFilter !== 'all') {
|
|
||||||
params.set('initiator', initiatorFilter);
|
|
||||||
}
|
|
||||||
if (approverFilter !== 'all') {
|
|
||||||
params.set('approver', approverFilter);
|
|
||||||
params.set('approverType', approverFilterType);
|
|
||||||
}
|
|
||||||
if (dateRange) params.set('dateRange', dateRange);
|
|
||||||
if (customStartDate) params.set('startDate', customStartDate.toISOString());
|
|
||||||
if (customEndDate) params.set('endDate', customEndDate.toISOString());
|
|
||||||
|
|
||||||
setSearchParams(params, { replace: true });
|
|
||||||
}, [
|
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
@ -61,8 +40,24 @@ export function useRequestsFilters() {
|
|||||||
dateRange,
|
dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
setSearchParams
|
showCustomDatePicker,
|
||||||
]);
|
currentPage,
|
||||||
|
} = useAppSelector((state) => state.requests);
|
||||||
|
|
||||||
|
// Create setters that dispatch Redux actions
|
||||||
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
|
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
||||||
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
|
const setSlaComplianceFilter = useCallback((value: string) => dispatch(setSlaComplianceFilterAction(value)), [dispatch]);
|
||||||
|
const setDepartmentFilter = useCallback((value: string) => dispatch(setDepartmentFilterAction(value)), [dispatch]);
|
||||||
|
const setInitiatorFilter = useCallback((value: string) => dispatch(setInitiatorFilterAction(value)), [dispatch]);
|
||||||
|
const setApproverFilter = useCallback((value: string) => dispatch(setApproverFilterAction(value)), [dispatch]);
|
||||||
|
const setApproverFilterType = useCallback((value: 'current' | 'any') => dispatch(setApproverFilterTypeAction(value)), [dispatch]);
|
||||||
|
const setDateRange = useCallback((value: DateRange) => dispatch(setDateRangeAction(value)), [dispatch]);
|
||||||
|
const setCustomStartDate = useCallback((value: Date | undefined) => dispatch(setCustomStartDateAction(value)), [dispatch]);
|
||||||
|
const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]);
|
||||||
|
const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]);
|
||||||
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
|
|
||||||
const getFilters = useCallback((): RequestFilters => {
|
const getFilters = useCallback((): RequestFilters => {
|
||||||
return {
|
return {
|
||||||
@ -93,42 +88,31 @@ export function useRequestsFilters() {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
setSearchTerm('');
|
dispatch(clearFiltersAction());
|
||||||
setStatusFilter('all');
|
}, [dispatch]);
|
||||||
setPriorityFilter('all');
|
|
||||||
setSlaComplianceFilter('all');
|
|
||||||
setDepartmentFilter('all');
|
|
||||||
setInitiatorFilter('all');
|
|
||||||
setApproverFilter('all');
|
|
||||||
setApproverFilterType('current');
|
|
||||||
setDateRange('all');
|
|
||||||
setCustomStartDate(undefined);
|
|
||||||
setCustomEndDate(undefined);
|
|
||||||
setShowCustomDatePicker(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleDateRangeChange = useCallback((value: string) => {
|
const handleDateRangeChange = useCallback((value: string) => {
|
||||||
const newRange = value as DateRange;
|
const newRange = value as DateRange;
|
||||||
setDateRange(newRange);
|
dispatch(setDateRangeAction(newRange));
|
||||||
if (newRange !== 'custom') {
|
if (newRange !== 'custom') {
|
||||||
setCustomStartDate(undefined);
|
dispatch(setCustomStartDateAction(undefined));
|
||||||
setCustomEndDate(undefined);
|
dispatch(setCustomEndDateAction(undefined));
|
||||||
setShowCustomDatePicker(false);
|
dispatch(setShowCustomDatePickerAction(false));
|
||||||
} else {
|
} else {
|
||||||
setShowCustomDatePicker(true);
|
dispatch(setShowCustomDatePickerAction(true));
|
||||||
}
|
}
|
||||||
}, []);
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleApplyCustomDate = useCallback(() => {
|
const handleApplyCustomDate = useCallback(() => {
|
||||||
if (customStartDate && customEndDate) {
|
if (customStartDate && customEndDate) {
|
||||||
if (customStartDate > customEndDate) {
|
if (customStartDate > customEndDate) {
|
||||||
const temp = customStartDate;
|
// Swap dates if start is after end
|
||||||
setCustomStartDate(customEndDate);
|
dispatch(setCustomStartDateAction(customEndDate));
|
||||||
setCustomEndDate(temp);
|
dispatch(setCustomEndDateAction(customStartDate));
|
||||||
}
|
}
|
||||||
setShowCustomDatePicker(false);
|
dispatch(setShowCustomDatePickerAction(false));
|
||||||
}
|
}
|
||||||
}, [customStartDate, customEndDate]);
|
}, [customStartDate, customEndDate, dispatch]);
|
||||||
|
|
||||||
const hasActiveFilters: boolean = !!(
|
const hasActiveFilters: boolean = !!(
|
||||||
searchTerm ||
|
searchTerm ||
|
||||||
@ -157,6 +141,7 @@ export function useRequestsFilters() {
|
|||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
showCustomDatePicker,
|
showCustomDatePicker,
|
||||||
|
currentPage,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
// Setters
|
// Setters
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
@ -171,6 +156,7 @@ export function useRequestsFilters() {
|
|||||||
setCustomStartDate,
|
setCustomStartDate,
|
||||||
setCustomEndDate,
|
setCustomEndDate,
|
||||||
setShowCustomDatePicker,
|
setShowCustomDatePicker,
|
||||||
|
setCurrentPage,
|
||||||
// Helpers
|
// Helpers
|
||||||
getFilters,
|
getFilters,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
|
|||||||
116
src/pages/Requests/redux/requestsSlice.ts
Normal file
116
src/pages/Requests/redux/requestsSlice.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
|
|
||||||
|
export interface RequestsFiltersState {
|
||||||
|
searchTerm: string;
|
||||||
|
statusFilter: string;
|
||||||
|
priorityFilter: string;
|
||||||
|
slaComplianceFilter: string;
|
||||||
|
departmentFilter: string;
|
||||||
|
initiatorFilter: string;
|
||||||
|
approverFilter: string;
|
||||||
|
approverFilterType: 'current' | 'any';
|
||||||
|
dateRange: DateRange;
|
||||||
|
customStartDate?: Date;
|
||||||
|
customEndDate?: Date;
|
||||||
|
showCustomDatePicker: boolean;
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: RequestsFiltersState = {
|
||||||
|
searchTerm: '',
|
||||||
|
statusFilter: 'all',
|
||||||
|
priorityFilter: 'all',
|
||||||
|
slaComplianceFilter: 'all',
|
||||||
|
departmentFilter: 'all',
|
||||||
|
initiatorFilter: 'all',
|
||||||
|
approverFilter: 'all',
|
||||||
|
approverFilterType: 'current',
|
||||||
|
dateRange: 'all',
|
||||||
|
customStartDate: undefined,
|
||||||
|
customEndDate: undefined,
|
||||||
|
showCustomDatePicker: false,
|
||||||
|
currentPage: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestsSlice = createSlice({
|
||||||
|
name: 'requests',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setSearchTerm: (state, action: PayloadAction<string>) => {
|
||||||
|
state.searchTerm = action.payload;
|
||||||
|
},
|
||||||
|
setStatusFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.statusFilter = action.payload;
|
||||||
|
},
|
||||||
|
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.priorityFilter = action.payload;
|
||||||
|
},
|
||||||
|
setSlaComplianceFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.slaComplianceFilter = action.payload;
|
||||||
|
},
|
||||||
|
setDepartmentFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.departmentFilter = action.payload;
|
||||||
|
},
|
||||||
|
setInitiatorFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.initiatorFilter = action.payload;
|
||||||
|
},
|
||||||
|
setApproverFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.approverFilter = action.payload;
|
||||||
|
},
|
||||||
|
setApproverFilterType: (state, action: PayloadAction<'current' | 'any'>) => {
|
||||||
|
state.approverFilterType = action.payload;
|
||||||
|
},
|
||||||
|
setDateRange: (state, action: PayloadAction<DateRange>) => {
|
||||||
|
state.dateRange = action.payload;
|
||||||
|
},
|
||||||
|
setCustomStartDate: (state, action: PayloadAction<Date | undefined>) => {
|
||||||
|
state.customStartDate = action.payload;
|
||||||
|
},
|
||||||
|
setCustomEndDate: (state, action: PayloadAction<Date | undefined>) => {
|
||||||
|
state.customEndDate = action.payload;
|
||||||
|
},
|
||||||
|
setShowCustomDatePicker: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.showCustomDatePicker = action.payload;
|
||||||
|
},
|
||||||
|
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||||
|
state.currentPage = action.payload;
|
||||||
|
},
|
||||||
|
clearFilters: (state) => {
|
||||||
|
state.searchTerm = '';
|
||||||
|
state.statusFilter = 'all';
|
||||||
|
state.priorityFilter = 'all';
|
||||||
|
state.slaComplianceFilter = 'all';
|
||||||
|
state.departmentFilter = 'all';
|
||||||
|
state.initiatorFilter = 'all';
|
||||||
|
state.approverFilter = 'all';
|
||||||
|
state.approverFilterType = 'current';
|
||||||
|
state.dateRange = 'all';
|
||||||
|
state.customStartDate = undefined;
|
||||||
|
state.customEndDate = undefined;
|
||||||
|
state.showCustomDatePicker = false;
|
||||||
|
state.currentPage = 1;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
setSearchTerm,
|
||||||
|
setStatusFilter,
|
||||||
|
setPriorityFilter,
|
||||||
|
setSlaComplianceFilter,
|
||||||
|
setDepartmentFilter,
|
||||||
|
setInitiatorFilter,
|
||||||
|
setApproverFilter,
|
||||||
|
setApproverFilterType,
|
||||||
|
setDateRange,
|
||||||
|
setCustomStartDate,
|
||||||
|
setCustomEndDate,
|
||||||
|
setShowCustomDatePicker,
|
||||||
|
setCurrentPage,
|
||||||
|
clearFilters,
|
||||||
|
} = requestsSlice.actions;
|
||||||
|
|
||||||
|
export default requestsSlice;
|
||||||
|
|
||||||
|
|
||||||
@ -1,11 +1,19 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import authSlice from './slices/authSlice';
|
import authSlice from './slices/authSlice';
|
||||||
import dashboardSlice from '../pages/Dashboard/redux/dashboardSlice';
|
import dashboardSlice from '../pages/Dashboard/redux/dashboardSlice';
|
||||||
|
import requestsSlice from '../pages/Requests/redux/requestsSlice';
|
||||||
|
import myRequestsSlice from '../pages/MyRequests/redux/myRequestsSlice';
|
||||||
|
import openRequestsSlice from '../pages/OpenRequests/redux/openRequestsSlice';
|
||||||
|
import closedRequestsSlice from '../pages/ClosedRequests/redux/closedRequestsSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: authSlice.reducer,
|
auth: authSlice.reducer,
|
||||||
dashboard: dashboardSlice.reducer,
|
dashboard: dashboardSlice.reducer,
|
||||||
|
requests: requestsSlice.reducer,
|
||||||
|
myRequests: myRequestsSlice.reducer,
|
||||||
|
openRequests: openRequestsSlice.reducer,
|
||||||
|
closedRequests: closedRequestsSlice.reducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user