diff --git a/docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md b/docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md new file mode 100644 index 0000000..df28da6 --- /dev/null +++ b/docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md @@ -0,0 +1,231 @@ +# Frontend Updates for Dealer Claim Management + +## Overview + +This document outlines the frontend changes needed to support the new backend structure with `workflowType` field for dealer claim management. + +## ✅ Completed + +1. **Created utility function** (`src/utils/claimRequestUtils.ts`) + - `isClaimManagementRequest()` - Checks if request is claim management (supports both old and new formats) + - `getWorkflowType()` - Gets workflow type from request + - `shouldUseClaimManagementUI()` - Determines if claim-specific UI should be used + +## 🔧 Required Updates + +### 1. Update RequestDetail Component + +**File**: `src/pages/RequestDetail/RequestDetail.tsx` + +**Changes Needed**: +- Import `isClaimManagementRequest` from `@/utils/claimRequestUtils` +- Conditionally render `ClaimManagementOverviewTab` instead of `OverviewTab` when it's a claim management request +- Conditionally render `DealerClaimWorkflowTab` instead of `WorkflowTab` when it's a claim management request + +**Example**: +```typescript +import { isClaimManagementRequest } from '@/utils/claimRequestUtils'; +import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab'; +import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab'; + +// In the component: +const isClaimManagement = isClaimManagementRequest(apiRequest); + + + {isClaimManagement ? ( + + ) : ( + + )} + + + + {isClaimManagement ? ( + + ) : ( + + )} + +``` + +### 2. Create Missing Utility Functions + +**File**: `src/pages/RequestDetail/utils/claimDataMapper.ts` (NEW FILE) + +**Functions Needed**: +- `mapToClaimManagementRequest(apiRequest, userId)` - Maps backend API response to claim management structure +- `determineUserRole(apiRequest, userId)` - Determines user's role (INITIATOR, DEALER, APPROVER, SPECTATOR) +- Helper functions to extract claim-specific data from API response + +**Structure**: +```typescript +export interface ClaimManagementRequest { + activityInfo: { + activityName: string; + activityType: string; + activityDate: string; + location: string; + periodStart?: string; + periodEnd?: string; + estimatedBudget?: number; + closedExpensesBreakdown?: any[]; + }; + dealerInfo: { + dealerCode: string; + dealerName: string; + dealerEmail?: string; + dealerPhone?: string; + dealerAddress?: string; + }; + proposalDetails?: { + costBreakup: any[]; + totalEstimatedBudget: number; + timeline: string; + dealerComments?: string; + }; + ioDetails?: { + ioNumber?: string; + availableBalance?: number; + blockedAmount?: number; + remainingBalance?: number; + }; + dmsDetails?: { + dmsNumber?: string; + eInvoiceNumber?: string; + creditNoteNumber?: string; + }; + claimAmount?: number; +} + +export function mapToClaimManagementRequest( + apiRequest: any, + userId: string +): ClaimManagementRequest | null { + // Extract data from apiRequest.claimDetails (from backend) + // Map to ClaimManagementRequest structure +} + +export function determineUserRole( + apiRequest: any, + userId: string +): 'INITIATOR' | 'DEALER' | 'APPROVER' | 'SPECTATOR' { + // Check if user is initiator + // Check if user is dealer (from participants or claimDetails) + // Check if user is approver (from approval levels) + // Check if user is spectator (from participants) +} +``` + +### 3. Update ClaimManagementOverviewTab + +**File**: `src/pages/RequestDetail/components/tabs/ClaimManagementOverviewTab.tsx` + +**Changes Needed**: +- Import the new utility functions from `claimDataMapper.ts` +- Remove TODO comments +- Ensure it properly handles both old and new API response formats + +### 4. Update API Service + +**File**: `src/services/workflowApi.ts` + +**Changes Needed**: +- Add `workflowType` field to `CreateWorkflowFromFormPayload` interface +- Include `workflowType: 'CLAIM_MANAGEMENT'` when creating claim management requests +- Update response types to include `workflowType` field + +**Example**: +```typescript +export interface CreateWorkflowFromFormPayload { + templateId?: string | null; + templateType: 'CUSTOM' | 'TEMPLATE'; + workflowType?: string; // NEW: 'CLAIM_MANAGEMENT' | 'NON_TEMPLATIZED' | etc. + title: string; + // ... rest of fields +} +``` + +### 5. Update ClaimManagementWizard + +**File**: `src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx` + +**Changes Needed**: +- When submitting, include `workflowType: 'CLAIM_MANAGEMENT'` in the payload +- Update to call the new backend API endpoint for creating claim requests + +### 6. Update Request List Components + +**Files**: +- `src/pages/MyRequests/components/RequestCard.tsx` +- `src/pages/Requests/components/RequestCard.tsx` +- `src/pages/OpenRequests/components/RequestCard.tsx` + +**Changes Needed**: +- Display template/workflow type badge based on `workflowType` field +- Support both old (`templateType`) and new (`workflowType`) formats + +**Example**: +```typescript +const workflowType = request.workflowType || + (request.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'); + +{workflowType === 'CLAIM_MANAGEMENT' && ( + Claim Management +)} +``` + +### 7. Create API Service for Claim Management + +**File**: `src/services/dealerClaimApi.ts` (NEW FILE) + +**Endpoints Needed**: +- `createClaimRequest(data)` - POST `/api/v1/dealer-claims` +- `getClaimDetails(requestId)` - GET `/api/v1/dealer-claims/:requestId` +- `submitProposal(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/proposal` +- `submitCompletion(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/completion` +- `updateIODetails(requestId, data)` - PUT `/api/v1/dealer-claims/:requestId/io` + +### 8. Update Type Definitions + +**File**: `src/types/index.ts` + +**Changes Needed**: +- Add `workflowType?: string` to request interfaces +- Update `ClaimFormData` interface to match backend structure + +## 📋 Implementation Order + +1. ✅ Create `claimRequestUtils.ts` (DONE) +2. ⏳ Create `claimDataMapper.ts` with mapping functions +3. ⏳ Update `RequestDetail.tsx` to conditionally render claim components +4. ⏳ Update `workflowApi.ts` to include `workflowType` +5. ⏳ Update `ClaimManagementWizard.tsx` to send `workflowType` +6. ⏳ Create `dealerClaimApi.ts` for claim-specific endpoints +7. ⏳ Update request card components to show workflow type +8. ⏳ Test end-to-end flow + +## 🔄 Backward Compatibility + +The frontend should support both: +- **Old Format**: `templateType: 'claim-management'` +- **New Format**: `workflowType: 'CLAIM_MANAGEMENT'` + +The `isClaimManagementRequest()` utility function handles both formats automatically. + +## 📝 Notes + +- All existing claim management UI components are already created +- The main work is connecting them to the new backend API structure +- The `workflowType` field allows the system to support multiple template types in the future +- All requests (claim management, non-templatized, future templates) will appear in "My Requests" and "Open Requests" automatically + diff --git a/src/App.tsx b/src/App.tsx index 4bce3ae..d99ca00 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,15 +24,9 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; -import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase'; import { AuthCallback } from '@/pages/Auth/AuthCallback'; - -// Combined Request Database for backward compatibility -// This combines both custom and claim management requests -export const REQUEST_DATABASE: any = { - ...CUSTOM_REQUEST_DATABASE, - ...CLAIM_MANAGEMENT_DATABASE -}; +import { createClaimRequest } from '@/services/dealerClaimApi'; +import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal'; interface AppProps { onLogout?: () => void; @@ -61,6 +55,20 @@ function AppRoutes({ onLogout }: AppProps) { const [dynamicRequests, setDynamicRequests] = useState([]); const [selectedRequestId, setSelectedRequestId] = useState(''); const [selectedRequestTitle, setSelectedRequestTitle] = useState(''); + const [managerModalOpen, setManagerModalOpen] = useState(false); + const [managerModalData, setManagerModalData] = useState<{ + errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND'; + managers?: Array<{ + userId: string; + email: string; + displayName: string; + firstName?: string; + lastName?: string; + department?: string; + }>; + message?: string; + pendingClaimData?: any; + } | null>(null); // Retrieve dynamic requests from localStorage on mount useEffect(() => { @@ -265,7 +273,85 @@ function AppRoutes({ onLogout }: AppProps) { setApprovalAction(null); }; - const handleClaimManagementSubmit = (claimData: any) => { + const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => { + try { + // Prepare payload for API + const payload = { + activityName: claimData.activityName, + activityType: claimData.activityType, + dealerCode: claimData.dealerCode, + dealerName: claimData.dealerName, + dealerEmail: claimData.dealerEmail || undefined, + dealerPhone: claimData.dealerPhone || undefined, + dealerAddress: claimData.dealerAddress || undefined, + activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined, + location: claimData.location, + requestDescription: claimData.requestDescription, + periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined, + periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined, + estimatedBudget: claimData.estimatedBudget || undefined, + selectedManagerEmail: selectedManagerEmail || undefined, + }; + + // Call API to create claim request + const response = await createClaimRequest(payload); + const createdRequest = response.request; + + // Close manager modal if open + setManagerModalOpen(false); + setManagerModalData(null); + + toast.success('Claim Request Submitted', { + description: 'Your claim management request has been created successfully.', + }); + + // Navigate to the created request detail page + if (createdRequest?.requestId) { + navigate(`/request/${createdRequest.requestId}`); + } else { + navigate('/my-requests'); + } + } catch (error: any) { + console.error('[App] Error creating claim request:', error); + + // Check for manager-related errors + const errorData = error?.response?.data; + const errorCode = errorData?.code || errorData?.error?.code; + + if (errorCode === 'NO_MANAGER_FOUND') { + // Show modal for no manager found + setManagerModalData({ + errorType: 'NO_MANAGER_FOUND', + message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.', + pendingClaimData: claimData, + }); + setManagerModalOpen(true); + return; + } + + if (errorCode === 'MULTIPLE_MANAGERS_FOUND') { + // Show modal with manager list for selection + const managers = errorData?.managers || errorData?.error?.managers || []; + setManagerModalData({ + errorType: 'MULTIPLE_MANAGERS_FOUND', + managers: managers, + message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.', + pendingClaimData: claimData, + }); + setManagerModalOpen(true); + return; + } + + // Other errors - show toast + const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request'; + toast.error('Failed to Submit Claim Request', { + description: errorMessage, + }); + } + + // Keep the old code below for backward compatibility (local storage fallback) + // This can be removed once API integration is fully tested + /* // Generate unique ID for the new claim request const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`; @@ -457,6 +543,7 @@ function AppRoutes({ onLogout }: AppProps) { description: 'Your claim management request has been created successfully.', }); navigate('/my-requests'); + */ }; return ( @@ -674,6 +761,27 @@ function AppRoutes({ onLogout }: AppProps) { }} /> + {/* Manager Selection Modal */} + { + setManagerModalOpen(false); + setManagerModalData(null); + }} + onSelect={async (managerEmail: string) => { + if (managerModalData?.pendingClaimData) { + // Retry creating claim request with selected manager + // The pendingClaimData contains all the form data from the wizard + // This preserves the entire submission state while waiting for manager selection + await handleClaimManagementSubmit(managerModalData.pendingClaimData, managerEmail); + } + }} + managers={managerModalData?.managers} + errorType={managerModalData?.errorType || 'NO_MANAGER_FOUND'} + message={managerModalData?.message} + isLoading={false} // Will be set to true during retry if needed + /> + {/* Approval Action Modal */} {approvalAction && ( void; + onSelect: (managerEmail: string) => void; + managers?: Manager[]; + errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND'; + message?: string; + isLoading?: boolean; +} + +export function ManagerSelectionModal({ + open, + onClose, + onSelect, + managers = [], + errorType, + message, + isLoading = false, +}: ManagerSelectionModalProps) { + const handleSelect = (managerEmail: string) => { + onSelect(managerEmail); + }; + + return ( + + + + + {errorType === 'NO_MANAGER_FOUND' ? ( + <> + + Manager Not Found + + ) : ( + <> + + Select Your Manager + + )} + + + {errorType === 'NO_MANAGER_FOUND' ? ( +
+

