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 { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -27,6 +28,7 @@ import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
import { TokenManager } from '@/utils/tokenManager';
interface AppProps {
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
function AppRoutes({ onLogout }: AppProps) {
const navigate = useNavigate();
@ -573,12 +612,12 @@ function AppRoutes({ onLogout }: AppProps) {
element={<AuthCallback />}
/>
{/* Dashboard */}
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
<Route
path="/"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>
@ -587,7 +626,7 @@ function AppRoutes({ onLogout }: AppProps) {
path="/dashboard"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>

View File

@ -24,6 +24,8 @@ interface AddApproverModalProps {
requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
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({
@ -31,7 +33,9 @@ export function AddApproverModal({
onClose,
onConfirm,
existingParticipants = [],
currentLevels = []
currentLevels = [],
maxApprovalLevels,
onPolicyViolation
}: AddApproverModalProps) {
const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24);
@ -140,6 +144,36 @@ export function AddApproverModal({
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
const existingParticipant = existingParticipants.find(
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.
</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 */}
{currentLevels.length > 0 && (
<div className="space-y-2">

View File

@ -82,6 +82,8 @@ interface WorkNoteChatProps {
isSpectator?: boolean; // Whether current user is a spectator (view-only)
currentLevels?: any[]; // Current approval levels for add approver modal
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
@ -142,7 +144,7 @@ const FileIcon = ({ type }: { type: string }) => {
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 effectiveRequestId = requestId || routeParams.requestId || '';
const [message, setMessage] = useState('');
@ -1815,6 +1817,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
requestTitle={requestInfo.title}
existingParticipants={existingParticipants}
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 { Badge } from '@/components/ui/badge';
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 { ensureUserExists } from '@/services/userApi';
@ -15,6 +15,8 @@ interface ApprovalWorkflowStepProps {
formData: FormData;
updateFormData: (field: keyof FormData, value: any) => 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({
formData,
updateFormData,
onValidationError
onValidationError,
systemPolicy,
onPolicyViolation
}: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -218,17 +222,29 @@ export function ApprovalWorkflowStep({
size="sm"
onClick={() => {
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);
}}
disabled={(formData.approverCount || 1) >= 10}
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
data-testid="approval-workflow-increase-count"
>
<Plus className="w-4 h-4" />
</Button>
</div>
<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>
</div>
</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)
export { CustomRequestDetail } from './pages/RequestDetail';
// Filters
export { StandardRequestsFilters } from './components/RequestsFilters';
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
// Re-export 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 { useModalManager } from '@/hooks/useModalManager';
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)
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
@ -112,6 +114,24 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = 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();
// Custom hooks
@ -179,6 +199,32 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
handleFinalizeConclusion,
} = 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
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@ -521,6 +567,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/>
</TabsContent>
</div>
@ -610,6 +658,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
actionStatus={actionStatus}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal}
@ -628,6 +678,19 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
downloadDocument={downloadDocument}
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;
currentUserName?: string;
onValidate?: (isValid: boolean) => void;
maxApprovalLevels?: number;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
}
export function ClaimApproverSelectionStep({
@ -64,6 +66,8 @@ export function ClaimApproverSelectionStep({
currentUserId = '',
currentUserName = '',
onValidate,
maxApprovalLevels,
onPolicyViolation,
}: ClaimApproverSelectionStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -560,6 +564,30 @@ export function ClaimApproverSelectionStep({
// Calculate insert level based on current shifted level
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 (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) {
try {
@ -728,6 +756,19 @@ export function ClaimApproverSelectionStep({
</CardTitle>
<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.
{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>
</CardHeader>
</Card>
@ -745,7 +786,20 @@ export function ClaimApproverSelectionStep({
</CardHeader>
<CardContent className="space-y-2 pt-4">
{/* 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
type="button"
variant="outline"
@ -1157,6 +1211,21 @@ export function ClaimApproverSelectionStep({
<p className="text-xs text-amber-600 font-medium">
Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
</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>
{/* TAT Input */}

View File

@ -32,6 +32,8 @@ import { toast } from 'sonner';
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
import { useAuth } from '@/contexts/AuthContext';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep)
const CLAIM_STEPS = [
@ -83,6 +85,48 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const [isSubmitting, setIsSubmitting] = useState(false);
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
useEffect(() => {
return () => {
@ -699,6 +743,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (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>
{/* 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>
);
}

View File

@ -30,5 +30,13 @@ export { ClaimManagementWizard } from './components/request-creation/ClaimManage
// Request Detail Screen (Complete standalone screen)
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
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 { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
@ -115,6 +117,24 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = 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();
// Custom hooks
@ -251,6 +271,32 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
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
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@ -639,6 +685,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/>
</TabsContent>
</div>
@ -728,6 +776,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
actionStatus={actionStatus}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal}
@ -746,6 +796,19 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
downloadDocument={downloadDocument}
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 { UserFilterType } from '@/utils/userFilterUtils';
// Import flow modules from src/ level
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
export { CustomFlow, DealerClaimFlow, SharedComponents };
export type { RequestFlowType } from '@/utils/requestTypeUtils';
export type { UserFilterType } from '@/utils/userFilterUtils';

View File

@ -162,9 +162,9 @@ export function useCreateRequestForm(
});
// Load system policy
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS');
const allConfigs = [...workflowConfigs, ...tatConfigs];
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;

View File

@ -1,8 +1,7 @@
import { useCallback, useRef, useEffect } from 'react';
import { useCallback, useRef, useEffect, useMemo } from 'react';
// Components
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
import { ClosedRequestsList } from './components/ClosedRequestsList';
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
@ -14,6 +13,11 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
// 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) {
// Data fetching hook
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
@ -23,6 +27,24 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current = closedRequests.fetchRequests;
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({
searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter,
@ -39,14 +61,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current(storedPage, {
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,
// Only include priority and templateType filters if user is not a dealer
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount
}, [isDealer]); // Re-fetch if dealer status changes
// Track filter changes and refetch
useEffect(() => {
@ -88,7 +111,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
return () => clearTimeout(timeoutId);
// 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
const handlePageChange = useCallback(
@ -130,7 +153,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
onRefresh={handleRefresh}
/>
{/* Filters */}
{/* Filters - Plug-and-play pattern */}
<ClosedRequestsFiltersComponent
searchTerm={filters.searchTerm}
priorityFilter={filters.priorityFilter}
@ -138,7 +161,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
templateTypeFilter={filters.templateTypeFilter}
sortBy={filters.sortBy}
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}
onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter}

View File

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

View File

@ -9,7 +9,7 @@
import { useState } from 'react';
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 { getDocumentPreviewUrl } from '@/services/workflowApi';
import { validateApprovers } from './useApproverValidation';
@ -29,6 +29,8 @@ interface UseHandlersOptions {
email: string,
message: string
) => void;
systemPolicy?: SystemPolicy;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
onSubmit?: (requestData: any) => void;
}
@ -43,6 +45,8 @@ export function useCreateRequestHandlers({
wizardPrevStep,
user,
openValidationModal,
systemPolicy,
onPolicyViolation,
onSubmit,
}: UseHandlersOptions) {
const navigate = useNavigate();
@ -93,6 +97,20 @@ export function useCreateRequestHandlers({
// Special validation when leaving step 3 (Approval Workflow)
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 validation = await validateApprovers(
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
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 { formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
interface Request {
id: string;
title: string;
@ -115,6 +116,40 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
// Use Redux for filters with callback (persists during navigation)
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)
// Note: This endpoint returns only requests where the user is:
// - An approver (with pending/in-progress status)
@ -192,31 +227,17 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
fetchRequestsRef.current = fetchRequests;
const handleRefresh = () => {
const handleRefresh = useCallback(() => {
setRefreshing(true);
fetchRequests(filters.currentPage, {
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
});
};
fetchRequests(filters.currentPage, getFilterParams(true));
}, [filters.currentPage, fetchRequests]);
const handlePageChange = (newPage: number) => {
const handlePageChange = useCallback((newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
filters.setCurrentPage(newPage);
fetchRequests(newPage, {
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
});
fetchRequests(newPage, getFilterParams(true));
}
};
}, [totalPages, filters, fetchRequests]);
const getPageNumbers = () => {
const pages = [];
@ -243,14 +264,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
if (!hasInitialFetchRun.current) {
hasInitialFetchRun.current = true;
const storedPage = filters.currentPage || 1;
fetchRequests(storedPage, {
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,
});
fetchRequests(storedPage, getFilterParams(true));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount
@ -263,19 +277,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
// Debounce search
const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); // Reset to page 1 when filters change
fetchRequests(1, {
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,
});
fetchRequests(1, getFilterParams(true));
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// 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
// No client-side sorting needed anymore
@ -316,119 +323,23 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</div>
</div>
{/* Enhanced Filters Section */}
<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">
{filters.activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{filters.activeFiltersCount} filter{filters.activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</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>
{/* Enhanced Filters Section - Plug-and-play pattern */}
<RequestsFiltersComponent
searchTerm={filters.searchTerm}
statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter}
templateTypeFilter={filters.templateTypeFilter}
sortBy={filters.sortBy}
sortOrder={filters.sortOrder}
onSearchChange={filters.setSearchTerm}
onStatusFilterChange={filters.setStatusFilter}
onPriorityFilterChange={filters.setPriorityFilter}
onTemplateTypeFilterChange={filters.setTemplateTypeFilter}
onSortByChange={filters.setSortBy}
onSortOrderChange={filters.setSortOrder}
onClearFilters={filters.clearFilters}
activeFiltersCount={filters.activeFiltersCount}
/>
{/* Requests List */}
<div className="space-y-3">

View File

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

View File

@ -13,6 +13,8 @@ interface WorkNotesTabProps {
isSpectator: boolean;
currentLevels: any[];
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({
@ -24,6 +26,8 @@ export function WorkNotesTab({
isSpectator,
currentLevels,
onAddApprover,
maxApprovalLevels,
onPolicyViolation,
}: WorkNotesTabProps) {
return (
<div className="h-[calc(100vh-300px)] min-h-[600px]">
@ -37,6 +41,8 @@ export function WorkNotesTab({
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={onAddApprover}
maxApprovalLevels={maxApprovalLevels}
onPolicyViolation={onPolicyViolation}
/>
</div>
);

View File

@ -25,6 +25,9 @@ import { useUserSearch } from './hooks/useUserSearch';
// Utils
import { transformRequests } from './utils/requestTransformers';
import { exportRequestsToCSV } from './utils/csvExports';
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getUserAllRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
// Services
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
@ -32,22 +35,60 @@ import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './s
// 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) {
// Filters hook
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
const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
@ -157,12 +198,14 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters);
const fetchBackendStatsRef = useRef(fetchBackendStats);
const getFiltersForApiRef = useRef(getFiltersForApi);
// Update refs on each render
useEffect(() => {
filtersRef.current = filters;
fetchBackendStatsRef.current = fetchBackendStats;
}, [filters, fetchBackendStats]);
getFiltersForApiRef.current = getFiltersForApi;
}, [filters, fetchBackendStats, getFiltersForApi]);
// Fetch requests - OPTIMIZED: Only fetches 10 records per page
const fetchRequests = useCallback(async (page: number = 1) => {
@ -172,7 +215,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
setApiRequests([]);
}
const filterOptions = filtersRef.current.getFilters();
const filterOptions = getFiltersForApiRef.current();
const result = await fetchUserParticipantRequestsData({
page,
itemsPerPage,
@ -190,21 +233,22 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
} finally {
setLoading(false);
}
}, [itemsPerPage]);
}, [itemsPerPage, filters]);
// Export to CSV
const handleExportToCSV = useCallback(async () => {
try {
setExporting(true);
const allData = await fetchAllRequestsForExport(filters.getFilters());
await exportRequestsToCSV(allData, filters.getFilters());
const exportFilters = getFiltersForApi();
const allData = await fetchAllRequestsForExport(exportFilters);
await exportRequestsToCSV(allData, exportFilters);
} catch (error: any) {
console.error('Failed to export requests:', error);
alert('Failed to export requests. Please try again.');
} finally {
setExporting(false);
}
}, [filters]);
}, [getFiltersForApi]);
// Initial fetch
useEffect(() => {
@ -216,16 +260,30 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
useEffect(() => {
const timeoutId = setTimeout(() => {
const filtersWithoutStatus = {
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
const filtersWithoutStatus: {
priority?: string;
templateType?: string;
department?: string;
initiator?: string;
approver?: string;
approverType?: 'current' | 'any';
search?: string;
slaCompliance?: string;
} = {
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : 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'
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
@ -250,7 +308,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters.dateRange,
filters.customStartDate,
filters.customEndDate,
filters.templateTypeFilter
filters.templateTypeFilter,
isDealer
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
]);
@ -415,342 +474,42 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
}}
/>
{/* Filters */}
<Card className="border-gray-200 shadow-md" data-testid="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>
{filters.hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{filters.hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={filters.clearFilters} 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={filters.searchTerm}
onChange={(e) => filters.setSearchTerm(e.target.value)}
className="pl-10 h-10"
data-testid="search-input"
/>
</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>
{/* Filters - Plug-and-play pattern */}
<UserAllRequestsFiltersComponent
searchTerm={filters.searchTerm}
statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter}
templateTypeFilter={filters.templateTypeFilter}
departmentFilter={filters.departmentFilter}
slaComplianceFilter={filters.slaComplianceFilter}
initiatorFilter={filters.initiatorFilter}
approverFilter={filters.approverFilter}
approverFilterType={filters.approverFilterType}
dateRange={filters.dateRange}
customStartDate={filters.customStartDate}
customEndDate={filters.customEndDate}
showCustomDatePicker={filters.showCustomDatePicker}
departments={departments}
loadingDepartments={loadingDepartments}
initiatorSearch={initiatorSearch}
approverSearch={approverSearch}
onSearchChange={filters.setSearchTerm}
onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter}
onDepartmentChange={filters.setDepartmentFilter}
onSlaComplianceChange={filters.setSlaComplianceFilter}
onInitiatorChange={filters.setInitiatorFilter}
onApproverChange={filters.setApproverFilter}
onApproverTypeChange={filters.setApproverFilterType}
onDateRangeChange={filters.handleDateRangeChange}
onCustomStartDateChange={filters.setCustomStartDate}
onCustomEndDateChange={filters.setCustomEndDate}
onShowCustomDatePickerChange={filters.setShowCustomDatePicker}
onApplyCustomDate={filters.handleApplyCustomDate}
onClearFilters={filters.clearFilters}
hasActiveFilters={calculateActiveFiltersCount()}
/>
{/* Requests List */}
<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';
}