templates checked for the dealer claim and dashboard added for the dealer
This commit is contained in:
parent
7893b52183
commit
164d576ea0
45
src/App.tsx
45
src/App.tsx
@ -10,6 +10,7 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
|
|||||||
import { WorkNotes } from '@/pages/WorkNotes';
|
import { WorkNotes } from '@/pages/WorkNotes';
|
||||||
import { CreateRequest } from '@/pages/CreateRequest';
|
import { CreateRequest } from '@/pages/CreateRequest';
|
||||||
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
||||||
|
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
|
||||||
import { MyRequests } from '@/pages/MyRequests';
|
import { MyRequests } from '@/pages/MyRequests';
|
||||||
import { Requests } from '@/pages/Requests/Requests';
|
import { Requests } from '@/pages/Requests/Requests';
|
||||||
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
||||||
@ -27,6 +28,7 @@ import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
|||||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
import { createClaimRequest } from '@/services/dealerClaimApi';
|
||||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@ -48,6 +50,43 @@ function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component to conditionally render Dashboard or DealerDashboard based on user job title
|
||||||
|
function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: string) => void; onNewRequest?: () => void }) {
|
||||||
|
const [isDealer, setIsDealer] = useState<boolean>(false);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
setIsDealer(userData?.jobTitle === 'Dealer');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[App] Error checking dealer status:', error);
|
||||||
|
setIsDealer(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render dealer-specific dashboard if user is a dealer
|
||||||
|
if (isDealer) {
|
||||||
|
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular dashboard for all other users
|
||||||
|
return <Dashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Main Application Routes Component
|
// Main Application Routes Component
|
||||||
function AppRoutes({ onLogout }: AppProps) {
|
function AppRoutes({ onLogout }: AppProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -573,12 +612,12 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
element={<AuthCallback />}
|
element={<AuthCallback />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dashboard */}
|
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
|
||||||
<Route
|
<Route
|
||||||
path="/"
|
path="/"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -587,7 +626,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -24,6 +24,8 @@ interface AddApproverModalProps {
|
|||||||
requestTitle?: string;
|
requestTitle?: string;
|
||||||
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
||||||
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
||||||
|
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddApproverModal({
|
export function AddApproverModal({
|
||||||
@ -31,7 +33,9 @@ export function AddApproverModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
existingParticipants = [],
|
existingParticipants = [],
|
||||||
currentLevels = []
|
currentLevels = [],
|
||||||
|
maxApprovalLevels,
|
||||||
|
onPolicyViolation
|
||||||
}: AddApproverModalProps) {
|
}: AddApproverModalProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [tatHours, setTatHours] = useState<number>(24);
|
const [tatHours, setTatHours] = useState<number>(24);
|
||||||
@ -140,6 +144,36 @@ export function AddApproverModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate against maxApprovalLevels policy
|
||||||
|
// Calculate the new total levels after adding this approver
|
||||||
|
// If inserting at a level that already exists, levels shift down, so total stays same
|
||||||
|
// If inserting at a new level (beyond current), total increases
|
||||||
|
const currentMaxLevel = currentLevels.length > 0
|
||||||
|
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
|
||||||
|
: 0;
|
||||||
|
const newTotalLevels = selectedLevel > currentMaxLevel
|
||||||
|
? selectedLevel // New level beyond current max
|
||||||
|
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
|
||||||
|
|
||||||
|
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
|
||||||
|
if (onPolicyViolation) {
|
||||||
|
onPolicyViolation([{
|
||||||
|
type: 'Maximum Approval Levels Exceeded',
|
||||||
|
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
|
||||||
|
currentValue: newTotalLevels,
|
||||||
|
maxValue: maxApprovalLevels
|
||||||
|
}]);
|
||||||
|
} else {
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is already a participant
|
// Check if user is already a participant
|
||||||
const existingParticipant = existingParticipants.find(
|
const existingParticipant = existingParticipants.find(
|
||||||
p => (p.email || '').toLowerCase() === emailToAdd
|
p => (p.email || '').toLowerCase() === emailToAdd
|
||||||
@ -394,6 +428,20 @@ export function AddApproverModal({
|
|||||||
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Max Approval Levels Note */}
|
||||||
|
{maxApprovalLevels && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
ℹ️ Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||||||
|
{currentLevels.length > 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current Levels Display */}
|
{/* Current Levels Display */}
|
||||||
{currentLevels.length > 0 && (
|
{currentLevels.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -82,6 +82,8 @@ interface WorkNoteChatProps {
|
|||||||
isSpectator?: boolean; // Whether current user is a spectator (view-only)
|
isSpectator?: boolean; // Whether current user is a spectator (view-only)
|
||||||
currentLevels?: any[]; // Current approval levels for add approver modal
|
currentLevels?: any[]; // Current approval levels for add approver modal
|
||||||
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
|
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
|
||||||
|
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void; // Callback for policy violations
|
||||||
}
|
}
|
||||||
|
|
||||||
// All data is now fetched from backend - no hardcoded mock data
|
// All data is now fetched from backend - no hardcoded mock data
|
||||||
@ -142,7 +144,7 @@ const FileIcon = ({ type }: { type: string }) => {
|
|||||||
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) {
|
export function WorkNoteChat({ requestId, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, isSpectator = false, currentLevels = [], onAddApprover, maxApprovalLevels, onPolicyViolation }: WorkNoteChatProps) {
|
||||||
const routeParams = useParams<{ requestId: string }>();
|
const routeParams = useParams<{ requestId: string }>();
|
||||||
const effectiveRequestId = requestId || routeParams.requestId || '';
|
const effectiveRequestId = requestId || routeParams.requestId || '';
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@ -1815,6 +1817,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
requestTitle={requestInfo.title}
|
requestTitle={requestInfo.title}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
|
maxApprovalLevels={maxApprovalLevels}
|
||||||
|
onPolicyViolation={onPolicyViolation}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
|
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
|
||||||
import { FormData } from '@/hooks/useCreateRequestForm';
|
import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
|
||||||
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
||||||
import { ensureUserExists } from '@/services/userApi';
|
import { ensureUserExists } from '@/services/userApi';
|
||||||
|
|
||||||
@ -15,6 +15,8 @@ interface ApprovalWorkflowStepProps {
|
|||||||
formData: FormData;
|
formData: FormData;
|
||||||
updateFormData: (field: keyof FormData, value: any) => void;
|
updateFormData: (field: keyof FormData, value: any) => void;
|
||||||
onValidationError: (error: { type: string; email: string; message: string }) => void;
|
onValidationError: (error: { type: string; email: string; message: string }) => void;
|
||||||
|
systemPolicy: SystemPolicy;
|
||||||
|
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +35,9 @@ interface ApprovalWorkflowStepProps {
|
|||||||
export function ApprovalWorkflowStep({
|
export function ApprovalWorkflowStep({
|
||||||
formData,
|
formData,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
onValidationError
|
onValidationError,
|
||||||
|
systemPolicy,
|
||||||
|
onPolicyViolation
|
||||||
}: ApprovalWorkflowStepProps) {
|
}: ApprovalWorkflowStepProps) {
|
||||||
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
||||||
|
|
||||||
@ -218,17 +222,29 @@ export function ApprovalWorkflowStep({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentCount = formData.approverCount || 1;
|
const currentCount = formData.approverCount || 1;
|
||||||
const newCount = Math.min(10, currentCount + 1);
|
const newCount = currentCount + 1;
|
||||||
|
|
||||||
|
// Validate against system policy
|
||||||
|
if (newCount > systemPolicy.maxApprovalLevels) {
|
||||||
|
onPolicyViolation([{
|
||||||
|
type: 'Maximum Approval Levels Exceeded',
|
||||||
|
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
|
||||||
|
currentValue: newCount,
|
||||||
|
maxValue: systemPolicy.maxApprovalLevels
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
updateFormData('approverCount', newCount);
|
updateFormData('approverCount', newCount);
|
||||||
}}
|
}}
|
||||||
disabled={(formData.approverCount || 1) >= 10}
|
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
|
||||||
data-testid="approval-workflow-increase-count"
|
data-testid="approval-workflow-increase-count"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
Maximum 10 approvers allowed. Each approver will review sequentially.
|
Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
172
src/custom/components/ClosedRequestsFilters.tsx
Normal file
172
src/custom/components/ClosedRequestsFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
161
src/custom/components/RequestsFilters.tsx
Normal file
161
src/custom/components/RequestsFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
458
src/custom/components/UserAllRequestsFilters.tsx
Normal file
458
src/custom/components/UserAllRequestsFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -23,5 +23,10 @@ export { CreateRequest as CustomCreateRequest } from './components/request-creat
|
|||||||
// Request Detail Screen (Complete standalone screen)
|
// Request Detail Screen (Complete standalone screen)
|
||||||
export { CustomRequestDetail } from './pages/RequestDetail';
|
export { CustomRequestDetail } from './pages/RequestDetail';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
export { StandardRequestsFilters } from './components/RequestsFilters';
|
||||||
|
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
|
||||||
|
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
||||||
|
|||||||
@ -37,6 +37,8 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
|
|||||||
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
||||||
import { useModalManager } from '@/hooks/useModalManager';
|
import { useModalManager } from '@/hooks/useModalManager';
|
||||||
import { downloadDocument } from '@/services/workflowApi';
|
import { downloadDocument } from '@/services/workflowApi';
|
||||||
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||||
|
|
||||||
// Custom Request Components (import from index to get properly aliased exports)
|
// Custom Request Components (import from index to get properly aliased exports)
|
||||||
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
|
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
|
||||||
@ -112,6 +114,24 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
const [showResumeModal, setShowResumeModal] = useState(false);
|
||||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||||
|
const [systemPolicy, setSystemPolicy] = useState<{
|
||||||
|
maxApprovalLevels: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
allowSpectators: boolean;
|
||||||
|
maxSpectators: number;
|
||||||
|
}>({
|
||||||
|
maxApprovalLevels: 10,
|
||||||
|
maxParticipants: 50,
|
||||||
|
allowSpectators: true,
|
||||||
|
maxSpectators: 20
|
||||||
|
});
|
||||||
|
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
violations: []
|
||||||
|
});
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
@ -179,6 +199,32 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
handleFinalizeConclusion,
|
handleFinalizeConclusion,
|
||||||
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
|
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
|
||||||
|
|
||||||
|
// Load system policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSystemPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSystemPolicy({
|
||||||
|
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
||||||
|
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
||||||
|
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
||||||
|
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system policy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSystemPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-switch tab when URL query parameter changes
|
// Auto-switch tab when URL query parameter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@ -521,6 +567,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
onAddApprover={handleAddApprover}
|
onAddApprover={handleAddApprover}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
@ -610,6 +658,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
actionStatus={actionStatus}
|
actionStatus={actionStatus}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
setShowApproveModal={setShowApproveModal}
|
setShowApproveModal={setShowApproveModal}
|
||||||
setShowRejectModal={setShowRejectModal}
|
setShowRejectModal={setShowRejectModal}
|
||||||
setShowAddApproverModal={setShowAddApproverModal}
|
setShowAddApproverModal={setShowAddApproverModal}
|
||||||
@ -628,6 +678,19 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
downloadDocument={downloadDocument}
|
downloadDocument={downloadDocument}
|
||||||
documentPolicy={documentPolicy}
|
documentPolicy={documentPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Policy Violation Modal */}
|
||||||
|
<PolicyViolationModal
|
||||||
|
open={policyViolationModal.open}
|
||||||
|
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
||||||
|
violations={policyViolationModal.violations}
|
||||||
|
policyDetails={{
|
||||||
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
||||||
|
maxParticipants: systemPolicy.maxParticipants,
|
||||||
|
allowSpectators: systemPolicy.allowSpectators,
|
||||||
|
maxSpectators: systemPolicy.maxSpectators,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/dealer-claim/components/DealerClosedRequestsFilters.tsx
Normal file
142
src/dealer-claim/components/DealerClosedRequestsFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
114
src/dealer-claim/components/DealerRequestsFilters.tsx
Normal file
114
src/dealer-claim/components/DealerRequestsFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
390
src/dealer-claim/components/DealerUserAllRequestsFilters.tsx
Normal file
390
src/dealer-claim/components/DealerUserAllRequestsFilters.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -54,6 +54,8 @@ interface ClaimApproverSelectionStepProps {
|
|||||||
currentUserId?: string;
|
currentUserId?: string;
|
||||||
currentUserName?: string;
|
currentUserName?: string;
|
||||||
onValidate?: (isValid: boolean) => void;
|
onValidate?: (isValid: boolean) => void;
|
||||||
|
maxApprovalLevels?: number;
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaimApproverSelectionStep({
|
export function ClaimApproverSelectionStep({
|
||||||
@ -64,6 +66,8 @@ export function ClaimApproverSelectionStep({
|
|||||||
currentUserId = '',
|
currentUserId = '',
|
||||||
currentUserName = '',
|
currentUserName = '',
|
||||||
onValidate,
|
onValidate,
|
||||||
|
maxApprovalLevels,
|
||||||
|
onPolicyViolation,
|
||||||
}: ClaimApproverSelectionStepProps) {
|
}: ClaimApproverSelectionStepProps) {
|
||||||
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
||||||
|
|
||||||
@ -560,6 +564,30 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Calculate insert level based on current shifted level
|
// Calculate insert level based on current shifted level
|
||||||
const insertLevel = currentLevelAfter + 1;
|
const insertLevel = currentLevelAfter + 1;
|
||||||
|
|
||||||
|
// Validate max approval levels
|
||||||
|
if (maxApprovalLevels) {
|
||||||
|
// Calculate total levels after adding the new approver
|
||||||
|
// After shifting, we'll have the same number of unique levels + 1 (the new approver)
|
||||||
|
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
|
||||||
|
const newTotalLevels = currentUniqueLevels + 1;
|
||||||
|
|
||||||
|
if (newTotalLevels > maxApprovalLevels) {
|
||||||
|
const violations = [{
|
||||||
|
type: 'max_approval_levels',
|
||||||
|
message: `Adding this approver would create ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove some approvers before adding a new one.`,
|
||||||
|
currentValue: newTotalLevels,
|
||||||
|
maxValue: maxApprovalLevels
|
||||||
|
}];
|
||||||
|
|
||||||
|
if (onPolicyViolation) {
|
||||||
|
onPolicyViolation(violations);
|
||||||
|
} else {
|
||||||
|
toast.error(violations[0]?.message || 'Maximum approval levels exceeded');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If user was NOT selected via @ search, validate against Okta
|
// If user was NOT selected via @ search, validate against Okta
|
||||||
if (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) {
|
if (!selectedAddApproverUser || selectedAddApproverUser.email.toLowerCase() !== emailToAdd) {
|
||||||
try {
|
try {
|
||||||
@ -728,6 +756,19 @@ export function ClaimApproverSelectionStep({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-blue-700">
|
<CardDescription className="text-blue-700">
|
||||||
Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for "Department Lead Approval" only. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for "Department Lead Approval" only. Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
||||||
|
{maxApprovalLevels && (
|
||||||
|
<span className="block mt-2 text-gray-600">
|
||||||
|
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||||||
|
{(() => {
|
||||||
|
const approvers = formData.approvers || [];
|
||||||
|
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
|
||||||
|
const currentCount = allLevels.size;
|
||||||
|
return currentCount > 0 ? (
|
||||||
|
<span> ({currentCount}/{maxApprovalLevels})</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
@ -745,7 +786,20 @@ export function ClaimApproverSelectionStep({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2 pt-4">
|
<CardContent className="space-y-2 pt-4">
|
||||||
{/* Add Additional Approver Button */}
|
{/* Add Additional Approver Button */}
|
||||||
<div className="mb-4 flex justify-end">
|
<div className="mb-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
|
{maxApprovalLevels && (
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||||||
|
{(() => {
|
||||||
|
const approvers = formData.approvers || [];
|
||||||
|
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
|
||||||
|
const currentCount = allLevels.size;
|
||||||
|
return currentCount > 0 ? (
|
||||||
|
<span> ({currentCount}/{maxApprovalLevels})</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -1157,6 +1211,21 @@ export function ClaimApproverSelectionStep({
|
|||||||
<p className="text-xs text-amber-600 font-medium">
|
<p className="text-xs text-amber-600 font-medium">
|
||||||
⚠️ Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
⚠️ Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Max Approval Levels Note */}
|
||||||
|
{maxApprovalLevels && (
|
||||||
|
<p className="text-xs text-gray-600 mt-2">
|
||||||
|
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||||||
|
{(() => {
|
||||||
|
const approvers = formData.approvers || [];
|
||||||
|
const allLevels = new Set(approvers.map((a: ClaimApprover) => a.level));
|
||||||
|
const currentCount = allLevels.size;
|
||||||
|
return currentCount > 0 ? (
|
||||||
|
<span> ({currentCount}/{maxApprovalLevels})</span>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TAT Input */}
|
{/* TAT Input */}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ import { toast } from 'sonner';
|
|||||||
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
|
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
|
||||||
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||||
|
|
||||||
// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep)
|
// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep)
|
||||||
const CLAIM_STEPS = [
|
const CLAIM_STEPS = [
|
||||||
@ -82,6 +84,48 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
const dealerSearchTimer = useRef<any>(null);
|
const dealerSearchTimer = useRef<any>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// System policy state
|
||||||
|
const [systemPolicy, setSystemPolicy] = useState({
|
||||||
|
maxApprovalLevels: 10,
|
||||||
|
maxParticipants: 50,
|
||||||
|
allowSpectators: true,
|
||||||
|
maxSpectators: 20
|
||||||
|
});
|
||||||
|
|
||||||
|
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
violations: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load system policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSystemPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSystemPolicy({
|
||||||
|
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
||||||
|
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
||||||
|
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
||||||
|
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system policy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSystemPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Cleanup timeout on unmount
|
// Cleanup timeout on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -699,6 +743,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
||||||
: (user as any)?.email || 'User')
|
: (user as any)?.email || 'User')
|
||||||
}
|
}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -1052,6 +1098,19 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Policy Violation Modal */}
|
||||||
|
<PolicyViolationModal
|
||||||
|
open={policyViolationModal.open}
|
||||||
|
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
||||||
|
violations={policyViolationModal.violations}
|
||||||
|
policyDetails={{
|
||||||
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
||||||
|
maxParticipants: systemPolicy.maxParticipants,
|
||||||
|
allowSpectators: systemPolicy.allowSpectators,
|
||||||
|
maxSpectators: systemPolicy.maxSpectators,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,5 +30,13 @@ export { ClaimManagementWizard } from './components/request-creation/ClaimManage
|
|||||||
// Request Detail Screen (Complete standalone screen)
|
// Request Detail Screen (Complete standalone screen)
|
||||||
export { DealerClaimRequestDetail } from './pages/RequestDetail';
|
export { DealerClaimRequestDetail } from './pages/RequestDetail';
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export { DealerDashboard } from './pages/Dashboard';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
export { DealerRequestsFilters } from './components/DealerRequestsFilters';
|
||||||
|
export { DealerClosedRequestsFilters } from './components/DealerClosedRequestsFilters';
|
||||||
|
export { DealerUserAllRequestsFilters } from './components/DealerUserAllRequestsFilters';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
||||||
|
|||||||
687
src/dealer-claim/pages/Dashboard.tsx
Normal file
687
src/dealer-claim/pages/Dashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -38,6 +38,8 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
|
|||||||
import { useModalManager } from '@/hooks/useModalManager';
|
import { useModalManager } from '@/hooks/useModalManager';
|
||||||
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
||||||
import { downloadDocument } from '@/services/workflowApi';
|
import { downloadDocument } from '@/services/workflowApi';
|
||||||
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||||
import { getSocket, joinUserRoom } from '@/utils/socket';
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||||
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
||||||
|
|
||||||
@ -115,6 +117,24 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
const [showResumeModal, setShowResumeModal] = useState(false);
|
||||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||||
|
const [systemPolicy, setSystemPolicy] = useState<{
|
||||||
|
maxApprovalLevels: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
allowSpectators: boolean;
|
||||||
|
maxSpectators: number;
|
||||||
|
}>({
|
||||||
|
maxApprovalLevels: 10,
|
||||||
|
maxParticipants: 50,
|
||||||
|
allowSpectators: true,
|
||||||
|
maxSpectators: 20
|
||||||
|
});
|
||||||
|
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
violations: []
|
||||||
|
});
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
@ -251,6 +271,32 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setShowActionStatusModal
|
setShowActionStatusModal
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load system policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSystemPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSystemPolicy({
|
||||||
|
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
||||||
|
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
||||||
|
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
||||||
|
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system policy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSystemPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-switch tab when URL query parameter changes
|
// Auto-switch tab when URL query parameter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@ -639,6 +685,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
onAddApprover={handleAddApprover}
|
onAddApprover={handleAddApprover}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
@ -728,6 +776,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
actionStatus={actionStatus}
|
actionStatus={actionStatus}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
setShowApproveModal={setShowApproveModal}
|
setShowApproveModal={setShowApproveModal}
|
||||||
setShowRejectModal={setShowRejectModal}
|
setShowRejectModal={setShowRejectModal}
|
||||||
setShowAddApproverModal={setShowAddApproverModal}
|
setShowAddApproverModal={setShowAddApproverModal}
|
||||||
@ -746,6 +796,19 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
downloadDocument={downloadDocument}
|
downloadDocument={downloadDocument}
|
||||||
documentPolicy={documentPolicy}
|
documentPolicy={documentPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Policy Violation Modal */}
|
||||||
|
<PolicyViolationModal
|
||||||
|
open={policyViolationModal.open}
|
||||||
|
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
||||||
|
violations={policyViolationModal.violations}
|
||||||
|
policyDetails={{
|
||||||
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
||||||
|
maxParticipants: systemPolicy.maxParticipants,
|
||||||
|
allowSpectators: systemPolicy.allowSpectators,
|
||||||
|
maxSpectators: systemPolicy.maxSpectators,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/flows.ts
74
src/flows.ts
@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RequestFlowType } from '@/utils/requestTypeUtils';
|
import { RequestFlowType } from '@/utils/requestTypeUtils';
|
||||||
|
import { UserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
|
||||||
// Import flow modules from src/ level
|
// Import flow modules from src/ level
|
||||||
import * as CustomFlow from './custom';
|
import * as CustomFlow from './custom';
|
||||||
@ -88,6 +89,79 @@ export function getRequestDetailScreen(flowType: RequestFlowType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Requests Filters component for a user filter type
|
||||||
|
* Each user type can have its own filter component
|
||||||
|
*
|
||||||
|
* This allows for plug-and-play filter components:
|
||||||
|
* - DEALER: Simplified filters (search + sort only)
|
||||||
|
* - STANDARD: Full filters (search + status + priority + template + sort)
|
||||||
|
*
|
||||||
|
* To add a new user filter type:
|
||||||
|
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
||||||
|
* 2. Create a filter component in the appropriate flow folder
|
||||||
|
* 3. Export it from the flow's index.ts
|
||||||
|
* 4. Add a case here to return it
|
||||||
|
*/
|
||||||
|
export function getRequestsFilters(userFilterType: UserFilterType) {
|
||||||
|
switch (userFilterType) {
|
||||||
|
case 'DEALER':
|
||||||
|
return DealerClaimFlow.DealerRequestsFilters;
|
||||||
|
case 'STANDARD':
|
||||||
|
default:
|
||||||
|
return CustomFlow.StandardRequestsFilters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Closed Requests Filters component for a user filter type
|
||||||
|
* Each user type can have its own filter component for closed requests
|
||||||
|
*
|
||||||
|
* This allows for plug-and-play filter components:
|
||||||
|
* - DEALER: Simplified filters (search + status + sort only, no priority or template)
|
||||||
|
* - STANDARD: Full filters (search + priority + status + template + sort)
|
||||||
|
*
|
||||||
|
* To add a new user filter type:
|
||||||
|
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
||||||
|
* 2. Create a closed requests filter component in the appropriate flow folder
|
||||||
|
* 3. Export it from the flow's index.ts
|
||||||
|
* 4. Add a case here to return it
|
||||||
|
*/
|
||||||
|
export function getClosedRequestsFilters(userFilterType: UserFilterType) {
|
||||||
|
switch (userFilterType) {
|
||||||
|
case 'DEALER':
|
||||||
|
return DealerClaimFlow.DealerClosedRequestsFilters;
|
||||||
|
case 'STANDARD':
|
||||||
|
default:
|
||||||
|
return CustomFlow.StandardClosedRequestsFilters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get User All Requests Filters component for a user filter type
|
||||||
|
* Each user type can have its own filter component for user all requests
|
||||||
|
*
|
||||||
|
* This allows for plug-and-play filter components:
|
||||||
|
* - DEALER: Simplified filters (search + status + initiator + approver + date range, no priority/template/department/sla)
|
||||||
|
* - STANDARD: Full filters (all filters including priority, template, department, and SLA compliance)
|
||||||
|
*
|
||||||
|
* To add a new user filter type:
|
||||||
|
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
||||||
|
* 2. Create a user all requests filter component in the appropriate flow folder
|
||||||
|
* 3. Export it from the flow's index.ts
|
||||||
|
* 4. Add a case here to return it
|
||||||
|
*/
|
||||||
|
export function getUserAllRequestsFilters(userFilterType: UserFilterType) {
|
||||||
|
switch (userFilterType) {
|
||||||
|
case 'DEALER':
|
||||||
|
return DealerClaimFlow.DealerUserAllRequestsFilters;
|
||||||
|
case 'STANDARD':
|
||||||
|
default:
|
||||||
|
return CustomFlow.StandardUserAllRequestsFilters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export flow modules for direct access
|
// Re-export flow modules for direct access
|
||||||
export { CustomFlow, DealerClaimFlow, SharedComponents };
|
export { CustomFlow, DealerClaimFlow, SharedComponents };
|
||||||
export type { RequestFlowType } from '@/utils/requestTypeUtils';
|
export type { RequestFlowType } from '@/utils/requestTypeUtils';
|
||||||
|
export type { UserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
|||||||
@ -162,9 +162,9 @@ export function useCreateRequestForm(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load system policy
|
// Load system policy
|
||||||
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS');
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
const allConfigs = [...workflowConfigs, ...tatConfigs];
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
const configMap: Record<string, string> = {};
|
const configMap: Record<string, string> = {};
|
||||||
allConfigs.forEach((c: AdminConfiguration) => {
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
configMap[c.configKey] = c.configValue;
|
configMap[c.configKey] = c.configValue;
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import { useCallback, useRef, useEffect } from 'react';
|
import { useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
||||||
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
|
|
||||||
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
||||||
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
||||||
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
||||||
@ -14,6 +13,11 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
|||||||
// Types
|
// Types
|
||||||
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
||||||
|
|
||||||
|
// Utils & Factory
|
||||||
|
import { getUserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
import { getClosedRequestsFilters } from '@/flows';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||||
// Data fetching hook
|
// Data fetching hook
|
||||||
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
||||||
@ -23,6 +27,24 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
fetchRef.current = closedRequests.fetchRequests;
|
fetchRef.current = closedRequests.fetchRequests;
|
||||||
|
|
||||||
const filters = useClosedRequestsFilters();
|
const filters = useClosedRequestsFilters();
|
||||||
|
|
||||||
|
// Get user filter type and corresponding filter component (plug-and-play pattern)
|
||||||
|
const userFilterType = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
return getUserFilterType(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClosedRequests] Error getting user filter type:', error);
|
||||||
|
return 'STANDARD' as const;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get the appropriate filter component based on user type
|
||||||
|
const ClosedRequestsFiltersComponent = useMemo(() => {
|
||||||
|
return getClosedRequestsFilters(userFilterType);
|
||||||
|
}, [userFilterType]);
|
||||||
|
|
||||||
|
const isDealer = userFilterType === 'DEALER';
|
||||||
const prevFiltersRef = useRef({
|
const prevFiltersRef = useRef({
|
||||||
searchTerm: filters.searchTerm,
|
searchTerm: filters.searchTerm,
|
||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
@ -39,14 +61,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
fetchRef.current(storedPage, {
|
fetchRef.current(storedPage, {
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
// Only include priority and templateType filters if user is not a dealer
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
hasInitialFetchRun.current = true;
|
hasInitialFetchRun.current = true;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // Only on mount
|
}, [isDealer]); // Re-fetch if dealer status changes
|
||||||
|
|
||||||
// Track filter changes and refetch
|
// Track filter changes and refetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -88,7 +111,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
@ -130,7 +153,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters - Plug-and-play pattern */}
|
||||||
<ClosedRequestsFiltersComponent
|
<ClosedRequestsFiltersComponent
|
||||||
searchTerm={filters.searchTerm}
|
searchTerm={filters.searchTerm}
|
||||||
priorityFilter={filters.priorityFilter}
|
priorityFilter={filters.priorityFilter}
|
||||||
@ -138,7 +161,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
templateTypeFilter={filters.templateTypeFilter}
|
templateTypeFilter={filters.templateTypeFilter}
|
||||||
sortBy={filters.sortBy}
|
sortBy={filters.sortBy}
|
||||||
sortOrder={filters.sortOrder}
|
sortOrder={filters.sortOrder}
|
||||||
activeFiltersCount={filters.activeFiltersCount}
|
activeFiltersCount={
|
||||||
|
isDealer
|
||||||
|
? // For dealers: only count search and status (closure type)
|
||||||
|
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
|
||||||
|
: // For standard users: count all filters
|
||||||
|
filters.activeFiltersCount
|
||||||
|
}
|
||||||
onSearchChange={filters.setSearchTerm}
|
onSearchChange={filters.setSearchTerm}
|
||||||
onPriorityChange={filters.setPriorityFilter}
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
onStatusChange={filters.setStatusFilter}
|
onStatusChange={filters.setStatusFilter}
|
||||||
|
|||||||
@ -98,6 +98,7 @@ export function CreateRequest({
|
|||||||
documentErrorModal,
|
documentErrorModal,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
closeValidationModal,
|
closeValidationModal,
|
||||||
|
openPolicyViolationModal,
|
||||||
closePolicyViolationModal,
|
closePolicyViolationModal,
|
||||||
openDocumentErrorModal,
|
openDocumentErrorModal,
|
||||||
closeDocumentErrorModal,
|
closeDocumentErrorModal,
|
||||||
@ -138,6 +139,8 @@ export function CreateRequest({
|
|||||||
wizardPrevStep,
|
wizardPrevStep,
|
||||||
user: user!,
|
user: user!,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
|
systemPolicy,
|
||||||
|
onPolicyViolation: openPolicyViolationModal,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -222,6 +225,7 @@ export function CreateRequest({
|
|||||||
<ApprovalWorkflowStep
|
<ApprovalWorkflowStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
|
systemPolicy={systemPolicy}
|
||||||
onValidationError={(error) =>
|
onValidationError={(error) =>
|
||||||
openValidationModal(
|
openValidationModal(
|
||||||
error.type as 'error' | 'self-assign' | 'not-found',
|
error.type as 'error' | 'self-assign' | 'not-found',
|
||||||
@ -229,6 +233,7 @@ export function CreateRequest({
|
|||||||
error.message
|
error.message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onPolicyViolation={openPolicyViolationModal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { RequestTemplate, FormData } from '@/hooks/useCreateRequestForm';
|
import { RequestTemplate, FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
|
||||||
import { PreviewDocument } from '../types/createRequest.types';
|
import { PreviewDocument } from '../types/createRequest.types';
|
||||||
import { getDocumentPreviewUrl } from '@/services/workflowApi';
|
import { getDocumentPreviewUrl } from '@/services/workflowApi';
|
||||||
import { validateApprovers } from './useApproverValidation';
|
import { validateApprovers } from './useApproverValidation';
|
||||||
@ -29,6 +29,8 @@ interface UseHandlersOptions {
|
|||||||
email: string,
|
email: string,
|
||||||
message: string
|
message: string
|
||||||
) => void;
|
) => void;
|
||||||
|
systemPolicy?: SystemPolicy;
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
onSubmit?: (requestData: any) => void;
|
onSubmit?: (requestData: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,6 +45,8 @@ export function useCreateRequestHandlers({
|
|||||||
wizardPrevStep,
|
wizardPrevStep,
|
||||||
user,
|
user,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
|
systemPolicy,
|
||||||
|
onPolicyViolation,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
}: UseHandlersOptions) {
|
}: UseHandlersOptions) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -93,6 +97,20 @@ export function useCreateRequestHandlers({
|
|||||||
|
|
||||||
// Special validation when leaving step 3 (Approval Workflow)
|
// Special validation when leaving step 3 (Approval Workflow)
|
||||||
if (currentStep === 3) {
|
if (currentStep === 3) {
|
||||||
|
// Validate approval level count against system policy
|
||||||
|
if (systemPolicy && onPolicyViolation) {
|
||||||
|
const approverCount = formData.approverCount || 1;
|
||||||
|
if (approverCount > systemPolicy.maxApprovalLevels) {
|
||||||
|
onPolicyViolation([{
|
||||||
|
type: 'Maximum Approval Levels Exceeded',
|
||||||
|
message: `The request has ${approverCount} approval levels, which exceeds the maximum allowed (${systemPolicy.maxApprovalLevels}). Please reduce the number of approvers.`,
|
||||||
|
currentValue: approverCount,
|
||||||
|
maxValue: systemPolicy.maxApprovalLevels
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
|
const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
|
||||||
const validation = await validateApprovers(
|
const validation = await validateApprovers(
|
||||||
formData.approvers,
|
formData.approvers,
|
||||||
|
|||||||
@ -1,15 +1,16 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { useOpenRequestsFilters } from './hooks/useOpenRequestsFilters';
|
import { useOpenRequestsFilters } from './hooks/useOpenRequestsFilters';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, CheckCircle, XCircle, Lock } from 'lucide-react';
|
import { Calendar, Clock, FileText, AlertCircle, ArrowRight, RefreshCw, CheckCircle, XCircle, Lock, Flame, Target } from 'lucide-react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
|
import { getUserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
import { getRequestsFilters } from '@/flows';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
interface Request {
|
interface Request {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -115,6 +116,40 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
// Use Redux for filters with callback (persists during navigation)
|
// Use Redux for filters with callback (persists during navigation)
|
||||||
const filters = useOpenRequestsFilters();
|
const filters = useOpenRequestsFilters();
|
||||||
|
|
||||||
|
// Get user filter type and corresponding filter component (plug-and-play pattern)
|
||||||
|
const userFilterType = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
return getUserFilterType(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[OpenRequests] Error getting user filter type:', error);
|
||||||
|
return 'STANDARD' as const;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get the appropriate filter component based on user type
|
||||||
|
const RequestsFiltersComponent = useMemo(() => {
|
||||||
|
return getRequestsFilters(userFilterType);
|
||||||
|
}, [userFilterType]);
|
||||||
|
|
||||||
|
// Determine once - use this throughout instead of checking repeatedly
|
||||||
|
const isDealer = userFilterType === 'DEALER';
|
||||||
|
|
||||||
|
// Helper to build filter params for API - excludes dealer-restricted filters
|
||||||
|
// Since we know user type initially, this helper uses that knowledge
|
||||||
|
// Note: This doesn't need useCallback since we'll use it inline in effects to avoid dependency issues
|
||||||
|
const getFilterParams = (includeStatus?: boolean) => {
|
||||||
|
return {
|
||||||
|
search: filters.searchTerm || undefined,
|
||||||
|
// Only include status, priority, and templateType filters if user is not a dealer
|
||||||
|
status: includeStatus && !isDealer && filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
|
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Fetch open requests for the current user only (user-scoped, not organization-wide)
|
// Fetch open requests for the current user only (user-scoped, not organization-wide)
|
||||||
// Note: This endpoint returns only requests where the user is:
|
// Note: This endpoint returns only requests where the user is:
|
||||||
// - An approver (with pending/in-progress status)
|
// - An approver (with pending/in-progress status)
|
||||||
@ -192,31 +227,17 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
|
|
||||||
fetchRequestsRef.current = fetchRequests;
|
fetchRequestsRef.current = fetchRequests;
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = useCallback(() => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(filters.currentPage, {
|
fetchRequests(filters.currentPage, getFilterParams(true));
|
||||||
search: filters.searchTerm || undefined,
|
}, [filters.currentPage, fetchRequests]);
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
|
||||||
sortOrder: filters.sortOrder
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = useCallback((newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
filters.setCurrentPage(newPage);
|
filters.setCurrentPage(newPage);
|
||||||
fetchRequests(newPage, {
|
fetchRequests(newPage, getFilterParams(true));
|
||||||
search: filters.searchTerm || undefined,
|
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
|
||||||
sortOrder: filters.sortOrder
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
}, [totalPages, filters, fetchRequests]);
|
||||||
|
|
||||||
const getPageNumbers = () => {
|
const getPageNumbers = () => {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
@ -243,14 +264,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
if (!hasInitialFetchRun.current) {
|
if (!hasInitialFetchRun.current) {
|
||||||
hasInitialFetchRun.current = true;
|
hasInitialFetchRun.current = true;
|
||||||
const storedPage = filters.currentPage || 1;
|
const storedPage = filters.currentPage || 1;
|
||||||
fetchRequests(storedPage, {
|
fetchRequests(storedPage, getFilterParams(true));
|
||||||
search: filters.searchTerm || undefined,
|
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
|
||||||
sortOrder: filters.sortOrder,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // Only on mount
|
}, []); // Only on mount
|
||||||
@ -263,19 +277,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
// Debounce search
|
// Debounce search
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
fetchRequests(1, {
|
fetchRequests(1, getFilterParams(true));
|
||||||
search: filters.searchTerm || undefined,
|
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
|
||||||
sortBy: filters.sortBy,
|
|
||||||
sortOrder: filters.sortOrder,
|
|
||||||
});
|
|
||||||
}, filters.searchTerm ? 500 : 0);
|
}, filters.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
|
||||||
|
|
||||||
// Backend handles both filtering and sorting - use items directly
|
// Backend handles both filtering and sorting - use items directly
|
||||||
// No client-side sorting needed anymore
|
// No client-side sorting needed anymore
|
||||||
@ -316,119 +323,23 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enhanced Filters Section */}
|
{/* Enhanced Filters Section - Plug-and-play pattern */}
|
||||||
<Card className="shadow-lg border-0">
|
<RequestsFiltersComponent
|
||||||
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
|
searchTerm={filters.searchTerm}
|
||||||
<div className="flex items-center justify-between">
|
statusFilter={filters.statusFilter}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
priorityFilter={filters.priorityFilter}
|
||||||
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
|
templateTypeFilter={filters.templateTypeFilter}
|
||||||
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
|
sortBy={filters.sortBy}
|
||||||
</div>
|
sortOrder={filters.sortOrder}
|
||||||
<div>
|
onSearchChange={filters.setSearchTerm}
|
||||||
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
|
onStatusFilterChange={filters.setStatusFilter}
|
||||||
<CardDescription className="text-xs sm:text-sm">
|
onPriorityFilterChange={filters.setPriorityFilter}
|
||||||
{filters.activeFiltersCount > 0 && (
|
onTemplateTypeFilterChange={filters.setTemplateTypeFilter}
|
||||||
<span className="text-blue-600 font-medium">
|
onSortByChange={filters.setSortBy}
|
||||||
{filters.activeFiltersCount} filter{filters.activeFiltersCount > 1 ? 's' : ''} active
|
onSortOrderChange={filters.setSortOrder}
|
||||||
</span>
|
onClearFilters={filters.clearFilters}
|
||||||
)}
|
activeFiltersCount={filters.activeFiltersCount}
|
||||||
</CardDescription>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{filters.activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={filters.clearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
|
||||||
{/* Primary filters */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search requests, IDs..."
|
|
||||||
value={filters.searchTerm}
|
|
||||||
onChange={(e) => filters.setSearchTerm(e.target.value)}
|
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={filters.priorityFilter} onValueChange={filters.setPriorityFilter}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Priorities" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priorities</SelectItem>
|
|
||||||
<SelectItem value="express">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Flame className="w-4 h-4 text-orange-600" />
|
|
||||||
<span>Express</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="standard">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Target className="w-4 h-4 text-blue-600" />
|
|
||||||
<span>Standard</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Statuses" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending (In Approval)</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Custom</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Select value={filters.sortBy} onValueChange={(value: any) => filters.setSortBy(value)}>
|
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
|
||||||
<SelectValue placeholder="Sort by" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="due">Due Date</SelectItem>
|
|
||||||
<SelectItem value="created">Date Created</SelectItem>
|
|
||||||
<SelectItem value="priority">Priority</SelectItem>
|
|
||||||
<SelectItem value="sla">SLA Progress</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
||||||
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
|
|
||||||
>
|
|
||||||
{filters.sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@ -32,6 +32,8 @@ interface RequestDetailModalsProps {
|
|||||||
actionStatus: any;
|
actionStatus: any;
|
||||||
existingParticipants: any[];
|
existingParticipants: any[];
|
||||||
currentLevels: any[];
|
currentLevels: any[];
|
||||||
|
maxApprovalLevels?: number;
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
setShowApproveModal: (show: boolean) => void;
|
setShowApproveModal: (show: boolean) => void;
|
||||||
@ -67,6 +69,8 @@ export function RequestDetailModals({
|
|||||||
actionStatus,
|
actionStatus,
|
||||||
existingParticipants,
|
existingParticipants,
|
||||||
currentLevels,
|
currentLevels,
|
||||||
|
maxApprovalLevels,
|
||||||
|
onPolicyViolation,
|
||||||
setShowApproveModal,
|
setShowApproveModal,
|
||||||
setShowRejectModal,
|
setShowRejectModal,
|
||||||
setShowAddApproverModal,
|
setShowAddApproverModal,
|
||||||
@ -114,6 +118,8 @@ export function RequestDetailModals({
|
|||||||
requestTitle={request.title}
|
requestTitle={request.title}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
|
maxApprovalLevels={maxApprovalLevels}
|
||||||
|
onPolicyViolation={onPolicyViolation}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Add Spectator Modal */}
|
{/* Add Spectator Modal */}
|
||||||
|
|||||||
@ -13,6 +13,8 @@ interface WorkNotesTabProps {
|
|||||||
isSpectator: boolean;
|
isSpectator: boolean;
|
||||||
currentLevels: any[];
|
currentLevels: any[];
|
||||||
onAddApprover: (email: string, tatHours: number, level: number) => Promise<void>;
|
onAddApprover: (email: string, tatHours: number, level: number) => Promise<void>;
|
||||||
|
maxApprovalLevels?: number;
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkNotesTab({
|
export function WorkNotesTab({
|
||||||
@ -24,6 +26,8 @@ export function WorkNotesTab({
|
|||||||
isSpectator,
|
isSpectator,
|
||||||
currentLevels,
|
currentLevels,
|
||||||
onAddApprover,
|
onAddApprover,
|
||||||
|
maxApprovalLevels,
|
||||||
|
onPolicyViolation,
|
||||||
}: WorkNotesTabProps) {
|
}: WorkNotesTabProps) {
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-300px)] min-h-[600px]">
|
<div className="h-[calc(100vh-300px)] min-h-[600px]">
|
||||||
@ -37,6 +41,8 @@ export function WorkNotesTab({
|
|||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
onAddApprover={onAddApprover}
|
onAddApprover={onAddApprover}
|
||||||
|
maxApprovalLevels={maxApprovalLevels}
|
||||||
|
onPolicyViolation={onPolicyViolation}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -25,6 +25,9 @@ import { useUserSearch } from './hooks/useUserSearch';
|
|||||||
// Utils
|
// Utils
|
||||||
import { transformRequests } from './utils/requestTransformers';
|
import { transformRequests } from './utils/requestTransformers';
|
||||||
import { exportRequestsToCSV } from './utils/csvExports';
|
import { exportRequestsToCSV } from './utils/csvExports';
|
||||||
|
import { getUserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
import { getUserAllRequestsFilters } from '@/flows';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
|
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
|
||||||
@ -32,22 +35,60 @@ import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './s
|
|||||||
// Types
|
// Types
|
||||||
import type { RequestsProps, BackendStats } from './types/requests.types';
|
import type { RequestsProps, BackendStats } from './types/requests.types';
|
||||||
|
|
||||||
// Filter UI components
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
|
|
||||||
export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||||
// Filters hook
|
// Filters hook
|
||||||
const filters = useRequestsFilters();
|
const filters = useRequestsFilters();
|
||||||
|
|
||||||
|
// Get user filter type and corresponding filter component (plug-and-play pattern)
|
||||||
|
// Determine once at the beginning - no need to check repeatedly
|
||||||
|
const userFilterType = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
return getUserFilterType(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[UserAllRequests] Error getting user filter type:', error);
|
||||||
|
return 'STANDARD' as const;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get the appropriate filter component based on user type
|
||||||
|
const UserAllRequestsFiltersComponent = useMemo(() => {
|
||||||
|
return getUserAllRequestsFilters(userFilterType);
|
||||||
|
}, [userFilterType]);
|
||||||
|
|
||||||
|
// Determine once - use this throughout instead of checking repeatedly
|
||||||
|
const isDealer = userFilterType === 'DEALER';
|
||||||
|
|
||||||
|
// Helper to get filters for API - excludes dealer-restricted filters
|
||||||
|
// Since we know user type initially, this helper uses that knowledge
|
||||||
|
const getFiltersForApi = useCallback(() => {
|
||||||
|
const filterOptions = filters.getFilters();
|
||||||
|
if (isDealer) {
|
||||||
|
// For dealers, exclude priority, templateType, department, and slaCompliance
|
||||||
|
const { priority, templateType, department, slaCompliance, ...dealerFilters } = filterOptions;
|
||||||
|
return dealerFilters;
|
||||||
|
}
|
||||||
|
return filterOptions;
|
||||||
|
}, [filters, isDealer]);
|
||||||
|
|
||||||
|
// Helper to calculate active filters count based on user type
|
||||||
|
const calculateActiveFiltersCount = useCallback(() => {
|
||||||
|
if (isDealer) {
|
||||||
|
// For dealers: only count search, status, initiator, approver, and date filters
|
||||||
|
return !!(
|
||||||
|
filters.searchTerm ||
|
||||||
|
filters.statusFilter !== 'all' ||
|
||||||
|
filters.initiatorFilter !== 'all' ||
|
||||||
|
filters.approverFilter !== 'all' ||
|
||||||
|
filters.dateRange !== 'all' ||
|
||||||
|
filters.customStartDate ||
|
||||||
|
filters.customEndDate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// For standard users: count all filters (use existing hasActiveFilters)
|
||||||
|
return filters.hasActiveFilters;
|
||||||
|
}, [isDealer, filters]);
|
||||||
|
|
||||||
// State
|
// State
|
||||||
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -157,12 +198,14 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// Use refs to store stable callbacks to prevent infinite loops
|
// Use refs to store stable callbacks to prevent infinite loops
|
||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
const fetchBackendStatsRef = useRef(fetchBackendStats);
|
const fetchBackendStatsRef = useRef(fetchBackendStats);
|
||||||
|
const getFiltersForApiRef = useRef(getFiltersForApi);
|
||||||
|
|
||||||
// Update refs on each render
|
// Update refs on each render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
fetchBackendStatsRef.current = fetchBackendStats;
|
fetchBackendStatsRef.current = fetchBackendStats;
|
||||||
}, [filters, fetchBackendStats]);
|
getFiltersForApiRef.current = getFiltersForApi;
|
||||||
|
}, [filters, fetchBackendStats, getFiltersForApi]);
|
||||||
|
|
||||||
// Fetch requests - OPTIMIZED: Only fetches 10 records per page
|
// Fetch requests - OPTIMIZED: Only fetches 10 records per page
|
||||||
const fetchRequests = useCallback(async (page: number = 1) => {
|
const fetchRequests = useCallback(async (page: number = 1) => {
|
||||||
@ -172,7 +215,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
setApiRequests([]);
|
setApiRequests([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filterOptions = filtersRef.current.getFilters();
|
const filterOptions = getFiltersForApiRef.current();
|
||||||
const result = await fetchUserParticipantRequestsData({
|
const result = await fetchUserParticipantRequestsData({
|
||||||
page,
|
page,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
@ -190,21 +233,22 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [itemsPerPage]);
|
}, [itemsPerPage, filters]);
|
||||||
|
|
||||||
// Export to CSV
|
// Export to CSV
|
||||||
const handleExportToCSV = useCallback(async () => {
|
const handleExportToCSV = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
const allData = await fetchAllRequestsForExport(filters.getFilters());
|
const exportFilters = getFiltersForApi();
|
||||||
await exportRequestsToCSV(allData, filters.getFilters());
|
const allData = await fetchAllRequestsForExport(exportFilters);
|
||||||
|
await exportRequestsToCSV(allData, exportFilters);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to export requests:', error);
|
console.error('Failed to export requests:', error);
|
||||||
alert('Failed to export requests. Please try again.');
|
alert('Failed to export requests. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}, [filters]);
|
}, [getFiltersForApi]);
|
||||||
|
|
||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -216,16 +260,30 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
const filtersWithoutStatus = {
|
const filtersWithoutStatus: {
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority?: string;
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
templateType?: string;
|
||||||
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
|
department?: string;
|
||||||
|
initiator?: string;
|
||||||
|
approver?: string;
|
||||||
|
approverType?: 'current' | 'any';
|
||||||
|
search?: string;
|
||||||
|
slaCompliance?: string;
|
||||||
|
} = {
|
||||||
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
||||||
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
||||||
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Only include priority, templateType, department, and slaCompliance if user is not a dealer
|
||||||
|
if (!isDealer) {
|
||||||
|
if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter;
|
||||||
|
if (filters.templateTypeFilter !== 'all') filtersWithoutStatus.templateType = filters.templateTypeFilter;
|
||||||
|
if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter;
|
||||||
|
if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter;
|
||||||
|
}
|
||||||
|
|
||||||
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
|
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
|
||||||
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
|
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
|
||||||
|
|
||||||
@ -250,7 +308,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
filters.dateRange,
|
filters.dateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate,
|
filters.customEndDate,
|
||||||
filters.templateTypeFilter
|
filters.templateTypeFilter,
|
||||||
|
isDealer
|
||||||
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -415,342 +474,42 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters - Plug-and-play pattern */}
|
||||||
<Card className="border-gray-200 shadow-md" data-testid="requests-filters">
|
<UserAllRequestsFiltersComponent
|
||||||
<CardContent className="p-4 sm:p-6">
|
searchTerm={filters.searchTerm}
|
||||||
<div className="flex flex-col gap-4">
|
statusFilter={filters.statusFilter}
|
||||||
<div className="flex items-center justify-between">
|
priorityFilter={filters.priorityFilter}
|
||||||
<div className="flex items-center gap-2">
|
templateTypeFilter={filters.templateTypeFilter}
|
||||||
<Filter className="w-5 h-5 text-muted-foreground" />
|
departmentFilter={filters.departmentFilter}
|
||||||
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
|
slaComplianceFilter={filters.slaComplianceFilter}
|
||||||
{filters.hasActiveFilters && (
|
initiatorFilter={filters.initiatorFilter}
|
||||||
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
|
approverFilter={filters.approverFilter}
|
||||||
Active
|
approverFilterType={filters.approverFilterType}
|
||||||
</Badge>
|
dateRange={filters.dateRange}
|
||||||
)}
|
customStartDate={filters.customStartDate}
|
||||||
</div>
|
customEndDate={filters.customEndDate}
|
||||||
{filters.hasActiveFilters && (
|
showCustomDatePicker={filters.showCustomDatePicker}
|
||||||
<Button variant="ghost" size="sm" onClick={filters.clearFilters} className="gap-2">
|
departments={departments}
|
||||||
<RefreshCw className="w-4 h-4" />
|
loadingDepartments={loadingDepartments}
|
||||||
Clear All
|
initiatorSearch={initiatorSearch}
|
||||||
</Button>
|
approverSearch={approverSearch}
|
||||||
)}
|
onSearchChange={filters.setSearchTerm}
|
||||||
</div>
|
onStatusChange={filters.setStatusFilter}
|
||||||
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
<Separator />
|
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
||||||
|
onDepartmentChange={filters.setDepartmentFilter}
|
||||||
{/* Primary Filters */}
|
onSlaComplianceChange={filters.setSlaComplianceFilter}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
|
onInitiatorChange={filters.setInitiatorFilter}
|
||||||
<div className="relative md:col-span-3 lg:col-span-1">
|
onApproverChange={filters.setApproverFilter}
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
onApproverTypeChange={filters.setApproverFilterType}
|
||||||
<Input
|
onDateRangeChange={filters.handleDateRangeChange}
|
||||||
placeholder="Search requests..."
|
onCustomStartDateChange={filters.setCustomStartDate}
|
||||||
value={filters.searchTerm}
|
onCustomEndDateChange={filters.setCustomEndDate}
|
||||||
onChange={(e) => filters.setSearchTerm(e.target.value)}
|
onShowCustomDatePickerChange={filters.setShowCustomDatePicker}
|
||||||
className="pl-10 h-10"
|
onApplyCustomDate={filters.handleApplyCustomDate}
|
||||||
data-testid="search-input"
|
onClearFilters={filters.clearFilters}
|
||||||
/>
|
hasActiveFilters={calculateActiveFiltersCount()}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
|
||||||
<SelectValue placeholder="All Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
<SelectItem value="paused">Paused</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={filters.priorityFilter} onValueChange={filters.setPriorityFilter}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="priority-filter">
|
|
||||||
<SelectValue placeholder="All Priority" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Priority</SelectItem>
|
|
||||||
<SelectItem value="express">Express</SelectItem>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
|
||||||
<SelectValue placeholder="All Templates" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Templates</SelectItem>
|
|
||||||
<SelectItem value="CUSTOM">Custom</SelectItem>
|
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.departmentFilter}
|
|
||||||
onValueChange={filters.setDepartmentFilter}
|
|
||||||
disabled={loadingDepartments || departments.length === 0}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-10" data-testid="department-filter">
|
|
||||||
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Departments</SelectItem>
|
|
||||||
{departments.map((dept) => (
|
|
||||||
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select value={filters.slaComplianceFilter} onValueChange={filters.setSlaComplianceFilter}>
|
|
||||||
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
|
|
||||||
<SelectValue placeholder="All SLA Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All SLA Status</SelectItem>
|
|
||||||
<SelectItem value="compliant">Compliant</SelectItem>
|
|
||||||
<SelectItem value="on-track">On Track</SelectItem>
|
|
||||||
<SelectItem value="approaching">Approaching</SelectItem>
|
|
||||||
<SelectItem value="critical">Critical</SelectItem>
|
|
||||||
<SelectItem value="breached">Breached</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Filters - Initiator and Approver */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
|
|
||||||
{/* Initiator Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
|
|
||||||
<div className="relative">
|
|
||||||
{initiatorSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search initiator..."
|
|
||||||
value={initiatorSearch.searchQuery}
|
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (initiatorSearch.searchResults.length > 0) {
|
|
||||||
initiatorSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="initiator-search-input"
|
|
||||||
/>
|
|
||||||
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{initiatorSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => initiatorSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Approver Filter */}
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<Label className="text-sm font-medium text-gray-700">Approver</Label>
|
|
||||||
{filters.approverFilter !== 'all' && (
|
|
||||||
<Select
|
|
||||||
value={filters.approverFilterType}
|
|
||||||
onValueChange={(value: 'current' | 'any') => filters.setApproverFilterType(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-7 w-32 text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="current">Current Only</SelectItem>
|
|
||||||
<SelectItem value="any">Any Approver</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
{approverSearch.selectedUser ? (
|
|
||||||
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
|
|
||||||
<span className="flex-1 text-sm text-gray-900 truncate">
|
|
||||||
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Input
|
|
||||||
placeholder="Search approver..."
|
|
||||||
value={approverSearch.searchQuery}
|
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
|
||||||
onFocus={() => {
|
|
||||||
if (approverSearch.searchResults.length > 0) {
|
|
||||||
approverSearch.setShowResults(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
|
|
||||||
className="h-10"
|
|
||||||
data-testid="approver-search-input"
|
|
||||||
/>
|
|
||||||
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
|
|
||||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
||||||
{approverSearch.searchResults.map((user) => (
|
|
||||||
<button
|
|
||||||
key={user.userId}
|
|
||||||
type="button"
|
|
||||||
onClick={() => approverSearch.handleSelect(user)}
|
|
||||||
className="w-full px-4 py-2 text-left hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-sm font-medium text-gray-900">
|
|
||||||
{user.displayName || user.email}
|
|
||||||
</span>
|
|
||||||
{user.displayName && (
|
|
||||||
<span className="text-xs text-gray-500">{user.email}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date Range Filter */}
|
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
|
||||||
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
|
|
||||||
<Select value={filters.dateRange} onValueChange={filters.handleDateRangeChange}>
|
|
||||||
<SelectTrigger className="w-[160px] h-10">
|
|
||||||
<SelectValue placeholder="Date Range" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Time</SelectItem>
|
|
||||||
<SelectItem value="today">Today</SelectItem>
|
|
||||||
<SelectItem value="week">This Week</SelectItem>
|
|
||||||
<SelectItem value="month">This Month</SelectItem>
|
|
||||||
<SelectItem value="last7days">Last 7 Days</SelectItem>
|
|
||||||
<SelectItem value="last30days">Last 30 Days</SelectItem>
|
|
||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{filters.dateRange === 'custom' && (
|
|
||||||
<Popover open={filters.showCustomDatePicker} onOpenChange={filters.setShowCustomDatePicker}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
|
||||||
<CalendarIcon className="w-4 h-4" />
|
|
||||||
{filters.customStartDate && filters.customEndDate
|
|
||||||
? `${format(filters.customStartDate, 'MMM d, yyyy')} - ${format(filters.customEndDate, 'MMM d, yyyy')}`
|
|
||||||
: 'Select dates'}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-4" align="start">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="start-date">Start Date</Label>
|
|
||||||
<Input
|
|
||||||
id="start-date"
|
|
||||||
type="date"
|
|
||||||
value={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const date = e.target.value ? new Date(e.target.value) : undefined;
|
|
||||||
if (date) {
|
|
||||||
filters.setCustomStartDate(date);
|
|
||||||
if (filters.customEndDate && date > filters.customEndDate) {
|
|
||||||
filters.setCustomEndDate(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filters.setCustomStartDate(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
max={format(new Date(), 'yyyy-MM-dd')}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="end-date">End Date</Label>
|
|
||||||
<Input
|
|
||||||
id="end-date"
|
|
||||||
type="date"
|
|
||||||
value={filters.customEndDate ? format(filters.customEndDate, 'yyyy-MM-dd') : ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const date = e.target.value ? new Date(e.target.value) : undefined;
|
|
||||||
if (date) {
|
|
||||||
filters.setCustomEndDate(date);
|
|
||||||
if (filters.customStartDate && date < filters.customStartDate) {
|
|
||||||
filters.setCustomStartDate(date);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
filters.setCustomEndDate(undefined);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
min={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : undefined}
|
|
||||||
max={format(new Date(), 'yyyy-MM-dd')}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 pt-2 border-t">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={filters.handleApplyCustomDate}
|
|
||||||
disabled={!filters.customStartDate || !filters.customEndDate}
|
|
||||||
className="flex-1 bg-re-green hover:bg-re-green/90"
|
|
||||||
>
|
|
||||||
Apply
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
filters.setShowCustomDatePicker(false);
|
|
||||||
filters.setCustomStartDate(undefined);
|
|
||||||
filters.setCustomEndDate(undefined);
|
|
||||||
filters.setDateRange('month');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
<RequestsList
|
<RequestsList
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
34
src/utils/userFilterUtils.ts
Normal file
34
src/utils/userFilterUtils.ts
Normal 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';
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user