+ {message || 'No reporting manager found in the system. Please ensure your manager is correctly configured in your profile.'} +

+

+ Please contact your administrator to update your manager information, or try again later. +

+
+ ) : ( +
+

+ {message || 'Multiple managers were found with the same name. Please select the correct manager from the list below.'} +

+
+ )} +
+
+ +
+ {errorType === 'NO_MANAGER_FOUND' ? ( +
+
+ +
+

+ Unable to Proceed +

+

+ We couldn't find your reporting manager in the system. The claim request cannot be created without a valid manager assigned. +

+
+
+
+ ) : ( +
+ {managers.map((manager) => ( +
!isLoading && handleSelect(manager.email)} + > +
+
+
+ +
+
+
+

+ {manager.displayName || `${manager.firstName || ''} ${manager.lastName || ''}`.trim() || 'Unknown'} +

+
+
+
+ + {manager.email} +
+ {manager.department && ( +
+ + {manager.department} +
+ )} +
+
+
+ +
+
+ ))} +
+ )} +
+ +
+ {errorType === 'NO_MANAGER_FOUND' ? ( + + ) : ( + <> + + + )} +
+
+
+ ); +} + diff --git a/src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx b/src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx index a1a2432..d505fdf 100644 --- a/src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx +++ b/src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -25,7 +25,7 @@ import { } from 'lucide-react'; import { format } from 'date-fns'; import { toast } from 'sonner'; -import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase'; +import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi'; interface ClaimManagementWizardProps { onBack?: () => void; @@ -41,9 +41,6 @@ const CLAIM_TYPES = [ 'Service Campaign' ]; -// Fetch dealers from database -const DEALERS = getAllDealers(); - const STEP_NAMES = [ 'Claim Details', 'Review & Submit' @@ -51,6 +48,8 @@ const STEP_NAMES = [ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) { const [currentStep, setCurrentStep] = useState(1); + const [dealers, setDealers] = useState([]); + const [loadingDealers, setLoadingDealers] = useState(true); const [formData, setFormData] = useState({ activityName: '', @@ -70,6 +69,23 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar const totalSteps = STEP_NAMES.length; + // Fetch dealers from API on component mount + useEffect(() => { + const fetchDealers = async () => { + setLoadingDealers(true); + try { + const fetchedDealers = await fetchDealersFromAPI(); + setDealers(fetchedDealers); + } catch (error) { + toast.error('Failed to load dealer list.'); + console.error('Error fetching dealers:', error); + } finally { + setLoadingDealers(false); + } + }; + fetchDealers(); + }, []); + const updateFormData = (field: string, value: any) => { setFormData(prev => ({ ...prev, [field]: value })); }; @@ -103,14 +119,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar } }; - const handleDealerChange = (dealerCode: string) => { - const dealer = getDealerInfo(dealerCode); - if (dealer) { - updateFormData('dealerCode', dealer.code); - updateFormData('dealerName', dealer.name); - updateFormData('dealerEmail', dealer.email); - updateFormData('dealerPhone', dealer.phone); - updateFormData('dealerAddress', formatDealerAddress(dealer)); + const handleDealerChange = async (dealerCode: string) => { + const selectedDealer = dealers.find(d => d.dealerCode === dealerCode); + if (selectedDealer) { + updateFormData('dealerCode', dealerCode); + updateFormData('dealerName', selectedDealer.dealerName); + updateFormData('dealerEmail', selectedDealer.email || ''); + updateFormData('dealerPhone', selectedDealer.phone || ''); + updateFormData('dealerAddress', ''); // Address not available in API response + + // Try to fetch full dealer info from API if available + try { + const fullDealerInfo = await getDealerByCode(dealerCode); + if (fullDealerInfo) { + updateFormData('dealerEmail', fullDealerInfo.email || selectedDealer.email || ''); + updateFormData('dealerPhone', fullDealerInfo.phone || selectedDealer.phone || ''); + } + } catch (error) { + // Ignore error, use basic info from list + console.debug('Could not fetch full dealer info:', error); + } } }; @@ -235,9 +263,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar {/* Dealer Selection */}
- - + {formData.dealerCode && (
{formData.dealerCode} @@ -248,15 +276,21 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar - {DEALERS.map((dealer) => ( - -
- {dealer.code} - - {dealer.name} -
-
- ))} + {dealers.length === 0 && !loadingDealers ? ( +
No dealers available
+ ) : ( + dealers + .filter((dealer) => dealer.dealerCode && dealer.dealerCode.trim() !== '') + .map((dealer) => ( + +
+ {dealer.dealerCode} + + {dealer.dealerName} +
+
+ )) + )}
{formData.dealerCode && ( diff --git a/src/hooks/useRequestDetails.ts b/src/hooks/useRequestDetails.ts index 391945d..460200d 100644 --- a/src/hooks/useRequestDetails.ts +++ b/src/hooks/useRequestDetails.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import workflowApi, { getPauseDetails } from '@/services/workflowApi'; +import apiClient from '@/services/authApi'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase'; import { getSocket } from '@/utils/socket'; @@ -229,6 +230,87 @@ export function useRequestDetails( console.debug('Pause details not available:', error); } + /** + * Fetch: Get claim details if this is a claim management request + */ + let claimDetails = null; + let proposalDetails = null; + let completionDetails = null; + let internalOrder = null; + + if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { + try { + console.debug('[useRequestDetails] Fetching claim details for requestId:', wf.requestId); + const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); + console.debug('[useRequestDetails] Claim API response:', { + status: claimResponse.status, + hasData: !!claimResponse.data, + dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [], + fullResponse: claimResponse.data, + }); + + const claimData = claimResponse.data?.data || claimResponse.data; + console.debug('[useRequestDetails] Extracted claimData:', { + hasClaimData: !!claimData, + claimDataKeys: claimData ? Object.keys(claimData) : [], + hasClaimDetails: !!(claimData?.claimDetails || claimData?.claim_details), + hasProposalDetails: !!(claimData?.proposalDetails || claimData?.proposal_details), + hasCompletionDetails: !!(claimData?.completionDetails || claimData?.completion_details), + hasInternalOrder: !!(claimData?.internalOrder || claimData?.internal_order), + }); + + if (claimData) { + claimDetails = claimData.claimDetails || claimData.claim_details; + proposalDetails = claimData.proposalDetails || claimData.proposal_details; + completionDetails = claimData.completionDetails || claimData.completion_details; + internalOrder = claimData.internalOrder || claimData.internal_order || null; + // New normalized tables + const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; + const invoice = claimData.invoice || null; + const creditNote = claimData.creditNote || claimData.credit_note || null; + const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; + + // Store new fields in claimDetails for backward compatibility and easy access + if (claimDetails) { + (claimDetails as any).budgetTracking = budgetTracking; + (claimDetails as any).invoice = invoice; + (claimDetails as any).creditNote = creditNote; + (claimDetails as any).completionExpenses = completionExpenses; + } + + console.debug('[useRequestDetails] Extracted details:', { + claimDetails: claimDetails ? { + hasActivityName: !!(claimDetails.activityName || claimDetails.activity_name), + hasActivityType: !!(claimDetails.activityType || claimDetails.activity_type), + hasLocation: !!(claimDetails.location), + activityName: claimDetails.activityName || claimDetails.activity_name, + activityType: claimDetails.activityType || claimDetails.activity_type, + location: claimDetails.location, + allKeys: Object.keys(claimDetails), + } : null, + hasProposalDetails: !!proposalDetails, + hasCompletionDetails: !!completionDetails, + hasInternalOrder: !!internalOrder, + hasBudgetTracking: !!budgetTracking, + hasInvoice: !!invoice, + hasCreditNote: !!creditNote, + hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0, + }); + } else { + console.warn('[useRequestDetails] No claimData found in response'); + } + } catch (error: any) { + // Claim details not available - request might not be fully initialized yet + console.error('[useRequestDetails] Error fetching claim details:', { + error: error?.message || error, + status: error?.response?.status, + statusText: error?.response?.statusText, + responseData: error?.response?.data, + requestId: wf.requestId, + }); + } + } + /** * Build: Complete request object with all transformed data * This object is used throughout the UI @@ -242,6 +324,7 @@ export function useRequestDetails( description: wf.description, status: statusMap(wf.status), priority: (wf.priority || '').toString().toLowerCase(), + workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'), approvalFlow, approvals, // Raw approvals for SLA calculations participants, @@ -266,6 +349,16 @@ export function useRequestDetails( conclusionRemark: wf.conclusionRemark || null, closureDate: wf.closureDate || null, pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons + // Claim management specific data + claimDetails: claimDetails || null, + proposalDetails: proposalDetails || null, + completionDetails: completionDetails || null, + internalOrder: internalOrder || null, + // New normalized tables (also available via claimDetails for backward compatibility) + budgetTracking: (claimDetails as any)?.budgetTracking || null, + invoice: (claimDetails as any)?.invoice || null, + creditNote: (claimDetails as any)?.creditNote || null, + completionExpenses: (claimDetails as any)?.completionExpenses || null, }; setApiRequest(updatedRequest); @@ -441,6 +534,49 @@ export function useRequestDetails( console.debug('Pause details not available:', error); } + /** + * Fetch: Get claim details if this is a claim management request + */ + let claimDetails = null; + let proposalDetails = null; + let completionDetails = null; + let internalOrder = null; + + if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { + try { + console.debug('[useRequestDetails] Initial load - Fetching claim details for requestId:', wf.requestId); + const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); + console.debug('[useRequestDetails] Initial load - Claim API response:', { + status: claimResponse.status, + hasData: !!claimResponse.data, + dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [], + }); + + const claimData = claimResponse.data?.data || claimResponse.data; + if (claimData) { + claimDetails = claimData.claimDetails || claimData.claim_details; + proposalDetails = claimData.proposalDetails || claimData.proposal_details; + completionDetails = claimData.completionDetails || claimData.completion_details; + internalOrder = claimData.internalOrder || claimData.internal_order || null; + + console.debug('[useRequestDetails] Initial load - Extracted details:', { + hasClaimDetails: !!claimDetails, + claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [], + hasProposalDetails: !!proposalDetails, + hasCompletionDetails: !!completionDetails, + hasInternalOrder: !!internalOrder, + }); + } + } catch (error: any) { + // Claim details not available - request might not be fully initialized yet + console.error('[useRequestDetails] Initial load - Error fetching claim details:', { + error: error?.message || error, + status: error?.response?.status, + requestId: wf.requestId, + }); + } + } + // Build complete request object const mapped = { id: wf.requestNumber || wf.requestId, @@ -449,6 +585,7 @@ export function useRequestDetails( description: wf.description, priority, status: statusMap(wf.status), + workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'), summary, initiator: { name: wf.initiator?.displayName || wf.initiator?.email, @@ -472,6 +609,11 @@ export function useRequestDetails( conclusionRemark: wf.conclusionRemark || null, closureDate: wf.closureDate || null, pauseInfo: pauseInfo || null, + // Claim management specific data + claimDetails: claimDetails || null, + proposalDetails: proposalDetails || null, + completionDetails: completionDetails || null, + internalOrder: internalOrder || null, }; setApiRequest(mapped); diff --git a/src/pages/RequestDetail/RequestDetail.tsx b/src/pages/RequestDetail/RequestDetail.tsx index 70eb9ce..1066f9f 100644 --- a/src/pages/RequestDetail/RequestDetail.tsx +++ b/src/pages/RequestDetail/RequestDetail.tsx @@ -26,6 +26,7 @@ import { ShieldX, RefreshCw, ArrowLeft, + DollarSign, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; @@ -44,11 +45,15 @@ import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal'; import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi'; import { toast } from 'sonner'; import { OverviewTab } from './components/tabs/OverviewTab'; +import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab'; import { WorkflowTab } from './components/tabs/WorkflowTab'; +import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab'; import { DocumentsTab } from './components/tabs/DocumentsTab'; import { ActivityTab } from './components/tabs/ActivityTab'; import { WorkNotesTab } from './components/tabs/WorkNotesTab'; import { SummaryTab } from './components/tabs/SummaryTab'; +import { IOTab } from './components/tabs/IOTab'; +import { isClaimManagementRequest } from '@/utils/claimRequestUtils'; import { QuickActionsSidebar } from './components/QuickActionsSidebar'; import { RequestDetailModals } from './components/RequestDetailModals'; import { RequestDetailProps } from './types/requestDetail.types'; @@ -130,6 +135,74 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests accessDenied, } = useRequestDetails(requestIdentifier, dynamicRequests, user); + // Determine if user is initiator (from overview tab initiator info) + const currentUserId = (user as any)?.userId || ''; + const currentUserEmail = (user as any)?.email?.toLowerCase() || ''; + const initiatorUserId = apiRequest?.initiator?.userId; + const initiatorEmail = apiRequest?.initiator?.email?.toLowerCase(); + const isUserInitiator = apiRequest?.initiator && ( + (initiatorUserId && initiatorUserId === currentUserId) || + (initiatorEmail && initiatorEmail === currentUserEmail) + ); + + // Determine if user is department lead (whoever is in step 3 / approval level 3) + const approvalLevels = apiRequest?.approvalLevels || []; + const step3Level = approvalLevels.find((level: any) => + (level.levelNumber || level.level_number) === 3 + ); + const deptLeadUserId = step3Level?.approverId || step3Level?.approver?.userId; + const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver?.email || step3Level?.approverEmail || '').toLowerCase().trim(); + + // Check if user is department lead by userId or email (case-insensitive) + const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) || + (deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail); + + // Get step 3 status (case-insensitive check) + const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : ''; + const isStep3PendingOrInProgress = step3Status === 'PENDING' || + step3Status === 'IN_PROGRESS'; + + // Check if user is current approver for step 3 (can access IO tab when step is pending/in-progress) + // Also check if currentLevel is 3 (workflow is at step 3) + const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || 0; + const isStep3CurrentLevel = currentLevel === 3; + + const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && ( + (deptLeadUserId && deptLeadUserId === currentUserId) || + (deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail) + ); + + // Check if IO tab should be visible (for initiator and department lead in claim management requests) + // Department lead can access IO tab when they are the current approver for step 3 (to fetch and block IO) + const showIOTab = isClaimManagementRequest(apiRequest) && + (isUserInitiator || isDeptLead || isStep3CurrentApprover); + + // Debug logging for troubleshooting + console.debug('[RequestDetail] IO Tab visibility:', { + isClaimManagement: isClaimManagementRequest(apiRequest), + isUserInitiator, + isDeptLead, + isStep3CurrentApprover, + currentUserId, + currentUserEmail, + initiatorUserId, + initiatorEmail, + currentLevel, + isStep3CurrentLevel, + step3Level: step3Level ? { + levelNumber: step3Level.levelNumber || step3Level.level_number, + approverId: step3Level.approverId || step3Level.approver?.userId, + approverEmail: step3Level.approverEmail || step3Level.approver?.email, + status: step3Level.status, + statusUpper: step3Status, + isPendingOrInProgress: isStep3PendingOrInProgress + } : null, + deptLeadUserId, + deptLeadEmail, + emailMatch: deptLeadEmail && currentUserEmail ? deptLeadEmail === currentUserEmail : false, + showIOTab, + }); + const { mergedMessages, unreadWorkNotes, @@ -430,6 +503,16 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests Workflow + {showIOTab && ( + + + IO + + )} - + {isClaimManagementRequest(apiRequest) ? ( + + ) : ( + + )} {isClosed && ( @@ -502,22 +594,49 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests )} - { - if (!data.levelId) { - alert('Level ID not available'); - return; - } - setSkipApproverData(data); - setShowSkipApproverModal(true); - }} - onRefresh={refreshDetails} - /> + {isClaimManagementRequest(apiRequest) ? ( + { + if (!data.levelId) { + alert('Level ID not available'); + return; + } + setSkipApproverData(data); + setShowSkipApproverModal(true); + }} + onRefresh={refreshDetails} + /> + ) : ( + { + if (!data.levelId) { + alert('Level ID not available'); + return; + } + setSkipApproverData(data); + setShowSkipApproverModal(true); + }} + onRefresh={refreshDetails} + /> + )} + {showIOTab && ( + + + + )} + )}
diff --git a/src/pages/RequestDetail/components/QuickActionsSidebar.tsx b/src/pages/RequestDetail/components/QuickActionsSidebar.tsx index 03804aa..c24cddf 100644 --- a/src/pages/RequestDetail/components/QuickActionsSidebar.tsx +++ b/src/pages/RequestDetail/components/QuickActionsSidebar.tsx @@ -2,7 +2,7 @@ * Quick Actions Sidebar Component */ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; @@ -10,6 +10,9 @@ import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi'; import { useAuth } from '@/contexts/AuthContext'; import notificationApi, { type Notification } from '@/services/notificationApi'; +import { ProcessDetailsCard } from './claim-cards'; +import { isClaimManagementRequest } from '@/utils/claimRequestUtils'; +import { determineUserRole, getRoleBasedVisibility, mapToClaimManagementRequest } from '@/utils/claimDataMapper'; interface QuickActionsSidebarProps { request: any; @@ -27,6 +30,8 @@ interface QuickActionsSidebarProps { refreshTrigger?: number; // Trigger to refresh shared recipients list pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility) currentUserId?: string; // Current user's ID (kept for backwards compatibility) + apiRequest?: any; + onEditClaimAmount?: () => void; } export function QuickActionsSidebar({ @@ -43,6 +48,10 @@ export function QuickActionsSidebar({ onRetrigger, summaryId, refreshTrigger, + pausedByUserId: pausedByUserIdProp, + currentUserId: currentUserIdProp, + apiRequest, + onEditClaimAmount, }: QuickActionsSidebarProps) { const { user } = useAuth(); const [sharedRecipients, setSharedRecipients] = useState([]); @@ -50,8 +59,8 @@ export function QuickActionsSidebar({ const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false); const isClosed = request?.status === 'closed'; const isPaused = request?.pauseInfo?.isPaused || false; - const pausedByUserId = request?.pauseInfo?.pausedBy?.userId; - const currentUserId = (user as any)?.userId || ''; + const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId; + const currentUserId = currentUserIdProp || (user as any)?.userId || ''; // Both approver AND initiator can pause (when not already paused and not closed) const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator); @@ -117,6 +126,16 @@ export function QuickActionsSidebar({ fetchSharedRecipients(); }, [isClosed, summaryId, isInitiator, refreshTrigger]); + // Claim details for sidebar (only for claim management requests) + const claimSidebarData = useMemo(() => { + if (!apiRequest || !isClaimManagementRequest(apiRequest)) return null; + const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId); + if (!claimRequest) return null; + const userRole = determineUserRole(apiRequest, currentUserId); + const visibility = getRoleBasedVisibility(userRole); + return { claimRequest, visibility }; + }, [apiRequest, currentUserId]); + return (
{/* Quick Actions Card - Hide entire card for spectators and closed requests */} @@ -339,6 +358,21 @@ export function QuickActionsSidebar({ )} + + {/* Process details anchored at the bottom of the action sidebar for claim workflows */} + {claimSidebarData && ( + + )}
); } diff --git a/src/pages/RequestDetail/components/claim-cards/ActivityInformationCard.tsx b/src/pages/RequestDetail/components/claim-cards/ActivityInformationCard.tsx new file mode 100644 index 0000000..4caa392 --- /dev/null +++ b/src/pages/RequestDetail/components/claim-cards/ActivityInformationCard.tsx @@ -0,0 +1,177 @@ +/** + * ActivityInformationCard Component + * Displays activity details for Claim Management requests + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Calendar, MapPin, DollarSign, Receipt } from 'lucide-react'; +import { ClaimActivityInfo } from '../../types/claimManagement.types'; +import { format } from 'date-fns'; + +interface ActivityInformationCardProps { + activityInfo: ClaimActivityInfo; + className?: string; +} + +export function ActivityInformationCard({ activityInfo, className }: ActivityInformationCardProps) { + // Defensive check: Ensure activityInfo exists + if (!activityInfo) { + console.warn('[ActivityInformationCard] activityInfo is missing'); + return ( + + +

Activity information not available

+
+
+ ); + } + + const formatCurrency = (amount: string | number) => { + const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount; + if (isNaN(numAmount)) return 'N/A'; + return `₹${numAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + }; + + const formatDate = (dateString?: string) => { + if (!dateString) return 'N/A'; + try { + return format(new Date(dateString), 'MMM d, yyyy'); + } catch { + return dateString; + } + }; + + return ( + + + + + Activity Information + + + +
+ {/* Activity Name */} +
+ +

+ {activityInfo.activityName} +

+
+ + {/* Activity Type */} +
+ +

+ {activityInfo.activityType} +

+
+ + {/* Location */} +
+ +

+ + {activityInfo.location} +

+
+ + {/* Requested Date */} +
+ +

+ {formatDate(activityInfo.requestedDate)} +

+
+ + {/* Estimated Budget */} +
+ +

+ + {activityInfo.estimatedBudget + ? formatCurrency(activityInfo.estimatedBudget) + : 'TBD'} +

+
+ + {/* Closed Expenses - Show if value exists (including 0) */} + {activityInfo.closedExpenses !== undefined && activityInfo.closedExpenses !== null && ( +
+ +

+ + {formatCurrency(activityInfo.closedExpenses)} +

+
+ )} + + {/* Period */} + {activityInfo.period && ( +
+ +

+ {formatDate(activityInfo.period.startDate)} - {formatDate(activityInfo.period.endDate)} +

+
+ )} +
+ + {/* Closed Expenses Breakdown */} + {activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0 && ( +
+ +
+ {activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => ( +
+ {item.description} + + {formatCurrency(item.amount)} + +
+ ))} +
+ Total + + {formatCurrency( + activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0) + )} + +
+
+
+ )} + + {/* Description */} + {activityInfo.description && ( +
+ +

+ {activityInfo.description} +

+
+ )} +
+
+ ); +} + + diff --git a/src/pages/RequestDetail/components/claim-cards/DealerInformationCard.tsx b/src/pages/RequestDetail/components/claim-cards/DealerInformationCard.tsx new file mode 100644 index 0000000..d6e5538 --- /dev/null +++ b/src/pages/RequestDetail/components/claim-cards/DealerInformationCard.tsx @@ -0,0 +1,100 @@ +/** + * DealerInformationCard Component + * Displays dealer details for Claim Management requests + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Building, Mail, Phone, MapPin } from 'lucide-react'; +import { DealerInfo } from '../../types/claimManagement.types'; + +interface DealerInformationCardProps { + dealerInfo: DealerInfo; + className?: string; +} + +export function DealerInformationCard({ dealerInfo, className }: DealerInformationCardProps) { + // Defensive check: Ensure dealerInfo exists + if (!dealerInfo) { + console.warn('[DealerInformationCard] dealerInfo is missing'); + return ( + + +

Dealer information not available

+
+
+ ); + } + + // Check if essential fields are present + if (!dealerInfo.dealerCode && !dealerInfo.dealerName) { + console.warn('[DealerInformationCard] Dealer info missing essential fields:', dealerInfo); + return ( + + +

Dealer information incomplete

+
+
+ ); + } + + return ( + + + + + Dealer Information + + + + {/* Dealer Code and Name */} +
+
+ +

