templates checked for the dealer claim and dashboard added for the dealer

This commit is contained in:
laxmanhalaki 2026-01-02 20:17:56 +05:30
parent 7893b52183
commit 164d576ea0
28 changed files with 2939 additions and 538 deletions

View File

@ -10,6 +10,7 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
import { WorkNotes } from '@/pages/WorkNotes'; import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest'; import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard'; import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { MyRequests } from '@/pages/MyRequests'; import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests'; import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests'; import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -27,6 +28,7 @@ import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback'; import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi'; import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal'; import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
import { TokenManager } from '@/utils/tokenManager';
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -48,6 +50,43 @@ function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) =
} }
} }
// Component to conditionally render Dashboard or DealerDashboard based on user job title
function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: string) => void; onNewRequest?: () => void }) {
const [isDealer, setIsDealer] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
try {
const userData = TokenManager.getUserData();
setIsDealer(userData?.jobTitle === 'Dealer');
} catch (error) {
console.error('[App] Error checking dealer status:', error);
setIsDealer(false);
} finally {
setIsLoading(false);
}
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
// Render dealer-specific dashboard if user is a dealer
if (isDealer) {
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
}
// Render regular dashboard for all other users
return <Dashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
}
// Main Application Routes Component // Main Application Routes Component
function AppRoutes({ onLogout }: AppProps) { function AppRoutes({ onLogout }: AppProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -573,12 +612,12 @@ function AppRoutes({ onLogout }: AppProps) {
element={<AuthCallback />} element={<AuthCallback />}
/> />
{/* Dashboard */} {/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
<Route <Route
path="/" path="/"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </PageLayout>
} }
/> />
@ -587,7 +626,7 @@ function AppRoutes({ onLogout }: AppProps) {
path="/dashboard" path="/dashboard"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </PageLayout>
} }
/> />

View File

@ -24,6 +24,8 @@ interface AddApproverModalProps {
requestTitle?: string; requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>; existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
currentLevels?: ApprovalLevelInfo[]; // Current approval levels currentLevels?: ApprovalLevelInfo[]; // Current approval levels
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
export function AddApproverModal({ export function AddApproverModal({
@ -31,7 +33,9 @@ export function AddApproverModal({
onClose, onClose,
onConfirm, onConfirm,
existingParticipants = [], existingParticipants = [],
currentLevels = [] currentLevels = [],
maxApprovalLevels,
onPolicyViolation
}: AddApproverModalProps) { }: AddApproverModalProps) {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24); const [tatHours, setTatHours] = useState<number>(24);
@ -140,6 +144,36 @@ export function AddApproverModal({
return; return;
} }
// Validate against maxApprovalLevels policy
// Calculate the new total levels after adding this approver
// If inserting at a level that already exists, levels shift down, so total stays same
// If inserting at a new level (beyond current), total increases
const currentMaxLevel = currentLevels.length > 0
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
: 0;
const newTotalLevels = selectedLevel > currentMaxLevel
? selectedLevel // New level beyond current max
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
if (onPolicyViolation) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
currentValue: newTotalLevels,
maxValue: maxApprovalLevels
}]);
} else {
setValidationModal({
open: true,
type: 'error',
email: '',
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
});
}
return;
}
// Check if user is already a participant // Check if user is already a participant
const existingParticipant = existingParticipants.find( const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd p => (p.email || '').toLowerCase() === emailToAdd
@ -394,6 +428,20 @@ export function AddApproverModal({
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down. Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
</p> </p>
{/* Max Approval Levels Note */}
{maxApprovalLevels && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
<p className="text-xs text-blue-800">
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
{currentLevels.length > 0 && (
<span className="ml-2">
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
</span>
)}
</p>
</div>
)}
{/* Current Levels Display */} {/* Current Levels Display */}
{currentLevels.length > 0 && ( {currentLevels.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -82,6 +82,8 @@ interface WorkNoteChatProps {
isSpectator?: boolean; // Whether current user is a spectator (view-only) isSpectator?: boolean; // Whether current user is a spectator (view-only)
currentLevels?: any[]; // Current approval levels for add approver modal currentLevels?: any[]; // Current approval levels for add approver modal
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void; // Callback for policy violations
} }
// All data is now fetched from backend - no hardcoded mock data // All data is now fetched from backend - no hardcoded mock data
@ -142,7 +144,7 @@ const FileIcon = ({ type }: { type: string }) => {
return <Paperclip className={`${iconClass} text-gray-600`} />; return <Paperclip className={`${iconClass} text-gray-600`} />;
}; };
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) { export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover, maxApprovalLevels, onPolicyViolation }: WorkNoteChatProps) {
const routeParams = useParams<{ requestId: string }>(); const routeParams = useParams<{ requestId: string }>();
const effectiveRequestId = requestId || routeParams.requestId || ''; const effectiveRequestId = requestId || routeParams.requestId || '';
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@ -1815,6 +1817,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
requestTitle={requestInfo.title} requestTitle={requestInfo.title}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={maxApprovalLevels}
onPolicyViolation={onPolicyViolation}
/> />
)} )}

View File