+ {dealerInfo.dealerCode} +

+
+ +
+ +

+ {dealerInfo.dealerName} +

+
+
+ + {/* Contact Information */} +
+ +
+ {/* Email */} +
+ + {dealerInfo.email} +
+ + {/* Phone */} +
+ + {dealerInfo.phone} +
+ + {/* Address */} +
+ + {dealerInfo.address} +
+
+
+
+
+ ); +} + + diff --git a/src/pages/RequestDetail/components/claim-cards/ProcessDetailsCard.tsx b/src/pages/RequestDetail/components/claim-cards/ProcessDetailsCard.tsx new file mode 100644 index 0000000..682f3ec --- /dev/null +++ b/src/pages/RequestDetail/components/claim-cards/ProcessDetailsCard.tsx @@ -0,0 +1,293 @@ +/** + * ProcessDetailsCard Component + * Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns + * Visibility controlled by user role + */ + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Activity, Receipt, DollarSign, Pen } from 'lucide-react'; +import { format } from 'date-fns'; + +// Local minimal types to avoid external dependency issues +interface IODetails { + ioNumber?: string; + remarks?: string; + availableBalance?: number; + blockedAmount?: number; + remainingBalance?: number; + blockedByName?: string; + blockedAt?: string; +} + +interface DMSDetails { + dmsNumber?: string; + remarks?: string; + createdByName?: string; + createdAt?: string; +} + +interface ClaimAmountDetails { + amount: number; + lastUpdatedBy?: string; + lastUpdatedAt?: string; +} + +interface CostBreakdownItem { + description: string; + amount: number; +} + +interface RoleBasedVisibility { + showIODetails: boolean; + showDMSDetails: boolean; + showClaimAmount: boolean; + canEditClaimAmount: boolean; +} + +interface ProcessDetailsCardProps { + ioDetails?: IODetails; + dmsDetails?: DMSDetails; + claimAmount?: ClaimAmountDetails; + estimatedBudgetBreakdown?: CostBreakdownItem[]; + closedExpensesBreakdown?: CostBreakdownItem[]; + visibility: RoleBasedVisibility; + onEditClaimAmount?: () => void; + className?: string; +} + +export function ProcessDetailsCard({ + ioDetails, + dmsDetails, + claimAmount, + estimatedBudgetBreakdown, + closedExpensesBreakdown, + visibility, + onEditClaimAmount, + className, +}: ProcessDetailsCardProps) { + const formatCurrency = (amount?: number | null) => { + if (amount === undefined || amount === null || Number.isNaN(amount)) { + return '₹0.00'; + } + return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + }; + + const formatDate = (dateString?: string | null) => { + if (!dateString) return ''; + try { + return format(new Date(dateString), 'MMM d, yyyy, h:mm a'); + } catch { + return dateString || ''; + } + }; + + const calculateTotal = (items?: CostBreakdownItem[]) => { + if (!items || items.length === 0) return 0; + return items.reduce((sum, item) => sum + (item.amount ?? 0), 0); + }; + + // Don't render if nothing to show + const hasContent = + (visibility.showIODetails && ioDetails) || + (visibility.showDMSDetails && dmsDetails) || + (visibility.showClaimAmount && claimAmount && claimAmount.amount !== undefined && claimAmount.amount !== null) || + (estimatedBudgetBreakdown && estimatedBudgetBreakdown.length > 0) || + (closedExpensesBreakdown && closedExpensesBreakdown.length > 0); + + if (!hasContent) { + return null; + } + + return ( + + + + + Process Details + + Workflow reference numbers + + + {/* IO Details - Only visible to internal RE users */} + {visibility.showIODetails && ioDetails && ( +
+
+ + +
+

{ioDetails.ioNumber}

+ + {ioDetails.remarks && ( +
+

Remark:

+

{ioDetails.remarks}

+
+ )} + + {/* Budget Details */} + {(ioDetails.availableBalance !== undefined || ioDetails.blockedAmount !== undefined) && ( +
+ {ioDetails.availableBalance !== undefined && ( +
+ Available Balance: + + {formatCurrency(ioDetails.availableBalance)} + +
+ )} + {ioDetails.blockedAmount !== undefined && ( +
+ Blocked Amount: + + {formatCurrency(ioDetails.blockedAmount)} + +
+ )} + {ioDetails.remainingBalance !== undefined && ( +
+ Remaining Balance: + + {formatCurrency(ioDetails.remainingBalance)} + +
+ )} +
+ )} + +
+

By {ioDetails.blockedByName}

+

{formatDate(ioDetails.blockedAt)}

+
+
+ )} + + {/* DMS Details */} + {visibility.showDMSDetails && dmsDetails && ( +
+
+ + +
+

{dmsDetails.dmsNumber}

+ + {dmsDetails.remarks && ( +
+

Remarks:

+

{dmsDetails.remarks}

+
+ )} + +
+

By {dmsDetails.createdByName}

+

{formatDate(dmsDetails.createdAt)}

+
+
+ )} + + {/* Claim Amount */} + {visibility.showClaimAmount && claimAmount && ( +
+
+
+ + +
+ {visibility.canEditClaimAmount && onEditClaimAmount && ( + + )} +
+

+ {formatCurrency(claimAmount.amount)} +

+ {claimAmount.lastUpdatedBy && ( +
+

+ Last updated by {claimAmount.lastUpdatedBy} +

+ {claimAmount.lastUpdatedAt && ( +

+ {formatDate(claimAmount.lastUpdatedAt)} +

+ )} +
+ )} +
+ )} + + {/* Estimated Budget Breakdown */} + {estimatedBudgetBreakdown && estimatedBudgetBreakdown.length > 0 && ( +
+
+ + +
+
+ {estimatedBudgetBreakdown.map((item, index) => ( +
+ {item.description} + + {formatCurrency(item.amount)} + +
+ ))} +
+ Total + + {formatCurrency(calculateTotal(estimatedBudgetBreakdown))} + +
+
+
+ )} + + {/* Closed Expenses Breakdown */} + {closedExpensesBreakdown && closedExpensesBreakdown.length > 0 && ( +
+
+ + +
+
+ {closedExpensesBreakdown.map((item, index) => ( +
+ {item.description} + + {formatCurrency(item.amount)} + +
+ ))} +
+ Total + + {formatCurrency(calculateTotal(closedExpensesBreakdown))} + +
+
+
+ )} +
+
+ ); +} + + diff --git a/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx b/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx new file mode 100644 index 0000000..111cacf --- /dev/null +++ b/src/pages/RequestDetail/components/claim-cards/ProposalDetailsCard.tsx @@ -0,0 +1,161 @@ +/** + * ProposalDetailsCard Component + * Displays proposal details submitted by dealer for Claim Management requests + */ + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; +import { Receipt, Calendar } from 'lucide-react'; +import { format } from 'date-fns'; + +// Minimal local types to avoid missing imports during runtime +interface ProposalCostItem { + description: string; + amount?: number | null; +} + +interface ProposalDetails { + costBreakup: ProposalCostItem[]; + estimatedBudgetTotal?: number | null; + timelineForClosure?: string | null; + dealerComments?: string | null; + submittedOn?: string | null; +} + +interface ProposalDetailsCardProps { + proposalDetails: ProposalDetails; + className?: string; +} + +export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) { + // Calculate estimated total from costBreakup if not provided + const calculateEstimatedTotal = () => { + if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) { + return proposalDetails.estimatedBudgetTotal; + } + + // Calculate sum from costBreakup items + if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) { + const total = proposalDetails.costBreakup.reduce((sum, item) => { + const amount = item.amount || 0; + return sum + (Number.isNaN(amount) ? 0 : amount); + }, 0); + return total; + } + + return 0; + }; + + const estimatedTotal = calculateEstimatedTotal(); + + const formatCurrency = (amount?: number | null) => { + if (amount === undefined || amount === null || Number.isNaN(amount)) { + return '₹0.00'; + } + return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + }; + + const formatDate = (dateString?: string | null) => { + if (!dateString) return ''; + try { + return format(new Date(dateString), 'MMM d, yyyy, h:mm a'); + } catch { + return dateString || ''; + } + }; + + const formatTimelineDate = (dateString?: string | null) => { + if (!dateString) return '-'; + try { + return format(new Date(dateString), 'MMM d, yyyy'); + } catch { + return dateString || '-'; + } + }; + + return ( + + + + + Proposal Details + + {proposalDetails.submittedOn && ( + + Submitted on {formatDate(proposalDetails.submittedOn)} + + )} + + + {/* Cost Breakup */} +
+ +
+ + + + + + + + + {(proposalDetails.costBreakup || []).map((item, index) => ( + + + + + ))} + + + + + +
+ Item Description + + Amount +
+ {item.description} + + {formatCurrency(item.amount)} +
+ Estimated Budget (Total) + + {formatCurrency(estimatedTotal)} +
+
+
+ + {/* Timeline for Closure */} +
+ +
+
+ + + Expected completion by: {formatTimelineDate(proposalDetails.timelineForClosure)} + +
+
+
+ + {/* Dealer Comments */} + {proposalDetails.dealerComments && ( +
+ +

+ {proposalDetails.dealerComments} +

+
+ )} +
+
+ ); +} + + diff --git a/src/pages/RequestDetail/components/claim-cards/RequestInitiatorCard.tsx b/src/pages/RequestDetail/components/claim-cards/RequestInitiatorCard.tsx new file mode 100644 index 0000000..d31030e --- /dev/null +++ b/src/pages/RequestDetail/components/claim-cards/RequestInitiatorCard.tsx @@ -0,0 +1,77 @@ +/** + * RequestInitiatorCard Component + * Displays initiator/requester details - can be used for both claim management and regular workflows + */ + +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Mail, Phone } from 'lucide-react'; + +interface InitiatorInfo { + name: string; + role?: string; + department?: string; + email: string; + phone?: string; +} + +interface RequestInitiatorCardProps { + initiatorInfo: InitiatorInfo; + className?: string; +} + +export function RequestInitiatorCard({ initiatorInfo, className }: RequestInitiatorCardProps) { + // Generate initials from name + const getInitials = (name: string) => { + return name + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2); + }; + + return ( + + + Request Initiator + + +
+ + + {getInitials(initiatorInfo.name)} + + +
+

{initiatorInfo.name}

+ {initiatorInfo.role && ( +

{initiatorInfo.role}

+ )} + {initiatorInfo.department && ( +

{initiatorInfo.department}

+ )} + +
+ {/* Email */} +
+ + {initiatorInfo.email} +
+ + {/* Phone */} + {initiatorInfo.phone && ( +
+ + {initiatorInfo.phone} +
+ )} +
+
+
+
+
+ ); +} + + diff --git a/src/pages/RequestDetail/components/claim-cards/index.ts b/src/pages/RequestDetail/components/claim-cards/index.ts new file mode 100644 index 0000000..a13bef2 --- /dev/null +++ b/src/pages/RequestDetail/components/claim-cards/index.ts @@ -0,0 +1,12 @@ +/** + * Claim Management Card Components + * Re-export all claim-specific card components for easy imports + */ + +export { ActivityInformationCard } from './ActivityInformationCard'; +export { DealerInformationCard } from './DealerInformationCard'; +export { ProposalDetailsCard } from './ProposalDetailsCard'; +export { ProcessDetailsCard } from './ProcessDetailsCard'; +export { RequestInitiatorCard } from './RequestInitiatorCard'; + + diff --git a/src/pages/RequestDetail/components/modals/CreditNoteSAPModal.tsx b/src/pages/RequestDetail/components/modals/CreditNoteSAPModal.tsx new file mode 100644 index 0000000..d6babc2 --- /dev/null +++ b/src/pages/RequestDetail/components/modals/CreditNoteSAPModal.tsx @@ -0,0 +1,270 @@ +/** + * CreditNoteSAPModal Component + * Modal for Step 8: Credit Note from SAP + * Allows Finance team to review credit note details and send to dealer + */ + +import { useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Receipt, CircleCheckBig, Hash, Calendar, DollarSign, Building, FileText, Download, Send } from 'lucide-react'; +import { toast } from 'sonner'; +import { formatDateTime } from '@/utils/dateFormatter'; + +interface CreditNoteSAPModalProps { + isOpen: boolean; + onClose: () => void; + onDownload?: () => Promise; + onSendToDealer?: () => Promise; + creditNoteData?: { + creditNoteNumber?: string; + creditNoteDate?: string; + creditNoteAmount?: number; + status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT'; + }; + dealerInfo?: { + dealerName?: string; + dealerCode?: string; + dealerEmail?: string; + }; + activityName?: string; + requestNumber?: string; + requestId?: string; + dueDate?: string; +} + +export function CreditNoteSAPModal({ + isOpen, + onClose, + onDownload, + onSendToDealer, + creditNoteData, + dealerInfo, + activityName, + requestNumber, + requestId: _requestId, + dueDate, +}: CreditNoteSAPModalProps) { + const [downloading, setDownloading] = useState(false); + const [sending, setSending] = useState(false); + + const creditNoteNumber = creditNoteData?.creditNoteNumber || 'CN-RE-REQ-2024-CM-101-312580'; + const creditNoteDate = creditNoteData?.creditNoteDate + ? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' }) + : 'Dec 5, 2025'; + const creditNoteAmount = creditNoteData?.creditNoteAmount || 800; + const status = creditNoteData?.status || 'APPROVED'; + + const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield'; + const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009'; + const activity = activityName || 'Activity'; + const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101'; + const dueDateDisplay = dueDate + ? formatDateTime(dueDate, { includeTime: false, format: 'short' }) + : 'Jan 4, 2026'; + + const handleDownload = async () => { + if (onDownload) { + try { + setDownloading(true); + await onDownload(); + toast.success('Credit note downloaded successfully'); + } catch (error) { + console.error('Failed to download credit note:', error); + toast.error('Failed to download credit note. Please try again.'); + } finally { + setDownloading(false); + } + } else { + // Default behavior: show info message + toast.info('Credit note will be automatically saved to Documents tab'); + } + }; + + const handleSendToDealer = async () => { + if (onSendToDealer) { + try { + setSending(true); + await onSendToDealer(); + toast.success('Credit note sent to dealer successfully'); + onClose(); + } catch (error) { + console.error('Failed to send credit note to dealer:', error); + toast.error('Failed to send credit note. Please try again.'); + } finally { + setSending(false); + } + } else { + // Default behavior: show info message + toast.info('Email notification will be sent to dealer with credit note attachment'); + } + }; + + const formatCurrency = (amount: number) => { + return `₹${amount.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + }; + + return ( + + + + + + Credit Note from SAP + + + Review and send credit note to dealer + + + +
+ {/* Credit Note Document Card */} +
+
+
+