@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react'; import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
import { FormData } from '@/hooks/useCreateRequestForm'; import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
import { useMultiUserSearch } from '@/hooks/useUserSearch'; import { useMultiUserSearch } from '@/hooks/useUserSearch';
import { ensureUserExists } from '@/services/userApi'; import { ensureUserExists } from '@/services/userApi';
@ -15,6 +15,8 @@ interface ApprovalWorkflowStepProps {
formData: FormData; formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void; updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void; onValidationError: (error: { type: string; email: string; message: string }) => void;
systemPolicy: SystemPolicy;
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
/** /**
@ -33,7 +35,9 @@ interface ApprovalWorkflowStepProps {
export function ApprovalWorkflowStep({ export function ApprovalWorkflowStep({
formData, formData,
updateFormData, updateFormData,
onValidationError onValidationError,
systemPolicy,
onPolicyViolation
}: ApprovalWorkflowStepProps) { }: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch(); const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -218,17 +222,29 @@ export function ApprovalWorkflowStep({
size="sm" size="sm"
onClick={() => { onClick={() => {
const currentCount = formData.approverCount || 1; const currentCount = formData.approverCount || 1;
const newCount = Math.min(10, currentCount + 1); const newCount = currentCount + 1;
// Validate against system policy
if (newCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
currentValue: newCount,
maxValue: systemPolicy.maxApprovalLevels
}]);
return;
}
updateFormData('approverCount', newCount); updateFormData('approverCount', newCount);
}} }}
disabled={(formData.approverCount || 1) >= 10} disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
data-testid="approval-workflow-increase-count" data-testid="approval-workflow-increase-count"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>
<p className="text-sm text-gray-600 mt-2"> <p className="text-sm text-gray-600 mt-2">
Maximum 10 approvers allowed. Each approver will review sequentially. Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially.
</p> </p>
</div> </div>
</CardContent> </CardContent>

View File

@ -0,0 +1,172 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
interface ClosedRequestsFiltersProps {
searchTerm: string;
priorityFilter: string;
statusFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
activeFiltersCount: number;
onSearchChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onStatusChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
}
/**
* Standard Closed Requests Filters Component
*
* Used for regular users (non-dealers).
* Includes: Search, Priority, Status (Closure Type), Template Type, and Sort filters.
*/
export function StandardClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
templateTypeFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
onTemplateTypeChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
}: ClosedRequestsFiltersProps) {
return (
<Card className="shadow-lg border-0" data-testid="closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="closed-requests-clear-filters"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative">
<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
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(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 transition-colors"
data-testid="closed-requests-search"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<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-priority-filter">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<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">
<SelectValue placeholder="Closure Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Closures</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Closed After Approval</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Closed After Rejection</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<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-template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<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-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="closed-requests-sort-order"
>
{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>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,161 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react';
interface RequestsFiltersProps {
searchTerm: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusFilterChange: (value: string) => void;
onPriorityFilterChange: (value: string) => void;
onTemplateTypeFilterChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Standard Requests Filters Component
*
* Used for regular users (non-dealers).
* Includes: Search, Status, Priority, Template Type, and Sort filters.
*/
export function StandardRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
templateTypeFilter,
sortBy,
sortOrder,
onSearchChange,
onStatusFilterChange,
onPriorityFilterChange,
onTemplateTypeFilterChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
}: RequestsFiltersProps) {
return (
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
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" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative">
<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
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(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"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityFilterChange}>
<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" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<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" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending (In Approval)</SelectItem>
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
</SelectContent>
</Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
<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 Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(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">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
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" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,458 @@
/**
* Standard User All Requests Filters Component
*
* Full filters for regular users (non-dealers).
* Includes: Search, Status, Priority, Template Type, Department, SLA Compliance,
* Initiator, Approver, and Date Range filters.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
interface StandardUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
departmentFilter: string;
slaComplianceFilter: string;
initiatorFilter: string;
approverFilter: string;
approverFilterType: 'current' | 'any';
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// Departments
departments: string[];
loadingDepartments: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onDepartmentChange: (value: string) => void;
onSlaComplianceChange: (value: string) => void;
onInitiatorChange?: (value: string) => void;
onApproverChange?: (value: string) => void;
onApproverTypeChange?: (value: 'current' | 'any') => void;
onDateRangeChange: (value: DateRange) => void;
onCustomStartDateChange?: (date: Date | undefined) => void;
onCustomEndDateChange?: (date: Date | undefined) => void;
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
export function StandardUserAllRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
templateTypeFilter,
departmentFilter,
slaComplianceFilter,
initiatorFilter: _initiatorFilter,
approverFilter,
approverFilterType,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
departments,
loadingDepartments,
initiatorSearch,
approverSearch,
onSearchChange,
onStatusChange,
onPriorityChange,
onTemplateTypeChange,
onDepartmentChange,
onSlaComplianceChange,
onInitiatorChange: _onInitiatorChange,
onApproverChange: _onApproverChange,
onApproverTypeChange,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onClearFilters,
hasActiveFilters,
}: StandardUserAllRequestsFiltersProps) {
return (
<Card className="border-gray-200 shadow-md" data-testid="user-all-requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
{hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 h-10"
data-testid="search-input"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<SelectTrigger className="h-10" data-testid="priority-filter">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<Select
value={departmentFilter}
onValueChange={onDepartmentChange}
disabled={loadingDepartments || departments.length === 0}
>
<SelectTrigger className="h-10" data-testid="department-filter">
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={slaComplianceFilter} onValueChange={onSlaComplianceChange}>
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
<SelectValue placeholder="All SLA Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA Status</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{approverFilter !== 'all' && onApproverTypeChange && (
<Select
value={approverFilterType}
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomStartDateChange?.(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange?.(date);
}
} else {
onCustomStartDateChange?.(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={customEndDate ? format(customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomEndDateChange?.(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange?.(date);
}
} else {
onCustomEndDateChange?.(undefined);
}
}}
min={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange?.(false);
onCustomStartDateChange?.(undefined);
onCustomEndDateChange?.(undefined);
onDateRangeChange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -23,5 +23,10 @@ export { CreateRequest as CustomCreateRequest } from './components/request-creat
// Request Detail Screen (Complete standalone screen) // Request Detail Screen (Complete standalone screen)
export { CustomRequestDetail } from './pages/RequestDetail'; export { CustomRequestDetail } from './pages/RequestDetail';
// Filters
export { StandardRequestsFilters } from './components/RequestsFilters';
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
// Re-export types // Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types'; export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -37,6 +37,8 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useConclusionRemark } from '@/hooks/useConclusionRemark'; import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { useModalManager } from '@/hooks/useModalManager'; import { useModalManager } from '@/hooks/useModalManager';
import { downloadDocument } from '@/services/workflowApi'; import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
// Custom Request Components (import from index to get properly aliased exports) // Custom Request Components (import from index to get properly aliased exports)
import { CustomOverviewTab, CustomWorkflowTab } from '../index'; import { CustomOverviewTab, CustomWorkflowTab } from '../index';
@ -112,6 +114,24 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
const [showPauseModal, setShowPauseModal] = useState(false); const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false); const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const { user } = useAuth(); const { user } = useAuth();
// Custom hooks // Custom hooks
@ -179,6 +199,32 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
handleFinalizeConclusion, handleFinalizeConclusion,
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal); } = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Auto-switch tab when URL query parameter changes // Auto-switch tab when URL query parameter changes
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -521,6 +567,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
isSpectator={isSpectator} isSpectator={isSpectator}
currentLevels={currentLevels} currentLevels={currentLevels}
onAddApprover={handleAddApprover} onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
</TabsContent> </TabsContent>
</div> </div>
@ -610,6 +658,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
actionStatus={actionStatus} actionStatus={actionStatus}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal} setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal} setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal} setShowAddApproverModal={setShowAddApproverModal}
@ -628,6 +678,19 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
downloadDocument={downloadDocument} downloadDocument={downloadDocument}
documentPolicy={documentPolicy} documentPolicy={documentPolicy}
/> />
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</> </>
); );
} }

View File

@ -0,0 +1,142 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, CheckCircle, XCircle } from 'lucide-react';
interface DealerClosedRequestsFiltersProps {
searchTerm: string;
statusFilter?: string;
priorityFilter?: string;
templateTypeFilter?: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusChange?: (value: string) => void;
onPriorityChange?: (value: string) => void;
onTemplateTypeChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Dealer Closed Requests Filters Component
*
* Simplified filters for dealer users viewing closed requests.
* Only includes: Search, Status (closure type), and Sort filters.
* Removes: Priority and Template Type filters.
*/
export function DealerClosedRequestsFilters({
searchTerm,
statusFilter = 'all',
sortBy,
sortOrder,
onSearchChange,
onStatusChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
...rest // Accept but ignore other props for interface compatibility
}: DealerClosedRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="shadow-lg border-0" data-testid="dealer-closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="dealer-closed-requests-clear-filters"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Dealer-specific filters - Search, Status (Closure Type), and Sort */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
<div className="relative">
<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
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(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"
data-testid="dealer-closed-requests-search"
/>
</div>
{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 focus:border-blue-400 focus:ring-1 focus:ring-blue-200" data-testid="dealer-closed-requests-status-filter">
<SelectValue placeholder="Closure Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Closures</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Closed After Approval</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Closed After Rejection</span>
</div>
</SelectItem>
</SelectContent>
</Select>
)}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<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" data-testid="dealer-closed-requests-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="dealer-closed-requests-sort-order"
>
{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>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,114 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X } from 'lucide-react';
interface DealerRequestsFiltersProps {
searchTerm: string;
statusFilter?: string;
priorityFilter?: string;
templateTypeFilter?: string;
sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusFilterChange?: (value: string) => void;
onPriorityFilterChange?: (value: string) => void;
onTemplateTypeFilterChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Dealer Requests Filters Component
*
* Simplified filters for dealer users.
* Only includes: Search and Sort filters (no status, priority, or template type).
*/
export function DealerRequestsFilters({
searchTerm,
sortBy,
sortOrder,
onSearchChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
...rest // Accept but ignore other props for interface compatibility
}: DealerRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
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" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Dealer-specific filters - Only Search and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div className="relative">
<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
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(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"
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(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">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
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" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,390 @@
/**
* Dealer User All Requests Filters Component
*
* Simplified filters for dealer users viewing their all requests.
* Only includes: Search, Status, Initiator, Approver, and Date Range filters.
* Removes: Priority, Template Type, Department, and SLA Compliance filters.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
interface DealerUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
statusFilter: string;
priorityFilter?: string;
templateTypeFilter?: string;
departmentFilter?: string;
slaComplianceFilter?: string;
initiatorFilter: string;
approverFilter: string;
approverFilterType: 'current' | 'any';
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onInitiatorChange?: (value: string) => void;
onApproverChange?: (value: string) => void;
onApproverTypeChange?: (value: 'current' | 'any') => void;
onDateRangeChange: (value: DateRange) => void;
onCustomStartDateChange?: (date: Date | undefined) => void;
onCustomEndDateChange?: (date: Date | undefined) => void;
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
export function DealerUserAllRequestsFilters({
searchTerm,
statusFilter,
initiatorFilter,
approverFilter,
approverFilterType,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
initiatorSearch,
approverSearch,
onSearchChange,
onStatusChange,
onInitiatorChange,
onApproverChange,
onApproverTypeChange,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onClearFilters,
hasActiveFilters,
...rest // Accept but ignore other props for interface compatibility
}: DealerUserAllRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="border-gray-200 shadow-md" data-testid="dealer-user-all-requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Filters</h3>
{hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters - Only Search and Status for dealers */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 h-10"
data-testid="dealer-search-input"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="dealer-status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="dealer-initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{approverFilter !== 'all' && onApproverTypeChange && (
<Select
value={approverFilterType}
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="dealer-approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomStartDateChange?.(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange?.(date);
}
} else {
onCustomStartDateChange?.(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={customEndDate ? format(customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomEndDateChange?.(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange?.(date);
}
} else {
onCustomEndDateChange?.(undefined);
}
}}
min={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange?.(false);
onCustomStartDateChange?.(undefined);
onCustomEndDateChange?.(undefined);
onDateRangeChange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -54,6 +54,8 @@ interface ClaimApproverSelectionStepProps {
currentUserId?: string; currentUserId?: string;
currentUserName?: string; currentUserName?: string;
onValidate?: (isValid: boolean) => void; onValidate?: (isValid: boolean) => void;
maxApprovalLevels?: number;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
export function ClaimApproverSelectionStep({ export function ClaimApproverSelectionStep({
@ -64,6 +66,8 @@ export function ClaimApproverSelectionStep({
currentUserId = '', currentUserId = '',
currentUserName = '', currentUserName = '',
onValidate, onValidate,
maxApprovalLevels,
onPolicyViolation,
}: ClaimApproverSelectionStepProps) { }: ClaimApproverSelectionStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch(); const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -560,6 +564,30 @@ export function ClaimApproverSelectionStep({
// Calculate insert level based on current shifted level // Calculate insert level based on current shifted level
const insertLevel = currentLevelAfter + 1; const insertLevel = currentLevelAfter + 1;
// Validate max approval levels
if (maxApprovalLevels) {
// Calculate total levels after adding the new approver
// After shifting, we'll have the same number of unique levels + 1 (the new approver)
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
const newTotalLevels = currentUniqueLevels + 1;
if (newTotalLevels > maxApprovalLevels) {
const violations = [{
type: 'max_approval_levels',
message: `Adding this approver would create ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove some approvers before adding a new one.`,
currentValue: newTotalLevels,
maxValue: maxApprovalLevels
}];
if (onPolicyViolation) {
onPolicyViolation(violations);
} else {
toast.error(violations[0]?.message || 'Maximum approval levels exceeded');
}
return;
}
}
// If user was NOT selected via @ search, validate against Okta // If user was NOT selected via @ search, validate against Okta
if (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) { if (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) {
try { try {
@ -728,6 +756,19 @@ export function ClaimApproverSelectionStep({
</CardTitle> </CardTitle>
<CardDescription className="text-blue-700"> <CardDescription className="text-blue-700">
Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for "Department Lead Approval" only. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final. Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for "Department Lead Approval" only. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
{maxApprovalLevels && (
<span className="block mt-2 text-gray-600">
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
{(() => {
const approvers = formData.approvers || [];
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
const currentCount = allLevels.size;
return currentCount > 0 ? (
<span> ({currentCount}/{maxApprovalLevels})</span>
) : null;
})()}
</span>
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
</Card> </Card>
@ -745,7 +786,20 @@ export function ClaimApproverSelectionStep({
</CardHeader> </CardHeader>
<CardContent className="space-y-2 pt-4"> <CardContent className="space-y-2 pt-4">
{/* Add Additional Approver Button */} {/* Add Additional Approver Button */}
<div className="mb-4 flex justify-end"> <div className="mb-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
{maxApprovalLevels && (
<p className="text-sm text-gray-600">
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
{(() => {
const approvers = formData.approvers || [];
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
const currentCount = allLevels.size;
return currentCount > 0 ? (
<span> ({currentCount}/{maxApprovalLevels})</span>
) : null;
})()}
</p>
)}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@ -1157,6 +1211,21 @@ export function ClaimApproverSelectionStep({
<p className="text-xs text-amber-600 font-medium"> <p className="text-xs text-amber-600 font-medium">
Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
</p> </p>
{/* Max Approval Levels Note */}
{maxApprovalLevels && (
<p className="text-xs text-gray-600 mt-2">
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
{(() => {
const approvers = formData.approvers || [];
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
const currentCount = allLevels.size;
return currentCount > 0 ? (
<span> ({currentCount}/{maxApprovalLevels})</span>
) : null;
})()}
</p>
)}
</div> </div>
{/* TAT Input */} {/* TAT Input */}

View File

@ -32,6 +32,8 @@ import { toast } from 'sonner';
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi'; import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep'; import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep) // CLAIM_STEPS definition (same as in ClaimApproverSelectionStep)
const CLAIM_STEPS = [ const CLAIM_STEPS = [
@ -82,6 +84,48 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const dealerSearchTimer = useRef<any>(null); const dealerSearchTimer = useRef<any>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null); const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// System policy state
const [systemPolicy, setSystemPolicy] = useState({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Cleanup timeout on unmount // Cleanup timeout on unmount
useEffect(() => { useEffect(() => {
@ -699,6 +743,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
? `${(user as any).firstName} ${(user as any).lastName}`.trim() ? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (user as any)?.email || 'User') : (user as any)?.email || 'User')
} }
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
); );
@ -1052,6 +1098,19 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
)} )}
</div> </div>
</div> </div>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</div> </div>
); );
} }

View File

@ -30,5 +30,13 @@ export { ClaimManagementWizard } from './components/request-creation/ClaimManage
// Request Detail Screen (Complete standalone screen) // Request Detail Screen (Complete standalone screen)
export { DealerClaimRequestDetail } from './pages/RequestDetail'; export { DealerClaimRequestDetail } from './pages/RequestDetail';
// Dashboard
export { DealerDashboard } from './pages/Dashboard';
// Filters
export { DealerRequestsFilters } from './components/DealerRequestsFilters';
export { DealerClosedRequestsFilters } from './components/DealerClosedRequestsFilters';
export { DealerUserAllRequestsFilters } from './components/DealerUserAllRequestsFilters';
// Re-export types // Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types'; export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -0,0 +1,687 @@
import { useEffect, useState, useMemo } from 'react';
import { Shield, Clock, FileText, ChartColumn, ChartPie, Activity, Target, DollarSign, Zap, Package, TrendingUp, TrendingDown, CircleCheckBig, CircleX, CreditCard, TriangleAlert } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { getDealerDashboard, type DashboardKPIs as DashboardKPIsType, type CategoryData as CategoryDataType } from '@/services/dealerClaimApi';
import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
// Use types from dealerClaimApi
type DashboardKPIs = DashboardKPIsType;
type CategoryData = CategoryDataType;
interface DashboardProps {
onNavigate?: (page: string) => void;
onNewRequest?: () => void;
}
export function DealerDashboard({ onNavigate, onNewRequest: _onNewRequest }: DashboardProps) {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [kpis, setKpis] = useState<DashboardKPIs>({
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
const [dateRange, _setDateRange] = useState<string>('all');
const [startDate, _setStartDate] = useState<string | undefined>();
const [endDate, _setEndDate] = useState<string | undefined>();
const fetchDashboardData = async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
// Fetch dealer claims dashboard data
const data = await getDealerDashboard(
dateRange || 'all',
startDate,
endDate
);
setKpis(data.kpis || {
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
setCategoryData(data.categoryData || []);
} catch (error: any) {
console.error('[DealerDashboard] Error fetching data:', error);
toast.error('Failed to load dashboard data. Please try again later.');
// Reset to empty state on error
setKpis({
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
setCategoryData([]);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchDashboardData();
}, []);
const formatCurrency = (amount: number, showExactRupees = false) => {
// Handle null, undefined, or invalid values
if (amount == null || isNaN(amount)) {
return '₹0';
}
// Convert to number if it's a string
const numAmount = typeof amount === 'string' ? parseFloat(amount) : Number(amount);
// Handle zero or negative values
if (numAmount <= 0) {
return '₹0';
}
// If showExactRupees is true or amount is less than 10,000, show exact rupees
if (showExactRupees || numAmount < 10000) {
return `${Math.round(numAmount).toLocaleString('en-IN')}`;
}
if (numAmount >= 100000) {
return `${(numAmount / 100000).toFixed(1)}L`;
}
if (numAmount >= 1000) {
return `${(numAmount / 1000).toFixed(1)}K`;
}
// Show exact rupee amount for amounts less than 1000 (e.g., ₹100, ₹200, ₹999)
return `${Math.round(numAmount).toLocaleString('en-IN')}`;
};
const formatNumber = (num: number) => {
return num.toLocaleString('en-IN');
};
const calculateApprovalRate = () => {
if (kpis.totalClaims === 0) return 0;
return ((kpis.approved / kpis.totalClaims) * 100).toFixed(1);
};
const calculateCreditRate = () => {
if (kpis.approved === 0) return 0;
return ((kpis.credited / kpis.approved) * 100).toFixed(1);
};
// Prepare data for pie chart (Distribution by Activity Type)
const distributionData = useMemo(() => {
const totalRaised = categoryData.reduce((sum, cat) => sum + cat.raised, 0);
if (totalRaised === 0) return [];
return categoryData.map(cat => ({
name: cat.activityType.length > 20 ? cat.activityType.substring(0, 20) + '...' : cat.activityType,
value: cat.raised,
fullName: cat.activityType,
percentage: ((cat.raised / totalRaised) * 100).toFixed(0),
}));
}, [categoryData]);
// Prepare data for bar chart (Status by Category)
const statusByCategoryData = useMemo(() => {
return categoryData.map(cat => ({
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
fullName: cat.activityType,
Raised: cat.raised,
Approved: cat.approved,
Rejected: cat.rejected,
Pending: cat.pending,
}));
}, [categoryData]);
// Prepare data for value comparison chart (keep original values, formatCurrency will handle display)
const valueComparisonData = useMemo(() => {
return categoryData.map(cat => ({
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
fullName: cat.activityType,
Raised: cat.raisedValue, // Keep original value
Approved: cat.approvedValue, // Keep original value
Credited: cat.creditedValue, // Keep original value
}));
}, [categoryData]);
const COLORS = ['#166534', '#15803d', '#16a34a', '#22c55e', '#4ade80', '#86efac', '#bbf7d0'];
// Find best performing category
const bestPerforming = useMemo(() => {
if (categoryData.length === 0) return null;
return categoryData.reduce((best, cat) =>
cat.approvalRate > (best?.approvalRate || 0) ? cat : best
);
}, [categoryData]);
// Find highest value category
const highestValue = useMemo(() => {
if (categoryData.length === 0) return null;
return categoryData.reduce((best, cat) =>
cat.raisedValue > (best?.raisedValue || 0) ? cat : best
);
}, [categoryData]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
);
}
// Show empty state if no data
const hasNoData = kpis.totalClaims === 0 && categoryData.length === 0;
if (hasNoData) {
return (
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
{/* Hero Section */}
<Card className="border-0 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
<Button
onClick={() => onNavigate?.('/new-request')}
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<FileText className="w-5 h-5 mr-2" />
Create New Claim
</Button>
<Button
onClick={() => {
setRefreshing(true);
fetchDashboardData(true);
}}
disabled={refreshing}
variant="outline"
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Empty State */}
<Card className="shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
<ChartPie className="w-12 h-12 text-gray-400" />
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">No Claims Data Available</h2>
<p className="text-gray-600 text-center max-w-md mb-6">
You don't have any claims data yet. Once you create and submit claim requests, your analytics will appear here.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button
onClick={() => onNavigate?.('/new-request')}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<FileText className="w-5 h-5 mr-2" />
Create Your First Claim
</Button>
<Button
onClick={() => {
setRefreshing(true);
fetchDashboardData(true);
}}
disabled={refreshing}
variant="outline"
>
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh Data
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
{/* Hero Section */}
<Card className="border-0 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
<Button
onClick={() => onNavigate?.('/requests?status=pending')}
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<Clock className="w-5 h-5 mr-2" />
View Pending Claims
</Button>
<Button
onClick={() => onNavigate?.('/requests')}
className="bg-emerald-600 hover:bg-emerald-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<FileText className="w-5 h-5 mr-2" />
My Claims
</Button>
</div>
</div>
<div className="hidden lg:flex items-center gap-4">
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
<ChartColumn className="w-8 h-8 text-yellow-400" />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<Card className="border-l-4 border-l-blue-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Raised Claims</CardTitle>
<div className="p-2 rounded-lg bg-blue-50">
<FileText className="h-4 w-4 text-blue-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.totalClaims)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.totalValue, true)}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Approved</CardTitle>
<div className="p-2 rounded-lg bg-green-50">
<CircleCheckBig className="h-4 w-4 text-green-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.approved)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-green-600" />
<p className="text-xs text-green-600">{calculateApprovalRate()}% approval rate</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-red-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Rejected</CardTitle>
<div className="p-2 rounded-lg bg-red-50">
<CircleX className="h-4 w-4 text-red-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.rejected)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingDown className="h-3 w-3 text-red-600" />
<p className="text-xs text-red-600">
{kpis.totalClaims > 0 ? ((kpis.rejected / kpis.totalClaims) * 100).toFixed(1) : 0}% rejection rate
</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Pending</CardTitle>
<div className="p-2 rounded-lg bg-orange-50">
<Clock className="h-4 w-4 text-orange-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.pending)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingValue)}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-emerald-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Credited</CardTitle>
<div className="p-2 rounded-lg bg-emerald-50">
<CreditCard className="h-4 w-4 text-emerald-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.credited)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-emerald-600" />
<p className="text-xs text-emerald-600">{calculateCreditRate()}% credit rate</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Pending Credit</CardTitle>
<div className="p-2 rounded-lg bg-amber-50">
<TriangleAlert className="h-4 w-4 text-amber-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.pendingCredit)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingCreditValue)}</p>
</CardContent>
</Card>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Distribution by Activity Type */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-purple-100 rounded-lg">
<ChartPie className="h-5 w-5 text-purple-600" />
</div>
<div>
<CardTitle>Claims Distribution by Activity Type</CardTitle>
<CardDescription>Total claims raised across activity types</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={distributionData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percentage }) => `${name}: ${percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{distributionData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="grid grid-cols-3 gap-2 mt-4">
{distributionData.slice(0, 3).map((item, index) => (
<div key={index} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
<div className="w-3 h-3 rounded" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
<div>
<p className="text-xs text-gray-600">{item.name}</p>
<p className="text-sm text-gray-900">{formatNumber(item.value)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Status by Category */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-100 rounded-lg">
<ChartColumn className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle>Claims Status by Activity Type</CardTitle>
<CardDescription>Count comparison across workflow stages</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statusByCategoryData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Raised" fill="#3b82f6" />
<Bar dataKey="Approved" fill="#22c55e" />
<Bar dataKey="Rejected" fill="#ef4444" />
<Bar dataKey="Pending" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Detailed Category Breakdown */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-emerald-100 rounded-lg">
<Activity className="h-5 w-5 text-emerald-600" />
</div>
<div>
<CardTitle>Detailed Activity Type Breakdown</CardTitle>
<CardDescription>In-depth analysis of claims by type and status</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="category-1">Top Category 1</TabsTrigger>
<TabsTrigger value="category-2">Top Category 2</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4 mt-6">
<div>
<h3 className="text-lg mb-4 text-gray-900">Activity Type Value Comparison</h3>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={valueComparisonData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis tickFormatter={(value) => formatCurrency(value)} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
labelFormatter={(label) => label}
/>
<Legend />
<Bar dataKey="Raised" fill="#3b82f6" />
<Bar dataKey="Approved" fill="#22c55e" />
<Bar dataKey="Credited" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
{categoryData.slice(0, 3).map((cat, index) => (
<Card key={index} className="shadow-md hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{cat.activityType}</CardTitle>
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">
{cat.approvalRate.toFixed(1)}% approved
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Raised:</span>
<span className="text-gray-900">{formatNumber(cat.raised)} ({formatCurrency(cat.raisedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Approved:</span>
<span className="text-green-600">{formatNumber(cat.approved)} ({formatCurrency(cat.approvedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Rejected:</span>
<span className="text-red-600">{formatNumber(cat.rejected)} ({formatCurrency(cat.rejectedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Pending:</span>
<span className="text-orange-600">{formatNumber(cat.pending)} ({formatCurrency(cat.pendingValue)})</span>
</div>
<div className="h-px bg-gray-200 my-2" />
<div className="flex justify-between text-sm">
<span className="text-gray-600">Credited:</span>
<span className="text-emerald-600">{formatNumber(cat.credited)} ({formatCurrency(cat.creditedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Pending Credit:</span>
<span className="text-amber-600">{formatNumber(cat.pendingCredit)} ({formatCurrency(cat.pendingCreditValue)})</span>
</div>
</div>
<div className="pt-2">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>Credit Rate</span>
<span>{cat.creditRate.toFixed(1)}%</span>
</div>
<Progress value={cat.creditRate} className="h-2" />
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="category-1" className="space-y-4">
{/* Category 1 details */}
<p className="text-gray-600">Detailed view for top category 1</p>
</TabsContent>
<TabsContent value="category-2" className="space-y-4">
{/* Category 2 details */}
<p className="text-gray-600">Detailed view for top category 2</p>
</TabsContent>
</Tabs>
</CardContent>
</Card>
{/* Performance Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-t-4 border-t-green-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<Target className="h-6 w-6 text-green-600" />
</div>
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Best Performing</h3>
<p className="text-xl text-gray-900 mb-1">{bestPerforming?.activityType || 'N/A'}</p>
<p className="text-sm text-green-600">{bestPerforming?.approvalRate.toFixed(2) || 0}% approval rate</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-blue-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<DollarSign className="h-6 w-6 text-blue-600" />
</div>
<Activity className="h-5 w-5 text-blue-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Top Activity Type</h3>
<p className="text-xl text-gray-900 mb-1">{highestValue?.activityType || 'N/A'}</p>
<p className="text-sm text-blue-600">{highestValue ? formatCurrency(highestValue.raisedValue, true) : '₹0'} raised</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-emerald-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-emerald-100 rounded-lg">
<Zap className="h-6 w-6 text-emerald-600" />
</div>
<CircleCheckBig className="h-5 w-5 text-emerald-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Overall Credit Rate</h3>
<p className="text-xl text-gray-900 mb-1">{calculateCreditRate()}%</p>
<p className="text-sm text-emerald-600">{formatNumber(kpis.credited)} claims credited</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-amber-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-amber-100 rounded-lg">
<Package className="h-6 w-6 text-amber-600" />
</div>
<TriangleAlert className="h-5 w-5 text-amber-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Pending Action</h3>
<p className="text-xl text-gray-900 mb-1">{formatNumber(kpis.pendingCredit)}</p>
<p className="text-sm text-amber-600">{formatCurrency(kpis.pendingCreditValue)} awaiting credit</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -38,6 +38,8 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useModalManager } from '@/hooks/useModalManager'; import { useModalManager } from '@/hooks/useModalManager';
import { useConclusionRemark } from '@/hooks/useConclusionRemark'; import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { downloadDocument } from '@/services/workflowApi'; import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils'; import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
@ -115,6 +117,24 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const [showPauseModal, setShowPauseModal] = useState(false); const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false); const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const { user } = useAuth(); const { user } = useAuth();
// Custom hooks // Custom hooks
@ -251,6 +271,32 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowActionStatusModal setShowActionStatusModal
); );
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Auto-switch tab when URL query parameter changes // Auto-switch tab when URL query parameter changes
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -639,6 +685,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
isSpectator={isSpectator} isSpectator={isSpectator}
currentLevels={currentLevels} currentLevels={currentLevels}
onAddApprover={handleAddApprover} onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
</TabsContent> </TabsContent>
</div> </div>
@ -728,6 +776,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
actionStatus={actionStatus} actionStatus={actionStatus}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal} setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal} setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal} setShowAddApproverModal={setShowAddApproverModal}
@ -746,6 +796,19 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
downloadDocument={downloadDocument} downloadDocument={downloadDocument}
documentPolicy={documentPolicy} documentPolicy={documentPolicy}
/> />
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</> </>
); );
} }

View File

@ -13,6 +13,7 @@
*/ */
import { RequestFlowType } from '@/utils/requestTypeUtils'; import { RequestFlowType } from '@/utils/requestTypeUtils';
import { UserFilterType } from '@/utils/userFilterUtils';
// Import flow modules from src/ level // Import flow modules from src/ level
import * as CustomFlow from './custom'; import * as CustomFlow from './custom';
@ -88,6 +89,79 @@ export function getRequestDetailScreen(flowType: RequestFlowType) {
} }
} }
/**
* Get Requests Filters component for a user filter type
* Each user type can have its own filter component
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + sort only)
* - STANDARD: Full filters (search + status + priority + template + sort)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardRequestsFilters;
}
}
/**
* Get Closed Requests Filters component for a user filter type
* Each user type can have its own filter component for closed requests
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + status + sort only, no priority or template)
* - STANDARD: Full filters (search + priority + status + template + sort)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a closed requests filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getClosedRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerClosedRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardClosedRequestsFilters;
}
}
/**
* Get User All Requests Filters component for a user filter type
* Each user type can have its own filter component for user all requests
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + status + initiator + approver + date range, no priority/template/department/sla)
* - STANDARD: Full filters (all filters including priority, template, department, and SLA compliance)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a user all requests filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getUserAllRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerUserAllRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardUserAllRequestsFilters;
}
}
// Re-export flow modules for direct access // Re-export flow modules for direct access
export { CustomFlow, DealerClaimFlow, SharedComponents }; export { CustomFlow, DealerClaimFlow, SharedComponents };
export type { RequestFlowType } from '@/utils/requestTypeUtils'; export type { RequestFlowType } from '@/utils/requestTypeUtils';
export type { UserFilterType } from '@/utils/userFilterUtils';

View File

@ -162,9 +162,9 @@ export function useCreateRequestForm(
}); });
// Load system policy // Load system policy
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING'); const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS'); const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...workflowConfigs, ...tatConfigs]; const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {}; const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => { allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue; configMap[c.configKey] = c.configValue;

View File

@ -1,8 +1,7 @@
import { useCallback, useRef, useEffect } from 'react'; import { useCallback, useRef, useEffect, useMemo } from 'react';
// Components // Components
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader'; import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
import { ClosedRequestsList } from './components/ClosedRequestsList'; import { ClosedRequestsList } from './components/ClosedRequestsList';
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty'; import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination'; import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
@ -14,6 +13,11 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
// Types // Types
import type { ClosedRequestsProps } from './types/closedRequests.types'; import type { ClosedRequestsProps } from './types/closedRequests.types';
// Utils & Factory
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getClosedRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
// Data fetching hook // Data fetching hook
const closedRequests = useClosedRequests({ itemsPerPage: 10 }); const closedRequests = useClosedRequests({ itemsPerPage: 10 });
@ -23,6 +27,24 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current = closedRequests.fetchRequests; fetchRef.current = closedRequests.fetchRequests;
const filters = useClosedRequestsFilters(); const filters = useClosedRequestsFilters();
// Get user filter type and corresponding filter component (plug-and-play pattern)
const userFilterType = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return getUserFilterType(userData);
} catch (error) {
console.error('[ClosedRequests] Error getting user filter type:', error);
return 'STANDARD' as const;
}
}, []);
// Get the appropriate filter component based on user type
const ClosedRequestsFiltersComponent = useMemo(() => {
return getClosedRequestsFilters(userFilterType);
}, [userFilterType]);
const isDealer = userFilterType === 'DEALER';
const prevFiltersRef = useRef({ const prevFiltersRef = useRef({
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
@ -39,14 +61,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current(storedPage, { fetchRef.current(storedPage, {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, // Only include priority and templateType filters if user is not a dealer
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount }, [isDealer]); // Re-fetch if dealer status changes
// Track filter changes and refetch // Track filter changes and refetch
useEffect(() => { useEffect(() => {
@ -88,7 +111,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
// Page change handler // Page change handler
const handlePageChange = useCallback( const handlePageChange = useCallback(
@ -130,7 +153,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
{/* Filters */} {/* Filters - Plug-and-play pattern */}
<ClosedRequestsFiltersComponent <ClosedRequestsFiltersComponent
searchTerm={filters.searchTerm} searchTerm={filters.searchTerm}
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}
@ -138,7 +161,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
templateTypeFilter={filters.templateTypeFilter} templateTypeFilter={filters.templateTypeFilter}
sortBy={filters.sortBy} sortBy={filters.sortBy}
sortOrder={filters.sortOrder} sortOrder={filters.sortOrder}
activeFiltersCount={filters.activeFiltersCount} activeFiltersCount={
isDealer
? // For dealers: only count search and status (closure type)
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
: // For standard users: count all filters
filters.activeFiltersCount
}
onSearchChange={filters.setSearchTerm} onSearchChange={filters.setSearchTerm}
onPriorityChange={filters.setPriorityFilter} onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter} onStatusChange={filters.setStatusFilter}

View File

@ -98,6 +98,7 @@ export function CreateRequest({
documentErrorModal, documentErrorModal,
openValidationModal, openValidationModal,
closeValidationModal, closeValidationModal,
openPolicyViolationModal,
closePolicyViolationModal, closePolicyViolationModal,
openDocumentErrorModal, openDocumentErrorModal,
closeDocumentErrorModal, closeDocumentErrorModal,
@ -138,6 +139,8 @@ export function CreateRequest({
wizardPrevStep, wizardPrevStep,
user: user!, user: user!,
openValidationModal, openValidationModal,
systemPolicy,
onPolicyViolation: openPolicyViolationModal,
onSubmit, onSubmit,
}); });
@ -222,6 +225,7 @@ export function CreateRequest({
<ApprovalWorkflowStep <ApprovalWorkflowStep
formData={formData} formData={formData}
updateFormData={updateFormData} updateFormData={updateFormData}
systemPolicy={systemPolicy}
onValidationError={(error) => onValidationError={(error) =>
openValidationModal( openValidationModal(
error.type as 'error' | 'self-assign' | 'not-found', error.type as 'error' | 'self-assign' | 'not-found',
@ -229,6 +233,7 @@ export function CreateRequest({
error.message error.message
) )
} }
onPolicyViolation={openPolicyViolationModal}
/> />
); );
case 4: case 4:

View File

@ -9,7 +9,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { RequestTemplate, FormData } from '@/hooks/useCreateRequestForm'; import { RequestTemplate, FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
import { PreviewDocument } from '../types/createRequest.types'; import { PreviewDocument } from '../types/createRequest.types';
import { getDocumentPreviewUrl } from '@/services/workflowApi'; import { getDocumentPreviewUrl } from '@/services/workflowApi';
import { validateApprovers } from './useApproverValidation'; import { validateApprovers } from './useApproverValidation';
@ -29,6 +29,8 @@ interface UseHandlersOptions {
email: string, email: string,
message: string message: string
) => void; ) => void;
systemPolicy?: SystemPolicy;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
onSubmit?: (requestData: any) => void; onSubmit?: (requestData: any) => void;
} }
@ -43,6 +45,8 @@ export function useCreateRequestHandlers({
wizardPrevStep, wizardPrevStep,
user, user,
openValidationModal, openValidationModal,
systemPolicy,
onPolicyViolation,
onSubmit, onSubmit,
}: UseHandlersOptions) { }: UseHandlersOptions) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -93,6 +97,20 @@ export function useCreateRequestHandlers({
// Special validation when leaving step 3 (Approval Workflow) // Special validation when leaving step 3 (Approval Workflow)
if (currentStep === 3) { if (currentStep === 3) {
// Validate approval level count against system policy
if (systemPolicy && onPolicyViolation) {
const approverCount = formData.approverCount || 1;
if (approverCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `The request has ${approverCount} approval levels, which exceeds the maximum allowed (${systemPolicy.maxApprovalLevels}). Please reduce the number of approvers.`,
currentValue: approverCount,
maxValue: systemPolicy.maxApprovalLevels
}]);
return;
}
}
const initiatorEmail = (user as any)?.email?.toLowerCase() || ''; const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
const validation = await validateApprovers( const validation = await validateApprovers(
formData.approvers, formData.approvers,

View File

@ -1,15 +1,16 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useOpenRequestsFilters } from './hooks/useOpenRequestsFilters'; import { useOpenRequestsFilters } from './hooks/useOpenRequestsFilters';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent } 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';
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 { 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, Lock } from 'lucide-react'; import { Calendar, Clock, FileText, AlertCircle, ArrowRight, RefreshCw, CheckCircle, XCircle, Lock, Flame, Target } from 'lucide-react';
import workflowApi from '@/services/workflowApi'; import workflowApi from '@/services/workflowApi';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
interface Request { interface Request {
id: string; id: string;
title: string; title: string;
@ -115,6 +116,40 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
// Use Redux for filters with callback (persists during navigation) // Use Redux for filters with callback (persists during navigation)
const filters = useOpenRequestsFilters(); const filters = useOpenRequestsFilters();
// Get user filter type and corresponding filter component (plug-and-play pattern)
const userFilterType = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return getUserFilterType(userData);
} catch (error) {
console.error('[OpenRequests] Error getting user filter type:', error);
return 'STANDARD' as const;
}
}, []);
// Get the appropriate filter component based on user type
const RequestsFiltersComponent = useMemo(() => {
return getRequestsFilters(userFilterType);
}, [userFilterType]);
// Determine once - use this throughout instead of checking repeatedly
const isDealer = userFilterType === 'DEALER';
// Helper to build filter params for API - excludes dealer-restricted filters
// Since we know user type initially, this helper uses that knowledge
// Note: This doesn't need useCallback since we'll use it inline in effects to avoid dependency issues
const getFilterParams = (includeStatus?: boolean) => {
return {
search: filters.searchTerm || undefined,
// Only include status, priority, and templateType filters if user is not a dealer
status: includeStatus && !isDealer && filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder
};
};
// 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:
// - An approver (with pending/in-progress status) // - An approver (with pending/in-progress status)
@ -192,31 +227,17 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
fetchRequestsRef.current = fetchRequests; fetchRequestsRef.current = fetchRequests;
const handleRefresh = () => { const handleRefresh = useCallback(() => {
setRefreshing(true); setRefreshing(true);
fetchRequests(filters.currentPage, { fetchRequests(filters.currentPage, getFilterParams(true));
search: filters.searchTerm || undefined, }, [filters.currentPage, fetchRequests]);
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder
});
};
const handlePageChange = (newPage: number) => { const handlePageChange = useCallback((newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) { if (newPage >= 1 && newPage <= totalPages) {
filters.setCurrentPage(newPage); filters.setCurrentPage(newPage);
fetchRequests(newPage, { fetchRequests(newPage, getFilterParams(true));
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder
});
} }
}; }, [totalPages, filters, fetchRequests]);
const getPageNumbers = () => { const getPageNumbers = () => {
const pages = []; const pages = [];
@ -243,14 +264,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
if (!hasInitialFetchRun.current) { if (!hasInitialFetchRun.current) {
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
const storedPage = filters.currentPage || 1; const storedPage = filters.currentPage || 1;
fetchRequests(storedPage, { fetchRequests(storedPage, getFilterParams(true));
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount }, []); // Only on mount
@ -263,19 +277,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
// Debounce search // Debounce search
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); // Reset to page 1 when filters change filters.setCurrentPage(1); // Reset to page 1 when filters change
fetchRequests(1, { fetchRequests(1, getFilterParams(true));
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
}, filters.searchTerm ? 500 : 0); }, filters.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
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
// 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
@ -316,119 +323,23 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</div> </div>
</div> </div>
{/* Enhanced Filters Section */} {/* Enhanced Filters Section - Plug-and-play pattern */}
<Card className="shadow-lg border-0"> <RequestsFiltersComponent
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6"> searchTerm={filters.searchTerm}
<div className="flex items-center justify-between"> statusFilter={filters.statusFilter}
<div className="flex items-center gap-2 sm:gap-3"> priorityFilter={filters.priorityFilter}
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg"> templateTypeFilter={filters.templateTypeFilter}
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" /> sortBy={filters.sortBy}
</div> sortOrder={filters.sortOrder}
<div> onSearchChange={filters.setSearchTerm}
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle> onStatusFilterChange={filters.setStatusFilter}
<CardDescription className="text-xs sm:text-sm"> onPriorityFilterChange={filters.setPriorityFilter}
{filters.activeFiltersCount > 0 && ( onTemplateTypeFilterChange={filters.setTemplateTypeFilter}
<span className="text-blue-600 font-medium"> onSortByChange={filters.setSortBy}
{filters.activeFiltersCount} filter{filters.activeFiltersCount > 1 ? 's' : ''} active onSortOrderChange={filters.setSortOrder}
</span> onClearFilters={filters.clearFilters}
)} activeFiltersCount={filters.activeFiltersCount}
</CardDescription> />
</div>
</div>
{filters.activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={filters.clearFilters}
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" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Primary filters */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative">
<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
placeholder="Search requests, IDs..."
value={filters.searchTerm}
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"
/>
</div>
<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">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<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">
<SelectValue placeholder="All Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending (In Approval)</SelectItem>
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
</SelectContent>
</Select>
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
<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 Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<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">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
>
{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>
</div>
</div>
</CardContent>
</Card>
{/* Requests List */} {/* Requests List */}
<div className="space-y-3"> <div className="space-y-3">

View File

@ -32,6 +32,8 @@ interface RequestDetailModalsProps {
actionStatus: any; actionStatus: any;
existingParticipants: any[]; existingParticipants: any[];
currentLevels: any[]; currentLevels: any[];
maxApprovalLevels?: number;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
// Handlers // Handlers
setShowApproveModal: (show: boolean) => void; setShowApproveModal: (show: boolean) => void;
@ -67,6 +69,8 @@ export function RequestDetailModals({
actionStatus, actionStatus,
existingParticipants, existingParticipants,
currentLevels, currentLevels,
maxApprovalLevels,
onPolicyViolation,
setShowApproveModal, setShowApproveModal,
setShowRejectModal, setShowRejectModal,
setShowAddApproverModal, setShowAddApproverModal,
@ -114,6 +118,8 @@ export function RequestDetailModals({
requestTitle={request.title} requestTitle={request.title}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={maxApprovalLevels}
onPolicyViolation={onPolicyViolation}
/> />
{/* Add Spectator Modal */} {/* Add Spectator Modal */}

View File

@ -13,6 +13,8 @@ interface WorkNotesTabProps {
isSpectator: boolean; isSpectator: boolean;
currentLevels: any[]; currentLevels: any[];
onAddApprover: (email: string, tatHours: number, level: number) => Promise<void>; onAddApprover: (email: string, tatHours: number, level: number) => Promise<void>;
maxApprovalLevels?: number;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
export function WorkNotesTab({ export function WorkNotesTab({
@ -24,6 +26,8 @@ export function WorkNotesTab({
isSpectator, isSpectator,
currentLevels, currentLevels,
onAddApprover, onAddApprover,
maxApprovalLevels,
onPolicyViolation,
}: WorkNotesTabProps) { }: WorkNotesTabProps) {
return ( return (
<div className="h-[calc(100vh-300px)] min-h-[600px]"> <div className="h-[calc(100vh-300px)] min-h-[600px]">
@ -37,6 +41,8 @@ export function WorkNotesTab({
isSpectator={isSpectator} isSpectator={isSpectator}
currentLevels={currentLevels} currentLevels={currentLevels}
onAddApprover={onAddApprover} onAddApprover={onAddApprover}
maxApprovalLevels={maxApprovalLevels}
onPolicyViolation={onPolicyViolation}
/> />
</div> </div>
); );

View File

@ -25,6 +25,9 @@ import { useUserSearch } from './hooks/useUserSearch';
// Utils // Utils
import { transformRequests } from './utils/requestTransformers'; import { transformRequests } from './utils/requestTransformers';
import { exportRequestsToCSV } from './utils/csvExports'; import { exportRequestsToCSV } from './utils/csvExports';
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getUserAllRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
// Services // Services
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService'; import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
@ -32,22 +35,60 @@ import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './s
// Types // Types
import type { RequestsProps, BackendStats } from './types/requests.types'; import type { RequestsProps, BackendStats } from './types/requests.types';
// Filter UI components
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
export function UserAllRequests({ onViewRequest }: RequestsProps) { export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Filters hook // Filters hook
const filters = useRequestsFilters(); const filters = useRequestsFilters();
// Get user filter type and corresponding filter component (plug-and-play pattern)
// Determine once at the beginning - no need to check repeatedly
const userFilterType = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return getUserFilterType(userData);
} catch (error) {
console.error('[UserAllRequests] Error getting user filter type:', error);
return 'STANDARD' as const;
}
}, []);
// Get the appropriate filter component based on user type
const UserAllRequestsFiltersComponent = useMemo(() => {
return getUserAllRequestsFilters(userFilterType);
}, [userFilterType]);
// Determine once - use this throughout instead of checking repeatedly
const isDealer = userFilterType === 'DEALER';
// Helper to get filters for API - excludes dealer-restricted filters
// Since we know user type initially, this helper uses that knowledge
const getFiltersForApi = useCallback(() => {
const filterOptions = filters.getFilters();
if (isDealer) {
// For dealers, exclude priority, templateType, department, and slaCompliance
const { priority, templateType, department, slaCompliance, ...dealerFilters } = filterOptions;
return dealerFilters;
}
return filterOptions;
}, [filters, isDealer]);
// Helper to calculate active filters count based on user type
const calculateActiveFiltersCount = useCallback(() => {
if (isDealer) {
// For dealers: only count search, status, initiator, approver, and date filters
return !!(
filters.searchTerm ||
filters.statusFilter !== 'all' ||
filters.initiatorFilter !== 'all' ||
filters.approverFilter !== 'all' ||
filters.dateRange !== 'all' ||
filters.customStartDate ||
filters.customEndDate
);
}
// For standard users: count all filters (use existing hasActiveFilters)
return filters.hasActiveFilters;
}, [isDealer, filters]);
// State // State
const [apiRequests, setApiRequests] = useState<any[]>([]); const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@ -157,12 +198,14 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
const fetchBackendStatsRef = useRef(fetchBackendStats); const fetchBackendStatsRef = useRef(fetchBackendStats);
const getFiltersForApiRef = useRef(getFiltersForApi);
// Update refs on each render // Update refs on each render
useEffect(() => { useEffect(() => {
filtersRef.current = filters; filtersRef.current = filters;
fetchBackendStatsRef.current = fetchBackendStats; fetchBackendStatsRef.current = fetchBackendStats;
}, [filters, fetchBackendStats]); getFiltersForApiRef.current = getFiltersForApi;
}, [filters, fetchBackendStats, getFiltersForApi]);
// Fetch requests - OPTIMIZED: Only fetches 10 records per page // Fetch requests - OPTIMIZED: Only fetches 10 records per page
const fetchRequests = useCallback(async (page: number = 1) => { const fetchRequests = useCallback(async (page: number = 1) => {
@ -172,7 +215,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
setApiRequests([]); setApiRequests([]);
} }
const filterOptions = filtersRef.current.getFilters(); const filterOptions = getFiltersForApiRef.current();
const result = await fetchUserParticipantRequestsData({ const result = await fetchUserParticipantRequestsData({
page, page,
itemsPerPage, itemsPerPage,
@ -190,21 +233,22 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [itemsPerPage]); }, [itemsPerPage, filters]);
// Export to CSV // Export to CSV
const handleExportToCSV = useCallback(async () => { const handleExportToCSV = useCallback(async () => {
try { try {
setExporting(true); setExporting(true);
const allData = await fetchAllRequestsForExport(filters.getFilters()); const exportFilters = getFiltersForApi();
await exportRequestsToCSV(allData, filters.getFilters()); const allData = await fetchAllRequestsForExport(exportFilters);
await exportRequestsToCSV(allData, exportFilters);
} catch (error: any) { } catch (error: any) {
console.error('Failed to export requests:', error); console.error('Failed to export requests:', error);
alert('Failed to export requests. Please try again.'); alert('Failed to export requests. Please try again.');
} finally { } finally {
setExporting(false); setExporting(false);
} }
}, [filters]); }, [getFiltersForApi]);
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
@ -216,16 +260,30 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// OPTIMIZED: Uses backend stats API instead of fetching 100 records // OPTIMIZED: Uses backend stats API instead of fetching 100 records
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
const filtersWithoutStatus = { const filtersWithoutStatus: {
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority?: string;
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, templateType?: string;
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined, department?: string;
initiator?: string;
approver?: string;
approverType?: 'current' | 'any';
search?: string;
slaCompliance?: string;
} = {
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined, initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined, approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
}; };
// Only include priority, templateType, department, and slaCompliance if user is not a dealer
if (!isDealer) {
if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter;
if (filters.templateTypeFilter !== 'all') filtersWithoutStatus.templateType = filters.templateTypeFilter;
if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter;
if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter;
}
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month' // Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month'); const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
@ -250,7 +308,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters.dateRange, filters.dateRange,
filters.customStartDate, filters.customStartDate,
filters.customEndDate, filters.customEndDate,
filters.templateTypeFilter filters.templateTypeFilter,
isDealer
// 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
]); ]);
@ -415,342 +474,42 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
}} }}
/> />
{/* Filters */} {/* Filters - Plug-and-play pattern */}
<Card className="border-gray-200 shadow-md" data-testid="requests-filters"> <UserAllRequestsFiltersComponent
<CardContent className="p-4 sm:p-6"> searchTerm={filters.searchTerm}
<div className="flex flex-col gap-4"> statusFilter={filters.statusFilter}
<div className="flex items-center justify-between"> priorityFilter={filters.priorityFilter}
<div className="flex items-center gap-2"> templateTypeFilter={filters.templateTypeFilter}
<Filter className="w-5 h-5 text-muted-foreground" /> departmentFilter={filters.departmentFilter}
<h3 className="font-semibold text-gray-900">Advanced Filters</h3> slaComplianceFilter={filters.slaComplianceFilter}
{filters.hasActiveFilters && ( initiatorFilter={filters.initiatorFilter}
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200"> approverFilter={filters.approverFilter}
Active approverFilterType={filters.approverFilterType}
</Badge> dateRange={filters.dateRange}
)} customStartDate={filters.customStartDate}
</div> customEndDate={filters.customEndDate}
{filters.hasActiveFilters && ( showCustomDatePicker={filters.showCustomDatePicker}
<Button variant="ghost" size="sm" onClick={filters.clearFilters} className="gap-2"> departments={departments}
<RefreshCw className="w-4 h-4" /> loadingDepartments={loadingDepartments}
Clear All initiatorSearch={initiatorSearch}
</Button> approverSearch={approverSearch}
)} onSearchChange={filters.setSearchTerm}
</div> onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter}
<Separator /> onTemplateTypeChange={filters.setTemplateTypeFilter}
onDepartmentChange={filters.setDepartmentFilter}
{/* Primary Filters */} onSlaComplianceChange={filters.setSlaComplianceFilter}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4"> onInitiatorChange={filters.setInitiatorFilter}
<div className="relative md:col-span-3 lg:col-span-1"> onApproverChange={filters.setApproverFilter}
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" /> onApproverTypeChange={filters.setApproverFilterType}
<Input onDateRangeChange={filters.handleDateRangeChange}
placeholder="Search requests..." onCustomStartDateChange={filters.setCustomStartDate}
value={filters.searchTerm} onCustomEndDateChange={filters.setCustomEndDate}
onChange={(e) => filters.setSearchTerm(e.target.value)} onShowCustomDatePickerChange={filters.setShowCustomDatePicker}
className="pl-10 h-10" onApplyCustomDate={filters.handleApplyCustomDate}
data-testid="search-input" onClearFilters={filters.clearFilters}
/> hasActiveFilters={calculateActiveFiltersCount()}
</div> />
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={filters.priorityFilter} onValueChange={filters.setPriorityFilter}>
<SelectTrigger className="h-10" data-testid="priority-filter">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
<SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.departmentFilter}
onValueChange={filters.setDepartmentFilter}
disabled={loadingDepartments || departments.length === 0}
>
<SelectTrigger className="h-10" data-testid="department-filter">
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.slaComplianceFilter} onValueChange={filters.setSlaComplianceFilter}>
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
<SelectValue placeholder="All SLA Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA Status</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{filters.approverFilter !== 'all' && (
<Select
value={filters.approverFilterType}
onValueChange={(value: 'current' | 'any') => filters.setApproverFilterType(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={filters.dateRange} onValueChange={filters.handleDateRangeChange}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{filters.dateRange === 'custom' && (
<Popover open={filters.showCustomDatePicker} onOpenChange={filters.setShowCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{filters.customStartDate && filters.customEndDate
? `${format(filters.customStartDate, 'MMM d, yyyy')} - ${format(filters.customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
filters.setCustomStartDate(date);
if (filters.customEndDate && date > filters.customEndDate) {
filters.setCustomEndDate(date);
}
} else {
filters.setCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={filters.customEndDate ? format(filters.customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
filters.setCustomEndDate(date);
if (filters.customStartDate && date < filters.customStartDate) {
filters.setCustomStartDate(date);
}
} else {
filters.setCustomEndDate(undefined);
}
}}
min={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={filters.handleApplyCustomDate}
disabled={!filters.customStartDate || !filters.customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
filters.setShowCustomDatePicker(false);
filters.setCustomStartDate(undefined);
filters.setCustomEndDate(undefined);
filters.setDateRange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
{/* Requests List */} {/* Requests List */}
<RequestsList <RequestsList

View File

@ -311,3 +311,64 @@ export async function sendCreditNoteToDealer(requestId: string): Promise<any> {
} }
} }
export interface DashboardKPIs {
totalClaims: number;
totalValue: number;
approved: number;
rejected: number;
pending: number;
credited: number;
pendingCredit: number;
approvedValue: number;
rejectedValue: number;
pendingValue: number;
creditedValue: number;
pendingCreditValue: number;
}
export interface CategoryData {
activityType: string;
raised: number;
raisedValue: number;
approved: number;
approvedValue: number;
rejected: number;
rejectedValue: number;
pending: number;
pendingValue: number;
credited: number;
creditedValue: number;
pendingCredit: number;
pendingCreditValue: number;
approvalRate: number;
creditRate: number;
}
export interface DealerDashboardData {
kpis: DashboardKPIs;
categoryData: CategoryData[];
}
/**
* Get dealer dashboard KPIs and category data
* GET /api/v1/dealer-claims/dashboard
*/
export async function getDealerDashboard(
dateRange?: string,
startDate?: string,
endDate?: string
): Promise<DealerDashboardData> {
try {
const params: any = {};
if (dateRange) params.dateRange = dateRange;
if (startDate) params.startDate = startDate;
if (endDate) params.endDate = endDate;
const response = await apiClient.get('/dealer-claims/dashboard', { params });
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error fetching dealer dashboard:', error);
throw error;
}
}

View File

@ -0,0 +1,34 @@
/**
* User Filter Type Detection Utilities
*
* Determines which filter component to use based on user role/job title.
* This allows for plug-and-play filter components per user type.
*/
export type UserFilterType = 'DEALER' | 'STANDARD';
/**
* Check if user is a dealer based on job title
*/
export function isDealerUser(user: any): boolean {
if (!user) return false;
// Check job title
if (user.jobTitle === 'Dealer' || user.jobTitle === 'DEALER') {
return true;
}
return false;
}
/**
* Get the filter type for a user
* Returns the appropriate UserFilterType based on user properties
*/
export function getUserFilterType(user: any): UserFilterType {
if (isDealerUser(user)) {
return 'DEALER';
}
return 'STANDARD';
}