Royal Enfield

+

Credit Note Document

+
+ + + {status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'} + +
+
+
+ +

{creditNoteNumber}

+
+
+ +

{creditNoteDate}

+
+
+
+ + {/* Credit Note Amount */} +
+ +

{formatCurrency(creditNoteAmount)}

+
+ + {/* Dealer Information */} +
+

+ + Dealer Information +

+
+
+ +

{dealerName}

+
+
+ +

{dealerCode}

+
+
+ +

{activity}

+
+
+
+ + {/* Reference Details */} +
+

+ + Reference Details +

+
+
+ +

{requestIdDisplay}

+
+
+ +

{dueDateDisplay}

+
+
+
+ + {/* Available Actions Info */} +
+ +
+

Available Actions

+
    +
  • + Download: Credit note will be automatically saved to Documents tab +
  • +
  • + Send to Dealer: Email notification will be sent to dealer with credit note attachment +
  • +
  • All actions will be recorded in activity trail for audit purposes
  • +
+
+
+
+ + + +
+ + +
+
+
+
+ ); +} + diff --git a/src/pages/RequestDetail/components/modals/DealerCompletionDocumentsModal.tsx b/src/pages/RequestDetail/components/modals/DealerCompletionDocumentsModal.tsx new file mode 100644 index 0000000..4865523 --- /dev/null +++ b/src/pages/RequestDetail/components/modals/DealerCompletionDocumentsModal.tsx @@ -0,0 +1,616 @@ +/** + * DealerCompletionDocumentsModal Component + * Modal for Step 5: Activity Completion Documents + * Allows dealers to upload completion documents, photos, expenses, and provide completion details + */ + +import { useState, useRef, useMemo } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Badge } from '@/components/ui/badge'; +import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert } from 'lucide-react'; +import { toast } from 'sonner'; + +interface ExpenseItem { + id: string; + description: string; + amount: number; +} + +interface DealerCompletionDocumentsModalProps { + isOpen: boolean; + onClose: () => void; + onSubmit: (data: { + activityCompletionDate: string; + numberOfParticipants?: number; + closedExpenses: ExpenseItem[]; + totalClosedExpenses: number; + completionDocuments: File[]; + activityPhotos: File[]; + invoicesReceipts?: File[]; + attendanceSheet?: File; + completionDescription: string; + }) => Promise; + dealerName?: string; + activityName?: string; + requestId?: string; +} + +export function DealerCompletionDocumentsModal({ + isOpen, + onClose, + onSubmit, + dealerName = 'Jaipur Royal Enfield', + activityName = 'Activity', + requestId: _requestId, +}: DealerCompletionDocumentsModalProps) { + const [activityCompletionDate, setActivityCompletionDate] = useState(''); + const [numberOfParticipants, setNumberOfParticipants] = useState(''); + const [expenseItems, setExpenseItems] = useState([]); + const [completionDocuments, setCompletionDocuments] = useState([]); + const [activityPhotos, setActivityPhotos] = useState([]); + const [invoicesReceipts, setInvoicesReceipts] = useState([]); + const [attendanceSheet, setAttendanceSheet] = useState(null); + const [completionDescription, setCompletionDescription] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const completionDocsInputRef = useRef(null); + const photosInputRef = useRef(null); + const invoicesInputRef = useRef(null); + const attendanceInputRef = useRef(null); + + // Calculate total closed expenses + const totalClosedExpenses = useMemo(() => { + return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0); + }, [expenseItems]); + + // Check if all required fields are filled + const isFormValid = useMemo(() => { + const hasCompletionDate = activityCompletionDate !== ''; + const hasDocuments = completionDocuments.length > 0; + const hasPhotos = activityPhotos.length > 0; + const hasDescription = completionDescription.trim().length > 0; + + return hasCompletionDate && hasDocuments && hasPhotos && hasDescription; + }, [activityCompletionDate, completionDocuments, activityPhotos, completionDescription]); + + // Get today's date in YYYY-MM-DD format for max date + const maxDate = new Date().toISOString().split('T')[0]; + + const handleAddExpense = () => { + setExpenseItems([ + ...expenseItems, + { id: Date.now().toString(), description: '', amount: 0 }, + ]); + }; + + const handleExpenseChange = (id: string, field: 'description' | 'amount', value: string | number) => { + setExpenseItems( + expenseItems.map((item) => + item.id === id ? { ...item, [field]: value } : item + ) + ); + }; + + const handleRemoveExpense = (id: string) => { + setExpenseItems(expenseItems.filter((item) => item.id !== id)); + }; + + const handleCompletionDocsChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + // Validate file types + const allowedTypes = ['.pdf', '.doc', '.docx', '.zip', '.rar']; + const invalidFiles = files.filter( + (file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext)) + ); + if (invalidFiles.length > 0) { + toast.error('Please upload PDF, DOC, DOCX, ZIP, or RAR files only'); + return; + } + setCompletionDocuments([...completionDocuments, ...files]); + } + }; + + const handleRemoveCompletionDoc = (index: number) => { + setCompletionDocuments(completionDocuments.filter((_, i) => i !== index)); + }; + + const handlePhotosChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + // Validate image files + const invalidFiles = files.filter( + (file) => !file.type.startsWith('image/') + ); + if (invalidFiles.length > 0) { + toast.error('Please upload image files only (JPG, PNG, etc.)'); + return; + } + setActivityPhotos([...activityPhotos, ...files]); + } + }; + + const handleRemovePhoto = (index: number) => { + setActivityPhotos(activityPhotos.filter((_, i) => i !== index)); + }; + + const handleInvoicesChange = (e: React.ChangeEvent) => { + const files = Array.from(e.target.files || []); + if (files.length > 0) { + // Validate file types + const allowedTypes = ['.pdf', '.jpg', '.jpeg', '.png']; + const invalidFiles = files.filter( + (file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext)) + ); + if (invalidFiles.length > 0) { + toast.error('Please upload PDF, JPG, or PNG files only'); + return; + } + setInvoicesReceipts([...invoicesReceipts, ...files]); + } + }; + + const handleRemoveInvoice = (index: number) => { + setInvoicesReceipts(invoicesReceipts.filter((_, i) => i !== index)); + }; + + const handleAttendanceChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + // Validate file types + const allowedTypes = ['.pdf', '.xlsx', '.xls', '.csv']; + const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase(); + if (!allowedTypes.includes(fileExtension)) { + toast.error('Please upload PDF, Excel, or CSV files only'); + return; + } + setAttendanceSheet(file); + } + }; + + const handleSubmit = async () => { + if (!isFormValid) { + toast.error('Please fill all required fields'); + return; + } + + // Filter valid expense items + const validExpenses = expenseItems.filter( + (item) => item.description.trim() !== '' && item.amount > 0 + ); + + try { + setSubmitting(true); + await onSubmit({ + activityCompletionDate, + numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined, + closedExpenses: validExpenses, + totalClosedExpenses, + completionDocuments, + activityPhotos, + invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined, + attendanceSheet: attendanceSheet || undefined, + completionDescription, + }); + handleReset(); + onClose(); + } catch (error) { + console.error('Failed to submit completion documents:', error); + toast.error('Failed to submit completion documents. Please try again.'); + } finally { + setSubmitting(false); + } + }; + + const handleReset = () => { + setActivityCompletionDate(''); + setNumberOfParticipants(''); + setExpenseItems([]); + setCompletionDocuments([]); + setActivityPhotos([]); + setInvoicesReceipts([]); + setAttendanceSheet(null); + setCompletionDescription(''); + if (completionDocsInputRef.current) completionDocsInputRef.current.value = ''; + if (photosInputRef.current) photosInputRef.current.value = ''; + if (invoicesInputRef.current) invoicesInputRef.current.value = ''; + if (attendanceInputRef.current) attendanceInputRef.current.value = ''; + }; + + const handleClose = () => { + if (!submitting) { + handleReset(); + onClose(); + } + }; + + return ( + + + + + + Activity Completion Documents + + + Step 5: Upload completion proof and final documents + +
+
+ Dealer: {dealerName} +
+
+ Activity: {activityName} +
+
+ Please upload completion documents, photos, and provide details about the completed activity. +
+
+
+ +
+ {/* Activity Completion Date */} +
+ + setActivityCompletionDate(e.target.value)} + /> +
+ + {/* Closed Expenses Section */} +
+
+
+

Closed Expenses

+ Optional +
+ +
+
+ {expenseItems.map((item) => ( +
+
+ + handleExpenseChange(item.id, 'description', e.target.value) + } + /> +
+
+ + handleExpenseChange(item.id, 'amount', parseFloat(e.target.value) || 0) + } + /> +
+ +
+ ))} + {expenseItems.length === 0 && ( +

+ No expenses added. Click "Add Expense" to add expense items. +

+ )} + {expenseItems.length > 0 && totalClosedExpenses > 0 && ( +
+
+ Total Closed Expenses: + + ₹{totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+
+ )} +
+
+ + {/* Completion Evidence Section */} +
+
+

Completion Evidence

+ Required +
+ + {/* Completion Documents */} +
+ +

+ Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder +

+
+ + +
+ {completionDocuments.length > 0 && ( +
+ {completionDocuments.map((file, index) => ( +
+ {file.name} + +
+ ))} +
+ )} +
+ + {/* Activity Photos */} +
+ +

+ Upload photos from the completed activity (event photos, installations, etc.) +

+
+ + +
+ {activityPhotos.length > 0 && ( +
+ {activityPhotos.map((file, index) => ( +
+ {file.name} + +
+ ))} +
+ )} +
+
+ + {/* Supporting Documents Section */} +
+
+

Supporting Documents

+ Optional +
+ + {/* Invoices/Receipts */} +
+ +

+ Upload invoices and receipts for expenses incurred +

+
+ + +
+ {invoicesReceipts.length > 0 && ( +
+ {invoicesReceipts.map((file, index) => ( +
+ {file.name} + +
+ ))} +
+ )} +
+ + {/* Attendance Sheet */} +
+ +

+ Upload attendance records or participant lists (if applicable) +

+
+ + +
+ {attendanceSheet && ( +
+ {attendanceSheet.name} + +
+ )} +
+
+ + {/* Completion Description */} +
+ +