claim management related tabls added dealers seeded untilt real dealers available tdb droped multiple times to make fresh setup

This commit is contained in:
laxmanhalaki 2025-12-09 20:48:08 +05:30
parent 2c0378c63a
commit 0e9f8adbf6
24 changed files with 5254 additions and 58 deletions

View File

@ -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);
<TabsContent value="overview">
{isClaimManagement ? (
<ClaimManagementOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={(user as any)?.userId}
isInitiator={isInitiator}
/>
) : (
<OverviewTab {...overviewProps} />
)}
</TabsContent>
<TabsContent value="workflow">
{isClaimManagement ? (
<DealerClaimWorkflowTab
request={request}
user={user}
isInitiator={isInitiator}
onRefresh={refreshDetails}
/>
) : (
<WorkflowTab {...workflowProps} />
)}
</TabsContent>
```
### 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' && (
<Badge variant="secondary">Claim Management</Badge>
)}
```
### 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

View File

@ -26,6 +26,7 @@ import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
// Combined Request Database for backward compatibility
// This combines both custom and claim management requests
@ -265,7 +266,50 @@ function AppRoutes({ onLogout }: AppProps) {
setApprovalAction(null);
};
const handleClaimManagementSubmit = (claimData: any) => {
const handleClaimManagementSubmit = async (claimData: any) => {
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,
};
// Call API to create claim request
const response = await createClaimRequest(payload);
const createdRequest = response.request;
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);
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 +501,7 @@ function AppRoutes({ onLogout }: AppProps) {
description: 'Your claim management request has been created successfully.',
});
navigate('/my-requests');
*/
};
return (

View File

@ -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<DealerInfo[]>([]);
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 */}
<div>
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
<Select value={formData.dealerCode} onValueChange={handleDealerChange}>
<Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
<SelectTrigger className="mt-2 h-12">
<SelectValue placeholder="Select dealer">
<SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
{formData.dealerCode && (
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{formData.dealerCode}</span>
@ -248,15 +276,19 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</SelectValue>
</SelectTrigger>
<SelectContent>
{DEALERS.map((dealer) => (
<SelectItem key={dealer.code} value={dealer.code}>
{dealers.length === 0 && !loadingDealers ? (
<div className="p-2 text-sm text-gray-500">No dealers available</div>
) : (
dealers.map((dealer) => (
<SelectItem key={dealer.dealerCode} value={dealer.dealerCode}>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
<span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
<span className="text-gray-400"></span>
<span>{dealer.name}</span>
<span>{dealer.dealerName}</span>
</div>
</SelectItem>
))}
))
)}
</SelectContent>
</Select>
{formData.dealerCode && (

View File

@ -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,66 @@ 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;
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),
});
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
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,
});
} 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 +303,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 +328,10 @@ 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,
};
setApiRequest(updatedRequest);
@ -441,6 +507,46 @@ 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;
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;
console.debug('[useRequestDetails] Initial load - Extracted details:', {
hasClaimDetails: !!claimDetails,
claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [],
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
});
}
} 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 +555,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 +579,10 @@ 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,
};
setApiRequest(mapped);

View File

@ -44,11 +44,14 @@ 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 { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
import { RequestDetailModals } from './components/RequestDetailModals';
import { RequestDetailProps } from './types/requestDetail.types';
@ -470,6 +473,14 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
{/* Left Column: Tab content */}
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
{isClaimManagementRequest(apiRequest) ? (
<ClaimManagementOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={(user as any)?.userId || ''}
isInitiator={isInitiator}
/>
) : (
<OverviewTab
request={request}
isInitiator={isInitiator}
@ -488,6 +499,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
/>
)}
</TabsContent>
{isClosed && (
@ -502,6 +514,22 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
)}
<TabsContent value="workflow" className="mt-0">
{isClaimManagementRequest(apiRequest) ? (
<DealerClaimWorkflowTab
request={request}
user={user}
isInitiator={isInitiator}
onSkipApprover={(data) => {
if (!data.levelId) {
alert('Level ID not available');
return;
}
setSkipApproverData(data);
setShowSkipApproverModal(true);
}}
onRefresh={refreshDetails}
/>
) : (
<WorkflowTab
request={request}
user={user}
@ -516,6 +544,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
}}
onRefresh={refreshDetails}
/>
)}
</TabsContent>
<TabsContent value="documents" className="mt-0">

View File

@ -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 (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Activity information not available</p>
</CardContent>
</Card>
);
}
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 (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Activity Name */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Activity Name
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{activityInfo.activityName}
</p>
</div>
{/* Activity Type */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Activity Type
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{activityInfo.activityType}
</p>
</div>
{/* Location */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Location
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
{activityInfo.location}
</p>
</div>
{/* Requested Date */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Requested Date
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{formatDate(activityInfo.requestedDate)}
</p>
</div>
{/* Estimated Budget */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Estimated Budget
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
{activityInfo.estimatedBudget
? formatCurrency(activityInfo.estimatedBudget)
: 'TBD'}
</p>
</div>
{/* Closed Expenses */}
{activityInfo.closedExpenses !== undefined && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Closed Expenses
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<Receipt className="w-4 h-4 text-blue-600" />
{formatCurrency(activityInfo.closedExpenses)}
</p>
</div>
)}
{/* Period */}
{activityInfo.period && (
<div className="col-span-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Period
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{formatDate(activityInfo.period.startDate)} - {formatDate(activityInfo.period.endDate)}
</p>
</div>
)}
</div>
{/* Closed Expenses Breakdown */}
{activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0 && (
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Closed Expenses Breakdown
</label>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
{activityInfo.closedExpensesBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
<span className="font-semibold text-gray-900">Total</span>
<span className="font-bold text-blue-600">
{formatCurrency(
activityInfo.closedExpensesBreakdown.reduce((sum, item) => sum + item.amount, 0)
)}
</span>
</div>
</div>
</div>
)}
{/* Description */}
{activityInfo.description && (
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Description
</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{activityInfo.description}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Dealer information not available</p>
</CardContent>
</Card>
);
}
// Check if essential fields are present
if (!dealerInfo.dealerCode && !dealerInfo.dealerName) {
console.warn('[DealerInformationCard] Dealer info missing essential fields:', dealerInfo);
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Dealer information incomplete</p>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="w-5 h-5 text-purple-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Dealer Code and Name */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Code
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{dealerInfo.dealerCode}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Name
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{dealerInfo.dealerName}
</p>
</div>
</div>
{/* Contact Information */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Contact Information
</label>
<div className="mt-2 space-y-2">
{/* Email */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4 text-gray-400" />
<span>{dealerInfo.email}</span>
</div>
{/* Phone */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4 text-gray-400" />
<span>{dealerInfo.phone}</span>
</div>
{/* Address */}
<div className="flex items-start gap-2 text-sm text-gray-700">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
<span>{dealerInfo.address}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,259 @@
/**
* 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 {
IODetails,
DMSDetails,
ClaimAmountDetails,
CostBreakdownItem,
RoleBasedVisibility,
} from '../../types/claimManagement.types';
import { format } from 'date-fns';
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) => {
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString;
}
};
const calculateTotal = (items: CostBreakdownItem[]) => {
return items.reduce((sum, item) => sum + item.amount, 0);
};
// Don't render if nothing to show
const hasContent =
(visibility.showIODetails && ioDetails) ||
(visibility.showDMSDetails && dmsDetails) ||
(visibility.showClaimAmount && claimAmount) ||
estimatedBudgetBreakdown ||
closedExpensesBreakdown;
if (!hasContent) {
return null;
}
return (
<Card className={`bg-gradient-to-br from-blue-50 to-purple-50 border-2 border-blue-200 ${className}`}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Activity className="w-4 h-4 text-blue-600" />
Process Details
</CardTitle>
<CardDescription>Workflow reference numbers</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* IO Details - Only visible to internal RE users */}
{visibility.showIODetails && ioDetails && (
<div className="bg-white rounded-lg p-3 border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-blue-600" />
<Label className="text-xs font-semibold text-blue-900 uppercase tracking-wide">
IO Number
</Label>
</div>
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
{ioDetails.remarks && (
<div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">Remark:</p>
<p className="text-xs text-gray-900">{ioDetails.remarks}</p>
</div>
)}
{/* Budget Details */}
{(ioDetails.availableBalance !== undefined || ioDetails.blockedAmount !== undefined) && (
<div className="pt-2 border-t border-blue-100 mt-2 space-y-1">
{ioDetails.availableBalance !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-gray-600">Available Balance:</span>
<span className="font-medium text-gray-900">
{formatCurrency(ioDetails.availableBalance)}
</span>
</div>
)}
{ioDetails.blockedAmount !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-gray-600">Blocked Amount:</span>
<span className="font-medium text-blue-700">
{formatCurrency(ioDetails.blockedAmount)}
</span>
</div>
)}
{ioDetails.remainingBalance !== undefined && (
<div className="flex justify-between text-xs">
<span className="text-gray-600">Remaining Balance:</span>
<span className="font-medium text-green-700">
{formatCurrency(ioDetails.remainingBalance)}
</span>
</div>
)}
</div>
)}
<div className="pt-2 border-t border-blue-100 mt-2">
<p className="text-xs text-gray-500">By {ioDetails.blockedByName}</p>
<p className="text-xs text-gray-500">{formatDate(ioDetails.blockedAt)}</p>
</div>
</div>
)}
{/* DMS Details */}
{visibility.showDMSDetails && dmsDetails && (
<div className="bg-white rounded-lg p-3 border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
DMS Number
</Label>
</div>
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
{dmsDetails.remarks && (
<div className="pt-2 border-t border-purple-100">
<p className="text-xs text-gray-600 mb-1">Remarks:</p>
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
</div>
)}
<div className="pt-2 border-t border-purple-100 mt-2">
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
</div>
</div>
)}
{/* Claim Amount */}
{visibility.showClaimAmount && claimAmount && (
<div className="bg-white rounded-lg p-3 border border-green-200">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
<Label className="text-xs font-semibold text-green-900 uppercase tracking-wide">
Claim Amount
</Label>
</div>
{visibility.canEditClaimAmount && onEditClaimAmount && (
<Button
variant="outline"
size="sm"
onClick={onEditClaimAmount}
className="h-7 px-2 text-xs border-green-300 hover:bg-green-50"
>
<Pen className="w-3 h-3 mr-1 text-green-700" />
Edit
</Button>
)}
</div>
<p className="text-2xl font-bold text-green-700">
{formatCurrency(claimAmount.amount)}
</p>
{claimAmount.lastUpdatedBy && (
<div className="mt-2 pt-2 border-t border-green-100">
<p className="text-xs text-gray-500">
Last updated by {claimAmount.lastUpdatedBy}
</p>
{claimAmount.lastUpdatedAt && (
<p className="text-xs text-gray-500">
{formatDate(claimAmount.lastUpdatedAt)}
</p>
)}
</div>
)}
</div>
)}
{/* Estimated Budget Breakdown */}
{estimatedBudgetBreakdown && estimatedBudgetBreakdown.length > 0 && (
<div className="bg-white rounded-lg p-3 border border-amber-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-amber-600" />
<Label className="text-xs font-semibold text-amber-900 uppercase tracking-wide">
Estimated Budget Breakdown
</Label>
</div>
<div className="space-y-1.5 pt-1">
{estimatedBudgetBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-amber-200 flex justify-between items-center">
<span className="font-semibold text-gray-900 text-xs">Total</span>
<span className="font-bold text-amber-700">
{formatCurrency(calculateTotal(estimatedBudgetBreakdown))}
</span>
</div>
</div>
</div>
)}
{/* Closed Expenses Breakdown */}
{closedExpensesBreakdown && closedExpensesBreakdown.length > 0 && (
<div className="bg-white rounded-lg p-3 border border-indigo-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-indigo-600" />
<Label className="text-xs font-semibold text-indigo-900 uppercase tracking-wide">
Closed Expenses Breakdown
</Label>
</div>
<div className="space-y-1.5 pt-1">
{closedExpensesBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-indigo-200 flex justify-between items-center">
<span className="font-semibold text-gray-900 text-xs">Total</span>
<span className="font-bold text-indigo-700">
{formatCurrency(calculateTotal(closedExpensesBreakdown))}
</span>
</div>
</div>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,123 @@
/**
* 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 { ProposalDetails } from '../../types/claimManagement.types';
import { format } from 'date-fns';
interface ProposalDetailsCardProps {
proposalDetails: ProposalDetails;
className?: string;
}
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString: string) => {
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString;
}
};
const formatTimelineDate = (dateString: string) => {
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return dateString;
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Receipt className="w-5 h-5 text-green-600" />
Proposal Details
</CardTitle>
{proposalDetails.submittedOn && (
<CardDescription>
Submitted on {formatDate(proposalDetails.submittedOn)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* Cost Breakup */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Cost Breakup
</label>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left text-xs font-semibold text-gray-700 uppercase tracking-wide">
Item Description
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
Amount
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{proposalDetails.costBreakup.map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900">
{item.description}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
{formatCurrency(item.amount)}
</td>
</tr>
))}
<tr className="bg-green-50 font-semibold">
<td className="px-4 py-3 text-sm text-gray-900">
Estimated Budget (Total)
</td>
<td className="px-4 py-3 text-sm text-green-700 text-right">
{formatCurrency(proposalDetails.estimatedBudgetTotal)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Timeline for Closure */}
<div className="pt-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Timeline for Closure
</label>
<div className="mt-2 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-gray-900">
Expected completion by: {formatTimelineDate(proposalDetails.timelineForClosure)}
</span>
</div>
</div>
</div>
{/* Dealer Comments */}
{proposalDetails.dealerComments && (
<div className="pt-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Comments
</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{proposalDetails.dealerComments}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">Request Initiator</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
<AvatarFallback className="bg-gray-700 text-white font-semibold text-lg">
{getInitials(initiatorInfo.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{initiatorInfo.name}</h3>
{initiatorInfo.role && (
<p className="text-sm text-gray-600">{initiatorInfo.role}</p>
)}
{initiatorInfo.department && (
<p className="text-sm text-gray-500">{initiatorInfo.department}</p>
)}
<div className="mt-3 space-y-2">
{/* Email */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{initiatorInfo.email}</span>
</div>
{/* Phone */}
{initiatorInfo.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{initiatorInfo.phone}</span>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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';

View File

@ -0,0 +1,485 @@
/**
* DealerProposalSubmissionModal Component
* Modal for Step 1: Dealer Proposal Submission
* Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments
*/
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, DollarSign, CircleAlert } from 'lucide-react';
import { toast } from 'sonner';
interface CostItem {
id: string;
description: string;
amount: number;
}
interface DealerProposalSubmissionModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: {
proposalDocument: File | null;
costBreakup: CostItem[];
expectedCompletionDate: string;
otherDocuments: File[];
dealerComments: string;
}) => Promise<void>;
dealerName?: string;
activityName?: string;
requestId?: string;
}
export function DealerProposalSubmissionModal({
isOpen,
onClose,
onSubmit,
dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity',
requestId,
}: DealerProposalSubmissionModalProps) {
const [proposalDocument, setProposalDocument] = useState<File | null>(null);
const [costItems, setCostItems] = useState<CostItem[]>([
{ id: '1', description: '', amount: 0 },
]);
const [timelineMode, setTimelineMode] = useState<'date' | 'days'>('date');
const [expectedCompletionDate, setExpectedCompletionDate] = useState('');
const [numberOfDays, setNumberOfDays] = useState('');
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
const [dealerComments, setDealerComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const proposalDocInputRef = useRef<HTMLInputElement>(null);
const otherDocsInputRef = useRef<HTMLInputElement>(null);
// Calculate total estimated budget
const totalBudget = useMemo(() => {
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
}, [costItems]);
// Check if all required fields are filled
const isFormValid = useMemo(() => {
const hasProposalDoc = proposalDocument !== null;
const hasValidCostItems = costItems.length > 0 &&
costItems.every(item => item.description.trim() !== '' && item.amount > 0);
const hasTimeline = timelineMode === 'date'
? expectedCompletionDate !== ''
: numberOfDays !== '' && parseInt(numberOfDays) > 0;
const hasComments = dealerComments.trim().length > 0;
return hasProposalDoc && hasValidCostItems && hasTimeline && hasComments;
}, [proposalDocument, costItems, timelineMode, expectedCompletionDate, numberOfDays, dealerComments]);
const handleProposalDocChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file type
const allowedTypes = ['.pdf', '.doc', '.docx'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(fileExtension)) {
toast.error('Please upload a PDF, DOC, or DOCX file');
return;
}
setProposalDocument(file);
}
};
const handleOtherDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setOtherDocuments(prev => [...prev, ...files]);
};
const handleAddCostItem = () => {
setCostItems(prev => [
...prev,
{ id: Date.now().toString(), description: '', amount: 0 },
]);
};
const handleRemoveCostItem = (id: string) => {
if (costItems.length > 1) {
setCostItems(prev => prev.filter(item => item.id !== id));
}
};
const handleCostItemChange = (id: string, field: 'description' | 'amount', value: string | number) => {
setCostItems(prev =>
prev.map(item =>
item.id === id
? { ...item, [field]: field === 'amount' ? parseFloat(value.toString()) || 0 : value }
: item
)
);
};
const handleRemoveOtherDoc = (index: number) => {
setOtherDocuments(prev => prev.filter((_, i) => i !== index));
};
const handleSubmit = async () => {
if (!isFormValid) {
toast.error('Please fill all required fields');
return;
}
// Calculate final completion date if using days mode
let finalCompletionDate = expectedCompletionDate;
if (timelineMode === 'days' && numberOfDays) {
const days = parseInt(numberOfDays);
const date = new Date();
date.setDate(date.getDate() + days);
finalCompletionDate = date.toISOString().split('T')[0];
}
try {
setSubmitting(true);
await onSubmit({
proposalDocument,
costBreakup: costItems.filter(item => item.description.trim() !== '' && item.amount > 0),
expectedCompletionDate: finalCompletionDate,
otherDocuments,
dealerComments,
});
handleReset();
onClose();
} catch (error) {
console.error('Failed to submit proposal:', error);
toast.error('Failed to submit proposal. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setProposalDocument(null);
setCostItems([{ id: '1', description: '', amount: 0 }]);
setTimelineMode('date');
setExpectedCompletionDate('');
setNumberOfDays('');
setOtherDocuments([]);
setDealerComments('');
if (proposalDocInputRef.current) proposalDocInputRef.current.value = '';
if (otherDocsInputRef.current) otherDocsInputRef.current.value = '';
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
// Get minimum date (today)
const minDate = new Date().toISOString().split('T')[0];
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
Dealer Proposal Submission
</DialogTitle>
<DialogDescription className="text-base">
Step 1: Upload proposal and planning details
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2">
Please upload proposal document, provide cost breakdown, timeline, and detailed comments.
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Proposal Document Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Proposal Document</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<div>
<Label className="text-base font-semibold flex items-center gap-2">
Proposal Document *
</Label>
<p className="text-sm text-gray-600 mb-2">
Detailed proposal with activity details and requested information
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
ref={proposalDocInputRef}
type="file"
accept=".pdf,.doc,.docx"
className="hidden"
id="proposalDoc"
onChange={handleProposalDocChange}
/>
<label
htmlFor="proposalDoc"
className="cursor-pointer flex flex-col items-center gap-2"
>
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
{proposalDocument
? proposalDocument.name
: 'Click to upload proposal (PDF, DOC, DOCX)'}
</span>
</label>
</div>
</div>
</div>
{/* Cost Breakup Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Cost Breakup</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<Button
type="button"
onClick={handleAddCostItem}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
Add Item
</Button>
</div>
<div className="space-y-3">
{costItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
placeholder="Item description (e.g., Banner printing, Event setup)"
value={item.description}
onChange={(e) =>
handleCostItemChange(item.id, 'description', e.target.value)
}
/>
</div>
<div className="w-40">
<Input
type="number"
placeholder="Amount"
min="0"
step="0.01"
value={item.amount || ''}
onChange={(e) =>
handleCostItemChange(item.id, 'amount', e.target.value)
}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveCostItem(item.id)}
disabled={costItems.length === 1}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
</div>
<div className="border-2 border-gray-300 rounded-lg p-4 bg-white">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-gray-700" />
<span className="font-semibold text-gray-900">Estimated Budget</span>
</div>
<div className="text-2xl font-bold text-gray-900">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</div>
{/* Timeline for Closure Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Timeline for Closure</h3>
<Badge className="bg-red-500 text-white text-xs">Required</Badge>
</div>
<div className="space-y-3">
<div className="flex gap-2">
<Button
type="button"
onClick={() => setTimelineMode('date')}
className={
timelineMode === 'date'
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
: 'border-2 hover:bg-gray-50'
}
size="sm"
>
<Calendar className="w-4 h-4 mr-1" />
Specific Date
</Button>
<Button
type="button"
onClick={() => setTimelineMode('days')}
className={
timelineMode === 'days'
? 'bg-[#2d4a3e] hover:bg-[#1f3329] text-white'
: 'border-2 hover:bg-gray-50'
}
size="sm"
>
Number of Days
</Button>
</div>
{timelineMode === 'date' ? (
<div>
<Label className="text-sm font-medium mb-2 block">
Expected Completion Date
</Label>
<Input
type="date"
min={minDate}
value={expectedCompletionDate}
onChange={(e) => setExpectedCompletionDate(e.target.value)}
/>
</div>
) : (
<div>
<Label className="text-sm font-medium mb-2 block">
Number of Days
</Label>
<Input
type="number"
placeholder="Enter number of days"
min="1"
value={numberOfDays}
onChange={(e) => setNumberOfDays(e.target.value)}
/>
</div>
)}
</div>
</div>
{/* Other Supporting Documents Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Other Supporting Documents</h3>
<Badge variant="secondary" className="text-xs">Optional</Badge>
</div>
<div>
<Label className="flex items-center gap-2 text-base font-semibold">
Additional Documents
</Label>
<p className="text-sm text-gray-600 mb-2">
Any other supporting documents (invoices, receipts, photos, etc.)
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
ref={otherDocsInputRef}
type="file"
multiple
className="hidden"
id="otherDocs"
onChange={handleOtherDocsChange}
/>
<label
htmlFor="otherDocs"
className="cursor-pointer flex flex-col items-center gap-2"
>
<Upload className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload additional documents (multiple files allowed)
</span>
</label>
</div>
{otherDocuments.length > 0 && (
<div className="mt-2 space-y-1">
{otherDocuments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
>
<span className="text-gray-700">{file.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-red-100"
onClick={() => handleRemoveOtherDoc(index)}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Dealer Comments Section */}
<div className="space-y-2">
<Label htmlFor="dealerComments" className="text-base font-semibold flex items-center gap-2">
Dealer Comments / Details *
</Label>
<Textarea
id="dealerComments"
placeholder="Provide detailed comments about this claim request, including any special considerations, execution details, or additional information..."
value={dealerComments}
onChange={(e) => setDealerComments(e.target.value)}
className="min-h-[120px]"
/>
<p className="text-xs text-gray-500">{dealerComments.length} characters</p>
</div>
{/* Warning Message */}
{!isFormValid && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start gap-3">
<CircleAlert className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<p className="font-semibold mb-1">Missing Required Information</p>
<p>
Please ensure proposal document, cost breakup, timeline, and dealer comments are provided before submitting.
</p>
</div>
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
>
{submitting ? 'Submitting...' : 'Submit Documents'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,304 @@
/**
* DeptLeadIOApprovalModal Component
* Modal for Step 3: Dept Lead Approval and IO Organization
* Allows department lead to approve request and organize IO details
*/
import { useState, 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 { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
import { toast } from 'sonner';
interface DeptLeadIOApprovalModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (data: {
ioNumber: string;
ioRemark: string;
comments: string;
}) => Promise<void>;
onReject: (comments: string) => Promise<void>;
requestTitle?: string;
requestId?: string;
}
export function DeptLeadIOApprovalModal({
isOpen,
onClose,
onApprove,
onReject,
requestTitle,
requestId,
}: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [ioNumber, setIoNumber] = useState('');
const [ioRemark, setIoRemark] = useState('');
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const ioRemarkChars = ioRemark.length;
const commentsChars = comments.length;
const maxIoRemarkChars = 300;
const maxCommentsChars = 500;
// Validate form
const isFormValid = useMemo(() => {
if (actionType === 'reject') {
return comments.trim().length > 0;
}
// For approve, need IO number, IO remark, and comments
return (
ioNumber.trim().length > 0 &&
ioRemark.trim().length > 0 &&
comments.trim().length > 0
);
}, [actionType, ioNumber, ioRemark, comments]);
const handleSubmit = async () => {
if (!isFormValid) {
if (actionType === 'approve') {
if (!ioNumber.trim()) {
toast.error('Please enter IO number');
return;
}
if (!ioRemark.trim()) {
toast.error('Please enter IO remark');
return;
}
}
if (!comments.trim()) {
toast.error('Please provide comments');
return;
}
return;
}
try {
setSubmitting(true);
if (actionType === 'approve') {
await onApprove({
ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
comments: comments.trim(),
});
} else {
await onReject(comments.trim());
}
handleReset();
onClose();
} catch (error) {
console.error(`Failed to ${actionType} request:`, error);
toast.error(`Failed to ${actionType} request. Please try again.`);
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setActionType('approve');
setIoNumber('');
setIoRemark('');
setComments('');
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-6 h-6 text-green-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-xl">
Approve and Organise IO
</DialogTitle>
<DialogDescription className="text-sm mt-1">
Enter blocked IO details and provide your approval comments
</DialogDescription>
</div>
</div>
{/* Request Info Card */}
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono">Step 3</Badge>
</div>
<div>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
APPROVE
</Badge>
</div>
</div>
</DialogHeader>
<div className="space-y-3">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 ${
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
Approve
</Button>
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
Reject
</Button>
</div>
{/* IO Organisation Details - Only shown when approving */}
{actionType === 'approve' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-2">
<div className="flex items-center gap-2">
<Receipt className="w-4 h-4 text-blue-600" />
<h4 className="font-semibold text-blue-900">IO Organisation Details</h4>
</div>
{/* IO Number */}
<div className="space-y-1">
<Label htmlFor="ioNumber" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
Blocked IO Number <span className="text-red-500">*</span>
</Label>
<Input
id="ioNumber"
placeholder="Enter IO number from SAP"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
className="bg-white h-8"
/>
</div>
{/* IO Remark */}
<div className="space-y-1">
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Remark <span className="text-red-500">*</span>
</Label>
<Textarea
id="ioRemark"
placeholder="Enter remarks about IO blocking"
value={ioRemark}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxIoRemarkChars) {
setIoRemark(value);
}
}}
rows={2}
className="bg-white text-sm min-h-[60px] resize-none"
/>
<div className="flex justify-end text-xs text-gray-600">
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
</div>
</div>
</div>
)}
{/* Comments & Remarks */}
<div className="space-y-1.5">
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
id="comment"
placeholder={
actionType === 'approve'
? 'Enter your approval comments and any conditions or notes...'
: 'Enter detailed reasons for rejection...'
}
value={comments}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxCommentsChars) {
setComments(value);
}
}}
rows={4}
className="text-sm min-h-[80px] resize-none"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-1">
<TriangleAlert className="w-3 h-3" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
</div>
</div>
</div>
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className={`${
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
} text-white`}
>
{submitting ? (
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
) : (
<>
<CircleCheckBig className="w-4 h-4 mr-2" />
{actionType === 'approve' ? 'Approve Request' : 'Reject Request'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,196 @@
/**
* EditClaimAmountModal Component
* Modal for editing claim amount (restricted by role)
*/
import { useState } 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 { DollarSign, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
interface EditClaimAmountModalProps {
isOpen: boolean;
onClose: () => void;
currentAmount: number;
onSubmit: (newAmount: number) => Promise<void>;
currency?: string;
}
export function EditClaimAmountModal({
isOpen,
onClose,
currentAmount,
onSubmit,
currency = '₹',
}: EditClaimAmountModalProps) {
const [amount, setAmount] = useState<string>(currentAmount.toString());
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string>('');
const formatCurrency = (value: number) => {
return `${currency}${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const handleAmountChange = (value: string) => {
// Remove non-numeric characters except decimal point
const cleaned = value.replace(/[^\d.]/g, '');
// Ensure only one decimal point
const parts = cleaned.split('.');
if (parts.length > 2) {
return;
}
setAmount(cleaned);
setError('');
};
const handleSubmit = async () => {
// Validate amount
const numAmount = parseFloat(amount);
if (isNaN(numAmount) || numAmount <= 0) {
setError('Please enter a valid amount greater than 0');
return;
}
if (numAmount === currentAmount) {
toast.info('Amount is unchanged');
onClose();
return;
}
try {
setSubmitting(true);
await onSubmit(numAmount);
onClose();
} catch (error) {
console.error('Failed to update claim amount:', error);
setError('Failed to update claim amount. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setAmount(currentAmount.toString());
setError('');
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-600" />
Edit Claim Amount
</DialogTitle>
<DialogDescription>
Update the claim amount. This will be recorded in the activity log.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Current Amount Display */}
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<Label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1 block">
Current Claim Amount
</Label>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(currentAmount)}
</p>
</div>
{/* New Amount Input */}
<div className="space-y-2">
<Label htmlFor="new-amount" className="text-sm font-medium">
New Claim Amount <span className="text-red-500">*</span>
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 font-semibold">
{currency}
</span>
<Input
id="new-amount"
type="text"
value={amount}
onChange={(e) => handleAmountChange(e.target.value)}
placeholder="Enter amount"
className="pl-8 text-lg font-semibold"
disabled={submitting}
/>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4" />
<span>{error}</span>
</div>
)}
</div>
{/* Amount Difference */}
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) !== currentAmount && (
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-700">Difference:</span>
<span className={`font-semibold ${
parseFloat(amount) > currentAmount ? 'text-green-700' : 'text-red-700'
}`}>
{parseFloat(amount) > currentAmount ? '+' : ''}
{formatCurrency(parseFloat(amount) - currentAmount)}
</span>
</div>
</div>
)}
{/* Warning Message */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<div className="flex gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-800">
<p className="font-semibold mb-1">Important:</p>
<ul className="space-y-1 list-disc list-inside">
<li>Ensure the new amount is verified and approved</li>
<li>This change will be logged in the activity trail</li>
<li>Budget blocking (IO) may need to be adjusted</li>
</ul>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || !amount || parseFloat(amount) <= 0}
className="bg-green-600 hover:bg-green-700"
>
{submitting ? 'Updating...' : 'Update Amount'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,443 @@
/**
* InitiatorProposalApprovalModal Component
* Modal for Step 2: Requestor Evaluation & Confirmation
* Allows initiator to review dealer's proposal and approve/reject
*/
import { useState, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import {
CheckCircle,
XCircle,
FileText,
DollarSign,
Calendar,
MessageSquare,
Download,
Eye,
} from 'lucide-react';
import { toast } from 'sonner';
interface CostItem {
id: string;
description: string;
amount: number;
}
interface ProposalData {
proposalDocument?: {
name: string;
url?: string;
id?: string;
};
costBreakup: CostItem[];
expectedCompletionDate: string;
otherDocuments?: Array<{
name: string;
url?: string;
id?: string;
}>;
dealerComments: string;
submittedAt?: string;
}
interface InitiatorProposalApprovalModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (comments: string) => Promise<void>;
onReject: (comments: string) => Promise<void>;
proposalData: ProposalData | null;
dealerName?: string;
activityName?: string;
requestId?: string;
}
export function InitiatorProposalApprovalModal({
isOpen,
onClose,
onApprove,
onReject,
proposalData,
dealerName = 'Dealer',
activityName = 'Activity',
requestId: _requestId, // Prefix with _ to indicate intentionally unused
}: InitiatorProposalApprovalModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
// Calculate total budget
const totalBudget = useMemo(() => {
if (!proposalData?.costBreakup) return 0;
// Ensure costBreakup is an array
const costBreakup = Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []);
if (!Array.isArray(costBreakup)) return 0;
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
return sum + (Number(amount) || 0);
}, 0);
}, [proposalData]);
// Format date
const formatDate = (dateString: string) => {
if (!dateString) return '—';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-IN', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
} catch {
return dateString;
}
};
const handleApprove = async () => {
if (!comments.trim()) {
toast.error('Please provide approval comments');
return;
}
try {
setSubmitting(true);
setActionType('approve');
await onApprove(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to approve proposal:', error);
toast.error('Failed to approve proposal. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReject = async () => {
if (!comments.trim()) {
toast.error('Please provide rejection reason');
return;
}
try {
setSubmitting(true);
setActionType('reject');
await onReject(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to reject proposal:', error);
toast.error('Failed to reject proposal. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReset = () => {
setComments('');
setActionType(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
// Don't return null - show modal even if proposalData is not loaded yet
// This allows the modal to open and show a loading/empty state
if (!isOpen) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0 border-b">
<DialogTitle className="flex items-center gap-2 text-2xl">
<CheckCircle className="w-6 h-6 text-green-600" />
Requestor Evaluation & Confirmation
</DialogTitle>
<DialogDescription className="text-base">
Step 2: Review dealer proposal and make a decision
</DialogDescription>
<div className="space-y-1 mt-2 text-sm text-gray-600">
<div>
<strong>Dealer:</strong> {dealerName}
</div>
<div>
<strong>Activity:</strong> {activityName}
</div>
<div className="mt-2 text-amber-600 font-medium">
Decision: <strong>Confirms?</strong> (YES Continue to Dept Lead / NO Request is cancelled)
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4 px-6 overflow-y-auto flex-1">
{/* Proposal Document Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
Proposal Document
</h3>
</div>
{proposalData?.proposalDocument ? (
<div className="border rounded-lg p-4 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileText className="w-8 h-8 text-blue-600" />
<div>
<p className="font-medium text-gray-900">{proposalData.proposalDocument.name}</p>
{proposalData?.submittedAt && (
<p className="text-xs text-gray-500">
Submitted on {formatDate(proposalData.submittedAt)}
</p>
)}
</div>
</div>
<div className="flex gap-2">
{proposalData.proposalDocument.url && (
<>
<Button
variant="outline"
size="sm"
onClick={() => window.open(proposalData.proposalDocument?.url, '_blank')}
>
<Eye className="w-4 h-4 mr-1" />
View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = proposalData.proposalDocument?.url || '';
link.download = proposalData.proposalDocument?.name || '';
link.click();
}}
>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
</>
)}
</div>
</div>
) : (
<p className="text-sm text-gray-500 italic">No proposal document available</p>
)}
</div>
{/* Cost Breakup Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-600" />
Cost Breakup
</h3>
</div>
{(() => {
// Ensure costBreakup is an array
const costBreakup = proposalData?.costBreakup
? (Array.isArray(proposalData.costBreakup)
? proposalData.costBreakup
: (typeof proposalData.costBreakup === 'string'
? JSON.parse(proposalData.costBreakup)
: []))
: [];
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
<>
<div className="border rounded-lg overflow-hidden">
<div className="bg-gray-50 px-4 py-2 border-b">
<div className="grid grid-cols-2 gap-4 text-sm font-semibold text-gray-700">
<div>Item Description</div>
<div className="text-right">Amount</div>
</div>
</div>
<div className="divide-y">
{costBreakup.map((item: any, index: number) => (
<div key={item?.id || item?.description || index} className="px-4 py-3 grid grid-cols-2 gap-4">
<div className="text-sm text-gray-700">{item?.description || 'N/A'}</div>
<div className="text-sm font-semibold text-gray-900 text-right">
{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
))}
</div>
</div>
<div className="bg-[--re-green] bg-opacity-10 border-2 border-[--re-green] rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[--re-green]" />
<span className="font-semibold text-gray-700">Total Estimated Budget</span>
</div>
<div className="text-2xl font-bold text-[--re-green]">
{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
</div>
</div>
</>
) : (
<p className="text-sm text-gray-500 italic">No cost breakdown available</p>
);
})()}
</div>
{/* Timeline Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<Calendar className="w-5 h-5 text-purple-600" />
Expected Completion Date
</h3>
</div>
<div className="border rounded-lg p-4 bg-gray-50">
<p className="text-lg font-semibold text-gray-900">
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
</p>
</div>
</div>
{/* Other Supporting Documents */}
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<FileText className="w-5 h-5 text-gray-600" />
Other Supporting Documents
</h3>
<Badge variant="secondary" className="text-xs">
{proposalData.otherDocuments.length} file(s)
</Badge>
</div>
<div className="space-y-2">
{proposalData.otherDocuments.map((doc, index) => (
<div
key={index}
className="border rounded-lg p-3 bg-gray-50 flex items-center justify-between"
>
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-gray-600" />
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
</div>
{doc.url && (
<Button
variant="ghost"
size="sm"
onClick={() => window.open(doc.url, '_blank')}
>
<Download className="w-4 h-4" />
</Button>
)}
</div>
))}
</div>
</div>
)}
{/* Dealer Comments */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Dealer Comments
</h3>
</div>
<div className="border rounded-lg p-4 bg-gray-50">
<p className="text-sm text-gray-700 whitespace-pre-wrap">
{proposalData?.dealerComments || 'No comments provided'}
</p>
</div>
</div>
{/* Decision Section */}
<div className="space-y-3 border-t pt-4">
<h3 className="font-semibold text-lg">Your Decision & Comments</h3>
<Textarea
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[120px]"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
{/* Warning for missing comments */}
{!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 flex items-start gap-2">
<XCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-4 flex-shrink-0 border-t bg-gray-50">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2"
>
Cancel
</Button>
<div className="flex gap-2">
<Button
onClick={handleReject}
disabled={!comments.trim() || submitting}
variant="destructive"
className="bg-red-600 hover:bg-red-700"
>
{submitting && actionType === 'reject' ? (
'Rejecting...'
) : (
<>
<XCircle className="w-4 h-4 mr-2" />
Reject (Cancel Request)
</>
)}
</Button>
<Button
onClick={handleApprove}
disabled={!comments.trim() || submitting}
className="bg-green-600 hover:bg-green-700 text-white"
>
{submitting && actionType === 'approve' ? (
'Approving...'
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Approve (Continue to Dept Lead)
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,184 @@
/**
* ClaimManagementOverviewTab Component
* Specialized overview tab for Claim Management requests
* Uses modular card components for flexible rendering based on role and request state
*/
import { useState } from 'react';
import {
ActivityInformationCard,
DealerInformationCard,
ProposalDetailsCard,
ProcessDetailsCard,
RequestInitiatorCard,
} from '../claim-cards';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import {
mapToClaimManagementRequest,
determineUserRole,
getRoleBasedVisibility,
type ClaimManagementRequest,
type RequestRole,
} from '@/utils/claimDataMapper';
interface ClaimManagementOverviewTabProps {
request: any; // Original request object
apiRequest: any; // API request data
currentUserId: string;
isInitiator: boolean;
onEditClaimAmount?: () => void;
className?: string;
}
export function ClaimManagementOverviewTab({
request,
apiRequest,
currentUserId,
isInitiator,
onEditClaimAmount,
className = '',
}: ClaimManagementOverviewTabProps) {
// Check if this is a claim management request
if (!isClaimManagementRequest(apiRequest)) {
return (
<div className="text-center py-8 text-gray-500">
<p>This is not a claim management request.</p>
</div>
);
}
// Map API data to claim management structure
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
if (!claimRequest) {
console.warn('[ClaimManagementOverviewTab] Failed to map claim data:', {
apiRequest,
hasClaimDetails: !!apiRequest?.claimDetails,
hasProposalDetails: !!apiRequest?.proposalDetails,
hasCompletionDetails: !!apiRequest?.completionDetails,
});
return (
<div className="text-center py-8 text-gray-500">
<p>Unable to load claim management data.</p>
<p className="text-xs mt-2">Please ensure the request has been properly initialized.</p>
</div>
);
}
// Debug: Log mapped data for troubleshooting
console.debug('[ClaimManagementOverviewTab] Mapped claim data:', {
activityInfo: claimRequest.activityInfo,
dealerInfo: claimRequest.dealerInfo,
hasProposalDetails: !!claimRequest.proposalDetails,
});
// Determine user's role
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
// Get visibility settings based on role
const visibility = getRoleBasedVisibility(userRole);
console.debug('[ClaimManagementOverviewTab] User role and visibility:', {
userRole,
visibility,
currentUserId,
});
// Extract initiator info from request
const initiatorInfo = {
name: apiRequest.requestedBy?.name || apiRequest.createdByName || 'Unknown',
role: 'initiator',
department: apiRequest.requestedBy?.department || apiRequest.department || '',
email: apiRequest.requestedBy?.email || 'N/A',
phone: apiRequest.requestedBy?.phone || apiRequest.requestedBy?.mobile,
};
return (
<div className={`grid grid-cols-1 lg:grid-cols-3 gap-6 ${className}`}>
{/* Left Column: Main Information Cards */}
<div className="lg:col-span-2 space-y-6">
{/* Activity Information - Always visible */}
<ActivityInformationCard activityInfo={claimRequest.activityInfo} />
{/* Dealer Information - Visible based on role */}
{visibility.showDealerInfo && (
<DealerInformationCard dealerInfo={claimRequest.dealerInfo} />
)}
{/* Proposal Details - Only shown after dealer submits proposal */}
{visibility.showProposalDetails && claimRequest.proposalDetails && (
<ProposalDetailsCard proposalDetails={claimRequest.proposalDetails} />
)}
{/* Request Initiator */}
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
</div>
{/* Right Column: Process Details Sidebar */}
<div className="space-y-6">
<ProcessDetailsCard
ioDetails={claimRequest.ioDetails}
dmsDetails={claimRequest.dmsDetails}
claimAmount={claimRequest.claimAmount}
estimatedBudgetBreakdown={claimRequest.proposalDetails?.costBreakup}
closedExpensesBreakdown={claimRequest.activityInfo.closedExpensesBreakdown}
visibility={visibility}
onEditClaimAmount={onEditClaimAmount}
/>
</div>
</div>
);
}
/**
* Wrapper component that decides whether to show claim management or regular overview
*/
interface AdaptiveOverviewTabProps {
request: any;
apiRequest: any;
currentUserId: string;
isInitiator: boolean;
onEditClaimAmount?: () => void;
// Props for regular overview tab
regularOverviewComponent?: React.ComponentType<any>;
regularOverviewProps?: any;
}
export function AdaptiveOverviewTab({
request,
apiRequest,
currentUserId,
isInitiator,
onEditClaimAmount,
regularOverviewComponent: RegularOverview,
regularOverviewProps,
}: AdaptiveOverviewTabProps) {
// Determine if this is a claim management request
const isClaim = isClaimManagementRequest(apiRequest);
if (isClaim) {
return (
<ClaimManagementOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={currentUserId}
isInitiator={isInitiator}
onEditClaimAmount={onEditClaimAmount}
/>
);
}
// Render regular overview if provided
if (RegularOverview) {
return <RegularOverview {...regularOverviewProps} />;
}
// Fallback
return (
<div className="text-center py-8 text-gray-500">
<p>No overview available for this request type.</p>
</div>
);
}

View File

@ -0,0 +1,311 @@
/**
* ClaimManagementWorkflowTab Component
* Displays the 8-step workflow process specific to Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
TrendingUp,
CircleCheckBig,
Clock,
Mail,
Download,
Receipt,
Activity,
AlertCircle,
} from 'lucide-react';
import { format } from 'date-fns';
interface WorkflowStep {
stepNumber: number;
stepName: string;
stepDescription: string;
assignedTo: string;
assignedToType: 'dealer' | 'requestor' | 'department_lead' | 'finance' | 'system';
status: 'pending' | 'in_progress' | 'approved' | 'rejected' | 'skipped';
tatHours: number;
elapsedHours?: number;
remarks?: string;
approvedAt?: string;
approvedBy?: string;
ioDetails?: {
ioNumber: string;
ioRemarks: string;
organisedBy: string;
organisedAt: string;
};
dmsDetails?: {
dmsNumber: string;
dmsRemarks: string;
pushedBy: string;
pushedAt: string;
};
hasEmailNotification?: boolean;
hasDownload?: boolean;
downloadUrl?: string;
}
interface ClaimManagementWorkflowTabProps {
steps: WorkflowStep[];
currentStep: number;
totalSteps?: number;
onViewEmailTemplate?: (stepNumber: number) => void;
onDownloadDocument?: (stepNumber: number, url: string) => void;
className?: string;
}
export function ClaimManagementWorkflowTab({
steps,
currentStep,
totalSteps = 8,
onViewEmailTemplate,
onDownloadDocument,
className = '',
}: ClaimManagementWorkflowTabProps) {
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString;
}
};
const getStepBorderColor = (status: string) => {
switch (status) {
case 'approved':
return 'border-green-500 bg-green-50';
case 'in_progress':
return 'border-blue-500 bg-blue-50';
case 'rejected':
return 'border-red-500 bg-red-50';
case 'pending':
return 'border-gray-300 bg-white';
case 'skipped':
return 'border-gray-400 bg-gray-50';
default:
return 'border-gray-300 bg-white';
}
};
const getStepIconBg = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100';
case 'in_progress':
return 'bg-blue-100';
case 'rejected':
return 'bg-red-100';
case 'pending':
return 'bg-gray-100';
case 'skipped':
return 'bg-gray-200';
default:
return 'bg-gray-100';
}
};
const getStepIcon = (status: string) => {
switch (status) {
case 'approved':
return <CircleCheckBig className="w-5 h-5 text-green-600" />;
case 'in_progress':
return <Clock className="w-5 h-5 text-blue-600" />;
case 'rejected':
return <AlertCircle className="w-5 h-5 text-red-600" />;
case 'pending':
return <Clock className="w-5 h-5 text-gray-400" />;
default:
return <Clock className="w-5 h-5 text-gray-400" />;
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'rejected':
return 'bg-red-100 text-red-800 border-red-200';
case 'pending':
return 'bg-gray-100 text-gray-600 border-gray-200';
case 'skipped':
return 'bg-gray-200 text-gray-700 border-gray-300';
default:
return 'bg-gray-100 text-gray-600 border-gray-200';
}
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="leading-none flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
Claim Management Workflow
</CardTitle>
<CardDescription className="mt-2">
8-Step approval process for dealer claim management
</CardDescription>
</div>
<Badge variant="outline" className="font-medium">
Step {currentStep} of {totalSteps}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{steps.map((step, index) => (
<div
key={step.stepNumber}
className={`relative p-5 rounded-lg border-2 transition-all ${getStepBorderColor(step.status)}`}
>
{/* Step Content */}
<div className="flex items-start gap-4">
{/* Icon */}
<div className={`p-3 rounded-xl ${getStepIconBg(step.status)}`}>
{getStepIcon(step.status)}
</div>
{/* Step Details */}
<div className="flex-1 min-w-0">
{/* Header Row */}
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">
Step {step.stepNumber}: {step.stepName}
</h4>
<Badge className={getStatusBadgeColor(step.status)}>
{step.status}
</Badge>
{/* Action Buttons */}
{step.hasEmailNotification && onViewEmailTemplate && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-blue-100"
onClick={() => onViewEmailTemplate(step.stepNumber)}
title="View email template"
>
<Mail className="w-3.5 h-3.5 text-blue-600" />
</Button>
)}
{step.hasDownload && onDownloadDocument && step.downloadUrl && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-green-100"
onClick={() => onDownloadDocument(step.stepNumber, step.downloadUrl!)}
title="Download E-Invoice"
>
<Download className="w-3.5 h-3.5 text-green-600" />
</Button>
)}
</div>
<p className="text-sm text-gray-600">{step.assignedTo}</p>
<p className="text-sm text-gray-500 mt-2 italic">
{step.stepDescription}
</p>
</div>
{/* TAT Info */}
<div className="text-right">
<p className="text-xs text-gray-500">TAT: {step.tatHours}h</p>
{step.elapsedHours !== undefined && (
<p className="text-xs text-gray-600 font-medium">
Elapsed: {step.elapsedHours}h
</p>
)}
</div>
</div>
{/* Remarks */}
{step.remarks && (
<div className="mt-3 p-3 bg-white rounded-lg border border-gray-200">
<p className="text-sm text-gray-700">{step.remarks}</p>
</div>
)}
{/* IO Details */}
{step.ioDetails && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-blue-600" />
<p className="text-xs font-semibold text-blue-900 uppercase tracking-wide">
IO Organisation Details
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900">
{step.ioDetails.ioNumber}
</span>
</div>
<div className="pt-1.5 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">IO Remark:</p>
<p className="text-sm text-gray-900">{step.ioDetails.ioRemarks}</p>
</div>
<div className="pt-1.5 border-t border-blue-100 text-xs text-gray-500">
Organised by {step.ioDetails.organisedBy} on{' '}
{formatDate(step.ioDetails.organisedAt)}
</div>
</div>
</div>
)}
{/* DMS Details */}
{step.dmsDetails && (
<div className="mt-3 p-3 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<p className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
DMS Processing Details
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">DMS Number:</span>
<span className="text-sm font-semibold text-gray-900">
{step.dmsDetails.dmsNumber}
</span>
</div>
<div className="pt-1.5 border-t border-purple-100">
<p className="text-xs text-gray-600 mb-1">DMS Remarks:</p>
<p className="text-sm text-gray-900">{step.dmsDetails.dmsRemarks}</p>
</div>
<div className="pt-1.5 border-t border-purple-100 text-xs text-gray-500">
Pushed by {step.dmsDetails.pushedBy} on{' '}
{formatDate(step.dmsDetails.pushedAt)}
</div>
</div>
</div>
)}
{/* Approval Timestamp */}
{step.approvedAt && (
<p className="text-xs text-gray-500 mt-2">
{step.status === 'approved' ? 'Approved' : 'Updated'} on{' '}
{formatDate(step.approvedAt)}
</p>
)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,950 @@
/**
* Dealer Claim Workflow Tab Component
*
* Purpose: Specialized workflow view for dealer claim management
* Features:
* - 8-step approval process visualization
* - Action buttons for document uploads, IO organization, DMS processing
* - Special sections for IO details and DMS details
* - Dealer-specific workflow steps
*/
import { useState, useMemo, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { TrendingUp, Clock, CheckCircle, Upload, Mail, Download, Receipt, Activity } from 'lucide-react';
import { formatDateTime } from '@/utils/dateFormatter';
import { DealerProposalSubmissionModal } from '../modals/DealerProposalSubmissionModal';
import { InitiatorProposalApprovalModal } from '../modals/InitiatorProposalApprovalModal';
import { DeptLeadIOApprovalModal } from '../modals/DeptLeadIOApprovalModal';
import { toast } from 'sonner';
import { submitProposal, updateIODetails } from '@/services/dealerClaimApi';
import { getWorkflowDetails, approveLevel, rejectLevel, updateWorkflow } from '@/services/workflowApi';
import { uploadDocument } from '@/services/documentApi';
import { createWorkNoteMultipart } from '@/services/workflowApi';
interface DealerClaimWorkflowTabProps {
request: any;
user: any;
isInitiator: boolean;
onSkipApprover?: (data: any) => void;
onRefresh?: () => void;
}
interface WorkflowStep {
step: number;
title: string;
approver: string;
description: string;
tatHours: number;
status: 'pending' | 'approved' | 'waiting' | 'rejected';
comment?: string;
approvedAt?: string;
elapsedHours?: number;
// Special fields for dealer claims
ioDetails?: {
ioNumber: string;
ioRemark: string;
organizedBy: string;
organizedAt: string;
};
dmsDetails?: {
dmsNumber: string;
dmsRemarks: string;
pushedBy: string;
pushedAt: string;
};
einvoiceUrl?: string;
emailTemplateUrl?: string;
}
/**
* Safe date formatter with fallback
*/
const formatDateSafe = (dateString: string | undefined | null): string => {
if (!dateString) return '';
try {
return formatDateTime(dateString);
} catch (error) {
// Fallback to simple date format
try {
return new Date(dateString).toLocaleString('en-IN', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true,
});
} catch {
return dateString;
}
}
};
/**
* Get step icon based on status
*/
const getStepIcon = (status: string) => {
switch (status) {
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'pending':
return <Clock className="w-5 h-5 text-blue-600" />;
case 'rejected':
return <CheckCircle className="w-5 h-5 text-red-600" />;
default:
return <Clock className="w-5 h-5 text-gray-400" />;
}
};
/**
* Get step badge variant
*/
const getStepBadgeVariant = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800 border-green-200';
case 'pending':
return 'bg-purple-100 text-purple-800 border-purple-200';
case 'rejected':
return 'bg-red-100 text-red-800 border-red-200';
default:
return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
/**
* Get step card styling
*/
const getStepCardStyle = (status: string, isActive: boolean) => {
if (isActive && status === 'pending') {
return 'border-purple-500 bg-purple-50 shadow-md';
}
if (status === 'approved') {
return 'border-green-500 bg-green-50';
}
if (status === 'rejected') {
return 'border-red-500 bg-red-50';
}
return 'border-gray-200 bg-white';
};
/**
* Get step icon background
*/
const getStepIconBg = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100';
case 'pending':
return 'bg-purple-100';
case 'rejected':
return 'bg-red-100';
default:
return 'bg-gray-100';
}
};
export function DealerClaimWorkflowTab({
request,
user,
isInitiator,
onSkipApprover,
onRefresh
}: DealerClaimWorkflowTabProps) {
const [showProposalModal, setShowProposalModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
const [showIOApprovalModal, setShowIOApprovalModal] = useState(false);
// Load approval flows from real API
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Reload approval flows whenever request changes or after refresh
useEffect(() => {
const loadApprovalFlows = async () => {
// First check if request has approvalFlow
if (request?.approvalFlow && request.approvalFlow.length > 0) {
setApprovalFlow(request.approvalFlow);
return;
}
// Load from real API
if (request?.id || request?.requestId) {
const requestId = request.id || request.requestId;
try {
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
if (approvals && approvals.length > 0) {
// Transform approval levels to match expected format
const flows = approvals.map((level: any) => ({
step: level.levelNumber || level.level_number || 0,
approver: level.approverName || level.approver_name || '',
status: level.status?.toLowerCase() || 'waiting',
tatHours: level.tatHours || level.tat_hours || 24,
elapsedHours: level.elapsedHours || level.elapsed_hours,
approvedAt: level.actionDate || level.action_date,
comment: level.comments || level.comment,
levelId: level.levelId || level.level_id,
}));
setApprovalFlow(flows);
}
} catch (error) {
console.warn('Failed to load approval flows from API:', error);
}
}
};
loadApprovalFlows();
}, [request, refreshTrigger]);
// Also reload when request.currentStep changes
useEffect(() => {
if (request?.id || request?.requestId) {
const requestId = request.id || request.requestId;
const loadApprovalFlows = async () => {
try {
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
if (approvals && approvals.length > 0) {
const flows = approvals.map((level: any) => ({
step: level.levelNumber || level.level_number || 0,
approver: level.approverName || level.approver_name || '',
status: level.status?.toLowerCase() || 'waiting',
tatHours: level.tatHours || level.tat_hours || 24,
elapsedHours: level.elapsedHours || level.elapsed_hours,
approvedAt: level.actionDate || level.action_date,
comment: level.comments || level.comment,
levelId: level.levelId || level.level_id,
}));
setApprovalFlow(flows);
}
} catch (error) {
console.warn('Failed to load approval flows from API:', error);
}
};
loadApprovalFlows();
}
}, [request?.currentStep]);
// Enhanced refresh handler that also reloads approval flows
const handleRefresh = () => {
setRefreshTrigger(prev => prev + 1);
onRefresh?.();
};
// Transform approval flow to dealer claim workflow steps
const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => {
const stepTitles = [
'Dealer - Proposal Submission',
'Requestor Evaluation & Confirmation',
'Dept Lead Approval',
'Activity Creation',
'Dealer - Completion Documents',
'Requestor - Claim Approval',
'E-Invoice Generation',
'Credit Note from SAP',
];
const stepDescriptions = [
'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
'E-invoice will be generated through DMS.',
'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
];
// Find approval data for this step
const approval = request?.approvals?.find((a: any) => a.levelId === step.levelId);
// Extract IO details from approval data or request (Step 3)
let ioDetails = undefined;
if (step.step === 3) {
if (approval?.ioDetails) {
ioDetails = {
ioNumber: approval.ioDetails.ioNumber || '',
ioRemark: approval.ioDetails.ioRemark || '',
organizedBy: approval.ioDetails.organizedBy || step.approver,
organizedAt: approval.ioDetails.organizedAt || step.approvedAt || '',
};
} else if (request?.ioNumber) {
// Fallback to request-level IO data
ioDetails = {
ioNumber: request.ioNumber || '',
ioRemark: request.ioRemark || request.ioDetails?.ioRemark || '',
organizedBy: step.approver,
organizedAt: step.approvedAt || request.updatedAt || '',
};
}
}
// Extract DMS details from approval data (Step 6)
let dmsDetails = undefined;
if (step.step === 6) {
if (approval?.dmsDetails) {
dmsDetails = {
dmsNumber: approval.dmsDetails.dmsNumber || '',
dmsRemarks: approval.dmsDetails.dmsRemarks || '',
pushedBy: approval.dmsDetails.pushedBy || step.approver,
pushedAt: approval.dmsDetails.pushedAt || step.approvedAt || '',
};
} else if (request?.dmsNumber) {
// Fallback to request-level DMS data
dmsDetails = {
dmsNumber: request.dmsNumber || '',
dmsRemarks: request.dmsRemarks || request.dmsDetails?.dmsRemarks || '',
pushedBy: step.approver,
pushedAt: step.approvedAt || request.updatedAt || '',
};
}
}
return {
step: step.step || index + 1,
title: stepTitles[index] || `Step ${step.step || index + 1}`,
approver: step.approver || 'Unknown',
description: stepDescriptions[index] || step.description || '',
tatHours: step.tatHours || 24,
status: (step.status || 'waiting').toLowerCase() as any,
comment: step.comment || approval?.comment,
approvedAt: step.approvedAt || approval?.timestamp,
elapsedHours: step.elapsedHours,
ioDetails,
dmsDetails,
einvoiceUrl: step.step === 7 ? (approval as any)?.einvoiceUrl : undefined,
emailTemplateUrl: step.step === 4 ? (approval as any)?.emailTemplateUrl : undefined,
};
});
const totalSteps = request?.totalSteps || 8;
// Calculate currentStep from approval flow - find the first pending step
// If no pending step, use the request's currentStep
const pendingStep = workflowSteps.find(s => s.status === 'pending');
const currentStep = pendingStep ? pendingStep.step : (request?.currentStep || 1);
const completedCount = workflowSteps.filter(s => s.status === 'approved').length;
// Handle proposal submission
const handleProposalSubmit = async (data: {
proposalDocument: File | null;
costBreakup: Array<{ id: string; description: string; amount: number }>;
expectedCompletionDate: string;
otherDocuments: File[];
dealerComments: string;
}) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Upload proposal document if provided
if (data.proposalDocument) {
await uploadDocument(data.proposalDocument, requestId, 'APPROVAL');
}
// Upload other supporting documents
for (const file of data.otherDocuments) {
await uploadDocument(file, requestId, 'SUPPORTING');
}
// Submit proposal using dealer claim API
const totalBudget = data.costBreakup.reduce((sum, item) => sum + item.amount, 0);
await submitProposal(requestId, {
proposalDocument: data.proposalDocument || undefined,
costBreakup: data.costBreakup.map(item => ({
description: item.description,
amount: item.amount,
})),
totalEstimatedBudget: totalBudget,
expectedCompletionDate: data.expectedCompletionDate,
dealerComments: data.dealerComments,
});
// Create work note for activity log
await createWorkNoteMultipart(requestId, {
message: `Dealer submitted proposal with ${data.costBreakup.length} cost items and estimated budget of ₹${totalBudget.toLocaleString('en-IN')}. ${data.dealerComments ? `Comments: ${data.dealerComments}` : ''}`,
isPriority: false,
}, []);
toast.success('Proposal submitted successfully');
handleRefresh();
} catch (error: any) {
console.error('Failed to submit proposal:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to submit proposal. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle proposal approval
const handleProposalApprove = async (comments: string) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get approval levels to find Step 2 levelId
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
const step2Level = approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 2
);
if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Step 2 approval level not found');
}
const levelId = step2Level.levelId || step2Level.level_id;
// Approve Step 2 using real API
await approveLevel(requestId, levelId, comments);
// Create work note for activity log
await createWorkNoteMultipart(requestId, {
message: `Requestor approved the dealer proposal. ${comments ? `Comments: ${comments}` : 'Proceeding to Dept Lead approval.'}`,
isPriority: false,
}, []);
toast.success('Proposal approved successfully');
handleRefresh();
} catch (error: any) {
console.error('Failed to approve proposal:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to approve proposal. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle proposal rejection
const handleProposalReject = async (comments: string) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get approval levels to find Step 2 levelId
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
const step2Level = approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 2
);
if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Step 2 approval level not found');
}
const levelId = step2Level.levelId || step2Level.level_id;
// Reject Step 2 using real API
await rejectLevel(requestId, levelId, 'Proposal rejected by requestor', comments);
// Create work note for activity log
await createWorkNoteMultipart(requestId, {
message: `Requestor rejected the dealer proposal. Request has been cancelled. ${comments ? `Reason: ${comments}` : ''}`,
isPriority: false,
}, []);
toast.success('Proposal rejected. Request has been cancelled.');
handleRefresh();
} catch (error: any) {
console.error('Failed to reject proposal:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to reject proposal. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle IO approval (Step 3)
const handleIOApproval = async (data: {
ioNumber: string;
ioRemark: string;
comments: string;
}) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get approval levels to find Step 3 levelId
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
const step3Level = approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3
);
if (!step3Level?.levelId && !step3Level?.level_id) {
throw new Error('Step 3 approval level not found');
}
const levelId = step3Level.levelId || step3Level.level_id;
// First, update IO details using dealer claim API
// Note: We need to get IO balance from SAP integration, but for now we'll use placeholder values
// The backend should handle SAP integration
await updateIODetails(requestId, {
ioNumber: data.ioNumber,
ioAvailableBalance: 0, // Should come from SAP integration
ioBlockedAmount: 0, // Should come from SAP integration
ioRemainingBalance: 0, // Should come from SAP integration
});
// Approve Step 3 using real API
await approveLevel(requestId, levelId, data.comments);
// Create work note for activity log
await createWorkNoteMultipart(requestId, {
message: `Dept Lead approved request and organized IO ${data.ioNumber}. ${data.ioRemark ? `IO Remark: ${data.ioRemark}. ` : ''}${data.comments ? `Comments: ${data.comments}` : 'Budget will be blocked in SAP.'}`,
isPriority: false,
}, []);
toast.success('Request approved and IO organized successfully');
handleRefresh();
} catch (error: any) {
console.error('Failed to approve and organize IO:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to approve request. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Handle IO rejection (Step 3)
const handleIORejection = async (comments: string) => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.id || request.requestId;
// Get approval levels to find Step 3 levelId
const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || [];
const step3Level = approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3
);
if (!step3Level?.levelId && !step3Level?.level_id) {
throw new Error('Step 3 approval level not found');
}
const levelId = step3Level.levelId || step3Level.level_id;
// Reject Step 3 using real API
await rejectLevel(requestId, levelId, 'Dept Lead rejected - More clarification required', comments);
// Create work note for activity log
await createWorkNoteMultipart(requestId, {
message: `Dept Lead rejected the request. More clarification required. Request has been cancelled. ${comments ? `Reason: ${comments}` : ''}`,
isPriority: false,
}, []);
toast.success('Request rejected. Request has been cancelled.');
handleRefresh();
} catch (error: any) {
console.error('Failed to reject request:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to reject request. Please try again.';
toast.error(errorMessage);
throw error;
}
};
// Extract proposal data from request
const [proposalData, setProposalData] = useState<any | null>(null);
useEffect(() => {
if (!request) {
setProposalData(null);
return;
}
const loadProposalData = async () => {
try {
const requestId = request.id || request.requestId;
if (!requestId) {
setProposalData(null);
return;
}
// Get workflow details which includes documents and proposal details
const details = await getWorkflowDetails(requestId);
const documents = details?.documents || [];
const proposalDetails = request.proposalDetails || details?.proposalDetails || {};
// Find proposal document (category APPROVAL or type proposal)
const proposalDoc = documents.find((d: any) =>
d.category === 'APPROVAL' || d.type === 'proposal' || d.documentCategory === 'APPROVAL'
);
// Find supporting documents
const otherDocs = documents.filter((d: any) =>
d.category === 'SUPPORTING' || d.type === 'supporting' || d.documentCategory === 'SUPPORTING'
);
// Ensure costBreakup is an array
let costBreakup = proposalDetails.costBreakup || [];
if (typeof costBreakup === 'string') {
try {
costBreakup = JSON.parse(costBreakup);
} catch (e) {
console.warn('Failed to parse costBreakup JSON:', e);
costBreakup = [];
}
}
if (!Array.isArray(costBreakup)) {
costBreakup = [];
}
setProposalData({
proposalDocument: proposalDoc ? {
name: proposalDoc.fileName || proposalDoc.file_name || proposalDoc.name,
id: proposalDoc.documentId || proposalDoc.document_id || proposalDoc.id,
} : undefined,
costBreakup: costBreakup,
expectedCompletionDate: proposalDetails.expectedCompletionDate || '',
otherDocuments: otherDocs.map((d: any) => ({
name: d.fileName || d.file_name || d.name,
id: d.documentId || d.document_id || d.id,
})),
dealerComments: proposalDetails.dealerComments || '',
submittedAt: proposalDetails.submittedAt,
});
} catch (error) {
console.warn('Failed to load proposal data:', error);
// Fallback to request data only
const proposalDetails = request.proposalDetails || {};
// Ensure costBreakup is an array
let costBreakup = proposalDetails.costBreakup || [];
if (typeof costBreakup === 'string') {
try {
costBreakup = JSON.parse(costBreakup);
} catch (e) {
console.warn('Failed to parse costBreakup JSON:', e);
costBreakup = [];
}
}
if (!Array.isArray(costBreakup)) {
costBreakup = [];
}
setProposalData({
proposalDocument: undefined,
costBreakup: costBreakup,
expectedCompletionDate: proposalDetails.expectedCompletionDate || '',
otherDocuments: [],
dealerComments: proposalDetails.dealerComments || '',
submittedAt: proposalDetails.submittedAt,
});
}
};
loadProposalData();
}, [request]);
// Get dealer and activity info
const dealerName = request?.claimDetails?.dealerName ||
request?.dealerInfo?.name ||
'Dealer';
const activityName = request?.claimDetails?.activityName ||
request?.activityInfo?.activityName ||
request?.title ||
'Activity';
return (
<>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
Claim Management Workflow
</CardTitle>
<CardDescription className="mt-2">
8-Step approval process for dealer claim management
</CardDescription>
</div>
<Badge variant="outline" className="font-medium">
Step {currentStep} of {totalSteps}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{workflowSteps.map((step, index) => {
const isActive = step.status === 'pending' && step.step === currentStep;
const isCompleted = step.status === 'approved';
const isWaiting = step.status === 'waiting';
// Debug logging for Step 2
if (step.step === 2) {
console.log('[DealerClaimWorkflowTab] Step 2 Debug:', {
step: step.step,
status: step.status,
currentStep,
isActive,
isInitiator,
showApprovalModal,
});
}
return (
<div
key={index}
className={`relative p-5 rounded-lg border-2 transition-all ${getStepCardStyle(step.status, isActive)}`}
>
<div className="flex items-start gap-4">
{/* Step Icon */}
<div className={`p-3 rounded-xl ${getStepIconBg(step.status)}`}>
{getStepIcon(step.status)}
</div>
{/* Step Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-4 mb-2">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-semibold text-gray-900">
Step {step.step}: {step.title}
</h4>
<Badge className={getStepBadgeVariant(step.status)}>
{step.status}
</Badge>
{/* Email Template Button (Step 4) */}
{step.step === 4 && step.emailTemplateUrl && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-blue-100"
title="View email template"
onClick={() => window.open(step.emailTemplateUrl, '_blank')}
>
<Mail className="w-3.5 h-3.5 text-blue-600" />
</Button>
)}
{/* E-Invoice Download Button (Step 7) */}
{step.step === 7 && step.einvoiceUrl && isCompleted && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-green-100"
title="Download E-Invoice"
onClick={() => window.open(step.einvoiceUrl, '_blank')}
>
<Download className="w-3.5 h-3.5 text-green-600" />
</Button>
)}
</div>
<p className="text-sm text-gray-600">{step.approver}</p>
<p className="text-sm text-gray-500 mt-2 italic">{step.description}</p>
</div>
<div className="text-right">
<p className="text-xs text-gray-500">TAT: {step.tatHours}h</p>
{step.elapsedHours && (
<p className="text-xs text-gray-600 font-medium">
Elapsed: {step.elapsedHours}h
</p>
)}
</div>
</div>
{/* Comment Section */}
{step.comment && (
<div className="mt-3 p-3 bg-white rounded-lg border border-gray-200">
<p className="text-sm text-gray-700">{step.comment}</p>
</div>
)}
{/* IO Organization Details (Step 3) */}
{step.step === 3 && step.ioDetails && step.ioDetails.ioNumber && (
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2 mb-2">
<Receipt className="w-4 h-4 text-blue-600" />
<p className="text-xs font-semibold text-blue-900 uppercase tracking-wide">
IO Organisation Details
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900">
{step.ioDetails.ioNumber}
</span>
</div>
{step.ioDetails.ioRemark && (
<div className="pt-1.5 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">IO Remark:</p>
<p className="text-sm text-gray-900">{step.ioDetails.ioRemark}</p>
</div>
)}
{step.ioDetails.organizedAt && (
<div className="pt-1.5 border-t border-blue-100 text-xs text-gray-500">
Organised by {step.ioDetails.organizedBy} on{' '}
{formatDateSafe(step.ioDetails.organizedAt)}
</div>
)}
</div>
</div>
)}
{/* DMS Processing Details (Step 6) */}
{step.step === 6 && step.dmsDetails && step.dmsDetails.dmsNumber && (
<div className="mt-3 p-3 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<p className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
DMS Processing Details
</p>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-600">DMS Number:</span>
<span className="text-sm font-semibold text-gray-900">
{step.dmsDetails.dmsNumber}
</span>
</div>
{step.dmsDetails.dmsRemarks && (
<div className="pt-1.5 border-t border-purple-100">
<p className="text-xs text-gray-600 mb-1">DMS Remarks:</p>
<p className="text-sm text-gray-900">{step.dmsDetails.dmsRemarks}</p>
</div>
)}
{step.dmsDetails.pushedAt && (
<div className="pt-1.5 border-t border-purple-100 text-xs text-gray-500">
Pushed by {step.dmsDetails.pushedBy} on{' '}
{formatDateSafe(step.dmsDetails.pushedAt)}
</div>
)}
</div>
</div>
)}
{/* Action Buttons */}
{isActive && (
<div className="mt-4 flex gap-2">
{/* Step 1: Submit Proposal Button */}
{step.step === 1 && (
<Button
className="bg-purple-600 hover:bg-purple-700"
onClick={() => {
console.log('[DealerClaimWorkflowTab] Opening proposal submission modal for Step 1');
setShowProposalModal(true);
}}
>
<Upload className="w-4 h-4 mr-2" />
Submit Proposal
</Button>
)}
{/* Step 2: Review & Approve Proposal - Show for initiator when step is active */}
{step.step === 2 && isInitiator && (
<Button
className="bg-blue-600 hover:bg-blue-700"
onClick={() => {
console.log('[DealerClaimWorkflowTab] Opening approval modal for Step 2');
setShowApprovalModal(true);
}}
>
<CheckCircle className="w-4 h-4 mr-2" />
Review & Evaluate Proposal
</Button>
)}
{/* Step 3: Approve and Organise IO */}
{step.step === 3 && (
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => {
console.log('[DealerClaimWorkflowTab] Opening IO approval modal for Step 3');
setShowIOApprovalModal(true);
}}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve and Organise IO
</Button>
)}
{/* Step 5: Upload Completion Documents */}
{step.step === 5 && (
<Button
className="bg-purple-600 hover:bg-purple-700"
onClick={() => {
console.log('[DealerClaimWorkflowTab] Upload Completion Documents clicked for Step 5');
// TODO: Open document upload modal
toast.info('Document upload feature coming soon');
}}
>
<Upload className="w-4 h-4 mr-2" />
Upload Documents
</Button>
)}
</div>
)}
{/* Approved Date */}
{step.approvedAt && (
<p className="text-xs text-gray-500 mt-2">
Approved on {formatDateSafe(step.approvedAt)}
</p>
)}
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Dealer Proposal Submission Modal */}
<DealerProposalSubmissionModal
isOpen={showProposalModal}
onClose={() => setShowProposalModal(false)}
onSubmit={handleProposalSubmit}
dealerName={dealerName}
activityName={activityName}
requestId={request?.id || request?.requestId}
/>
{/* Initiator Proposal Approval Modal */}
<InitiatorProposalApprovalModal
isOpen={showApprovalModal}
onClose={() => {
console.log('[DealerClaimWorkflowTab] Closing approval modal');
setShowApprovalModal(false);
}}
onApprove={handleProposalApprove}
onReject={handleProposalReject}
proposalData={proposalData}
dealerName={dealerName}
activityName={activityName}
requestId={request?.id || request?.requestId}
/>
{/* Dept Lead IO Approval Modal */}
<DeptLeadIOApprovalModal
isOpen={showIOApprovalModal}
onClose={() => setShowIOApprovalModal(false)}
onApprove={handleIOApproval}
onReject={handleIORejection}
requestTitle={request?.title}
requestId={request?.id || request?.requestId}
/>
</>
);
}

View File

@ -0,0 +1,435 @@
/**
* IO Tab Component
*
* Purpose: Handle IO (Internal Order) budget management for dealer claims
* Features:
* - Fetch IO budget from SAP
* - Block IO amount in SAP
* - Display blocked IO details
*/
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DollarSign, Download, CircleCheckBig, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { mockApi } from '@/services/mockApi';
// Helper to extract data from API response and handle errors
const handleApiResponse = <T>(response: any): T => {
if (!response.success) {
const errorMsg = response.error?.message || 'Operation failed';
throw new Error(errorMsg);
}
return response.data;
};
interface IOTabProps {
request: any;
apiRequest?: any;
onRefresh?: () => void;
}
interface IOBlockedDetails {
ioNumber: string;
blockedAmount: number;
availableBalance: number;
blockedDate: string;
sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed';
}
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const requestId = apiRequest?.requestId || request?.requestId;
const [ioNumber, setIoNumber] = useState(request?.ioNumber || '');
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block from mock API
useEffect(() => {
if (requestId) {
mockApi.getIOBlock(requestId).then(response => {
try {
const ioBlock = handleApiResponse<any>(response);
if (ioBlock) {
setBlockedDetails({
ioNumber: ioBlock.ioNumber,
blockedAmount: ioBlock.blockedAmount,
availableBalance: ioBlock.availableBalance,
blockedDate: ioBlock.blockedDate,
sapDocumentNumber: ioBlock.sapDocumentNumber,
status: ioBlock.status,
});
setIoNumber(ioBlock.ioNumber);
}
} catch (error) {
// IO block not found is expected for new requests
console.debug('No IO block found for request:', requestId);
}
}).catch(error => {
console.warn('Error loading IO block:', error);
});
}
}, [requestId]);
/**
* Fetch available budget from SAP
*/
const handleFetchAmount = async () => {
if (!ioNumber.trim()) {
toast.error('Please enter an IO number');
return;
}
setFetchingAmount(true);
try {
// TODO: Replace with actual SAP API integration
// const response = await fetch(`/api/sap/io/${ioNumber}/budget`);
// const data = await response.json();
// Mock API call - simulate SAP integration
await new Promise(resolve => setTimeout(resolve, 1500));
// Mock response
const mockAvailableAmount = 50000; // ₹50,000
setFetchedAmount(mockAvailableAmount);
toast.success('IO budget fetched successfully from SAP');
} catch (error: any) {
console.error('Failed to fetch IO budget:', error);
toast.error(error.message || 'Failed to fetch IO budget from SAP');
setFetchedAmount(null);
} finally {
setFetchingAmount(false);
}
};
/**
* Block budget in SAP system
*/
const handleBlockBudget = async () => {
if (!ioNumber.trim() || !fetchedAmount) {
toast.error('Please fetch IO amount first');
return;
}
const claimAmount = request?.claimAmount || request?.amount || 0;
if (claimAmount > fetchedAmount) {
toast.error('Claim amount exceeds available IO budget');
return;
}
setBlockingBudget(true);
try {
// TODO: Replace with actual SAP API integration
// const response = await fetch(`/api/sap/io/block`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// ioNumber,
// amount: claimAmount,
// requestId: apiRequest?.requestId,
// }),
// });
// const data = await response.json();
// Mock API call
await new Promise(resolve => setTimeout(resolve, 2000));
// Mock blocked details
const blocked: IOBlockedDetails = {
ioNumber,
blockedAmount: claimAmount,
availableBalance: fetchedAmount - claimAmount,
blockedDate: new Date().toISOString(),
sapDocumentNumber: `SAP-${Date.now()}`,
status: 'blocked',
};
// Save to mock API
if (requestId) {
try {
const ioBlockResponse = await mockApi.createIOBlock(requestId, {
id: `io-${Date.now()}`,
ioNumber: blocked.ioNumber,
blockedAmount: blocked.blockedAmount,
availableBalance: blocked.availableBalance,
blockedDate: blocked.blockedDate,
sapDocumentNumber: blocked.sapDocumentNumber,
status: blocked.status,
});
handleApiResponse(ioBlockResponse);
// Update request with IO number
const updateResponse = await mockApi.updateRequest(requestId, {
ioNumber: blocked.ioNumber,
ioBlockedAmount: blocked.blockedAmount,
sapDocumentNumber: blocked.sapDocumentNumber,
});
handleApiResponse(updateResponse);
// Create activity log
const activityResponse = await mockApi.createActivity(requestId, {
id: `act-${Date.now()}`,
type: 'io_blocked',
action: 'IO Budget Blocked',
details: `IO ${ioNumber} budget of ₹${claimAmount.toLocaleString('en-IN')} blocked in SAP`,
user: 'System',
message: `IO budget blocked: ${blocked.sapDocumentNumber}`,
});
handleApiResponse(activityResponse);
} catch (error) {
console.error('Failed to save IO block to database:', error);
}
}
setBlockedDetails(blocked);
toast.success('IO budget blocked successfully in SAP');
// Refresh request details
onRefresh?.();
} catch (error: any) {
console.error('Failed to block IO budget:', error);
toast.error(error.message || 'Failed to block IO budget in SAP');
} finally {
setBlockingBudget(false);
}
};
/**
* Release blocked budget
*/
const handleReleaseBudget = async () => {
if (!blockedDetails || !requestId) return;
try {
// TODO: Replace with actual SAP API integration
await new Promise(resolve => setTimeout(resolve, 1500));
// Update IO block in mock API
try {
const ioBlockResponse = await mockApi.getIOBlock(requestId);
const ioBlock = handleApiResponse<any>(ioBlockResponse);
if (ioBlock) {
const updateIOResponse = await mockApi.updateIOBlock(requestId, {
status: 'released',
releasedDate: new Date().toISOString(),
});
handleApiResponse(updateIOResponse);
// Update request
const updateRequestResponse = await mockApi.updateRequest(requestId, {
ioBlockedAmount: null,
sapDocumentNumber: null,
});
handleApiResponse(updateRequestResponse);
// Create activity log
const activityResponse = await mockApi.createActivity(requestId, {
id: `act-${Date.now()}`,
type: 'io_released',
action: 'IO Budget Released',
details: `IO ${blockedDetails.ioNumber} budget released`,
user: 'System',
message: 'IO budget released',
});
handleApiResponse(activityResponse);
}
} catch (error) {
console.error('Failed to update IO block in database:', error);
}
setBlockedDetails(null);
setFetchedAmount(null);
setIoNumber('');
toast.success('IO budget released successfully');
onRefresh?.();
} catch (error: any) {
console.error('Failed to release IO budget:', error);
toast.error(error.message || 'Failed to release IO budget');
}
};
const claimAmount = request?.claimAmount || request?.amount || 0;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* IO Budget Management Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-[#2d4a3e]" />
IO Budget Management
</CardTitle>
<CardDescription>
Enter IO number to fetch available budget from SAP
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* IO Number Input */}
<div className="space-y-3">
<Label htmlFor="ioNumber">IO Number *</Label>
<div className="flex gap-2">
<Input
id="ioNumber"
placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || !!blockedDetails}
className="flex-1"
/>
<Button
onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Download className="w-4 h-4 mr-2" />
{fetchingAmount ? 'Fetching...' : 'Fetch Amount'}
</Button>
</div>
</div>
{/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-green-600 font-medium">Available Budget</p>
<p className="text-2xl font-bold text-green-900">
{fetchedAmount.toLocaleString('en-IN')}
</p>
</div>
<CircleCheckBig className="w-8 h-8 text-green-600" />
</div>
<div className="border-t border-green-200 pt-3 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-green-700">Claim Amount:</span>
<span className="font-semibold text-green-900">
{claimAmount.toLocaleString('en-IN')}
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-green-700">Balance After Block:</span>
<span className="font-semibold text-green-900">
{(fetchedAmount - claimAmount).toLocaleString('en-IN')}
</span>
</div>
</div>
{claimAmount > fetchedAmount ? (
<div className="flex items-center gap-2 bg-red-100 text-red-700 p-3 rounded-md">
<AlertCircle className="w-4 h-4" />
<p className="text-xs font-medium">
Insufficient budget! Claim amount exceeds available balance.
</p>
</div>
) : (
<Button
onClick={handleBlockBudget}
disabled={blockingBudget}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
{blockingBudget ? 'Blocking in SAP...' : 'Block Budget in SAP'}
</Button>
)}
</div>
)}
</CardContent>
</Card>
{/* IO Blocked Details Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CircleCheckBig className="w-5 h-5 text-green-600" />
IO Blocked Details
</CardTitle>
<CardDescription>
Details of IO blocked in SAP system
</CardDescription>
</CardHeader>
<CardContent>
{blockedDetails ? (
<div className="space-y-4">
{/* Success Banner */}
<div className="bg-gradient-to-r from-emerald-50 to-green-50 rounded-lg border-2 border-emerald-300 p-4">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-emerald-500 flex items-center justify-center">
<CircleCheckBig className="w-6 h-6 text-white" />
</div>
<div>
<p className="font-semibold text-emerald-900">Budget Blocked Successfully!</p>
<p className="text-xs text-emerald-700">SAP integration completed</p>
</div>
</div>
</div>
{/* Blocked Details */}
<div className="space-y-3">
<div className="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">IO Number:</span>
<span className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">Blocked Amount:</span>
<span className="text-sm font-semibold text-green-700">
{blockedDetails.blockedAmount.toLocaleString('en-IN')}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">Available Balance:</span>
<span className="text-sm font-semibold text-gray-900">
{blockedDetails.availableBalance.toLocaleString('en-IN')}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">Blocked Date:</span>
<span className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN')}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-sm text-gray-600">SAP Document No:</span>
<span className="text-sm font-mono font-medium text-blue-700">
{blockedDetails.sapDocumentNumber}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-sm text-gray-600">Status:</span>
<span className="text-xs font-semibold px-2 py-1 bg-green-100 text-green-800 rounded-full">
{blockedDetails.status.toUpperCase()}
</span>
</div>
</div>
{/* Release Button */}
<Button
variant="outline"
onClick={handleReleaseBudget}
className="w-full border-red-300 text-red-700 hover:bg-red-50"
>
Release Budget
</Button>
</div>
) : (
<div className="text-center py-12">
<DollarSign className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-sm text-gray-500 mb-2">No IO blocked yet</p>
<p className="text-xs text-gray-400">
Enter IO number and fetch amount to block budget
</p>
</div>
)}
</CardContent>
</Card>
</div>
);
}

72
src/services/dealerApi.ts Normal file
View File

@ -0,0 +1,72 @@
/**
* Dealer API Service
* Handles API calls for dealer-related operations
*/
import apiClient from './authApi';
export interface DealerInfo {
userId: string;
email: string;
dealerCode: string;
dealerName: string;
displayName: string;
phone?: string;
department?: string;
designation?: string;
}
/**
* Get all dealers
*/
export async function getAllDealers(): Promise<DealerInfo[]> {
try {
const res = await apiClient.get('/dealers');
return res.data?.data || res.data || [];
} catch (error) {
console.error('[DealerAPI] Error fetching dealers:', error);
throw error;
}
}
/**
* Get dealer by code
*/
export async function getDealerByCode(dealerCode: string): Promise<DealerInfo | null> {
try {
const res = await apiClient.get(`/dealers/code/${dealerCode}`);
return res.data?.data || res.data || null;
} catch (error) {
console.error('[DealerAPI] Error fetching dealer by code:', error);
return null;
}
}
/**
* Get dealer by email
*/
export async function getDealerByEmail(email: string): Promise<DealerInfo | null> {
try {
const res = await apiClient.get(`/dealers/email/${encodeURIComponent(email)}`);
return res.data?.data || res.data || null;
} catch (error) {
console.error('[DealerAPI] Error fetching dealer by email:', error);
return null;
}
}
/**
* Search dealers
*/
export async function searchDealers(searchTerm: string): Promise<DealerInfo[]> {
try {
const res = await apiClient.get('/dealers/search', {
params: { q: searchTerm },
});
return res.data?.data || res.data || [];
} catch (error) {
console.error('[DealerAPI] Error searching dealers:', error);
return [];
}
}

View File

@ -0,0 +1,245 @@
/**
* Dealer Claim API Service
* Handles API calls for dealer claim management operations
*/
import apiClient from './authApi';
export interface CreateClaimRequestPayload {
activityName: string;
activityType: string;
dealerCode: string;
dealerName: string;
dealerEmail?: string;
dealerPhone?: string;
dealerAddress?: string;
activityDate?: string; // ISO date string
location: string;
requestDescription: string;
periodStartDate?: string; // ISO date string
periodEndDate?: string; // ISO date string
estimatedBudget?: string | number;
}
export interface ClaimRequestResponse {
request: {
requestId: string;
requestNumber: string;
title: string;
description: string;
status: string;
workflowType: string;
// ... other fields
};
message: string;
}
/**
* Create a new dealer claim request
* POST /api/v1/dealer-claims
*/
export async function createClaimRequest(payload: CreateClaimRequestPayload): Promise<ClaimRequestResponse> {
try {
const response = await apiClient.post('/dealer-claims', payload);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error creating claim request:', error);
throw error;
}
}
/**
* Get claim details
* GET /api/v1/dealer-claims/:requestId
*/
export async function getClaimDetails(requestId: string): Promise<any> {
try {
const response = await apiClient.get(`/dealer-claims/${requestId}`);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error fetching claim details:', error);
throw error;
}
}
/**
* Submit dealer proposal (Step 1)
* POST /api/v1/dealer-claims/:requestId/proposal
*/
export async function submitProposal(
requestId: string,
proposalData: {
proposalDocument?: File;
costBreakup?: Array<{ description: string; amount: number }>;
totalEstimatedBudget?: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: string;
expectedCompletionDays?: number;
dealerComments?: string;
}
): Promise<any> {
try {
const formData = new FormData();
if (proposalData.proposalDocument) {
formData.append('proposalDocument', proposalData.proposalDocument);
}
if (proposalData.costBreakup) {
formData.append('costBreakup', JSON.stringify(proposalData.costBreakup));
}
if (proposalData.totalEstimatedBudget !== undefined) {
formData.append('totalEstimatedBudget', proposalData.totalEstimatedBudget.toString());
}
if (proposalData.timelineMode) {
formData.append('timelineMode', proposalData.timelineMode);
}
if (proposalData.expectedCompletionDate) {
formData.append('expectedCompletionDate', proposalData.expectedCompletionDate);
}
if (proposalData.expectedCompletionDays !== undefined) {
formData.append('expectedCompletionDays', proposalData.expectedCompletionDays.toString());
}
if (proposalData.dealerComments) {
formData.append('dealerComments', proposalData.dealerComments);
}
const response = await apiClient.post(`/dealer-claims/${requestId}/proposal`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error submitting proposal:', error);
throw error;
}
}
/**
* Submit completion documents (Step 5)
* POST /api/v1/dealer-claims/:requestId/completion
*/
export async function submitCompletion(
requestId: string,
completionData: {
activityCompletionDate: string; // ISO date string
numberOfParticipants?: number;
closedExpenses?: Array<{ description: string; amount: number }>;
totalClosedExpenses?: number;
completionDocuments?: File[];
activityPhotos?: File[];
}
): Promise<any> {
try {
const formData = new FormData();
formData.append('activityCompletionDate', completionData.activityCompletionDate);
if (completionData.numberOfParticipants !== undefined) {
formData.append('numberOfParticipants', completionData.numberOfParticipants.toString());
}
if (completionData.closedExpenses) {
formData.append('closedExpenses', JSON.stringify(completionData.closedExpenses));
}
if (completionData.totalClosedExpenses !== undefined) {
formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString());
}
if (completionData.completionDocuments) {
completionData.completionDocuments.forEach((file) => {
formData.append('completionDocuments', file);
});
}
if (completionData.activityPhotos) {
completionData.activityPhotos.forEach((file) => {
formData.append('activityPhotos', file);
});
}
const response = await apiClient.post(`/dealer-claims/${requestId}/completion`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error submitting completion:', error);
throw error;
}
}
/**
* Update IO details (Step 3)
* PUT /api/v1/dealer-claims/:requestId/io
*/
export async function updateIODetails(
requestId: string,
ioData: {
ioNumber: string;
ioAvailableBalance: number;
ioBlockedAmount: number;
ioRemainingBalance: number;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/io`, ioData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating IO details:', error);
throw error;
}
}
/**
* Update E-Invoice details (Step 7)
* PUT /api/v1/dealer-claims/:requestId/e-invoice
*/
export async function updateEInvoice(
requestId: string,
eInvoiceData: {
eInvoiceNumber?: string;
eInvoiceDate: string; // ISO date string
dmsNumber?: string;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/e-invoice`, eInvoiceData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating e-invoice:', error);
throw error;
}
}
/**
* Update Credit Note details (Step 8)
* PUT /api/v1/dealer-claims/:requestId/credit-note
*/
export async function updateCreditNote(
requestId: string,
creditNoteData: {
creditNoteNumber?: string;
creditNoteDate: string; // ISO date string
creditNoteAmount: number;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/credit-note`, creditNoteData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating credit note:', error);
throw error;
}
}

View File

@ -275,7 +275,7 @@ export async function listClosedByMe(params: { page?: number; limit?: number; se
};
}
export async function getWorkflowDetails(requestId: string) {
export async function getWorkflowDetails(requestId: string, workflowType?: string) {
const res = await apiClient.get(`/workflows/${requestId}/details`);
return res.data?.data || res.data;
}

View File

@ -0,0 +1,318 @@
/**
* Claim Data Mapper Utilities
* Maps API response data to ClaimManagementRequest structure for frontend components
*/
import { isClaimManagementRequest } from './claimRequestUtils';
/**
* User roles in a claim management request
*/
export type RequestRole = 'INITIATOR' | 'DEALER' | 'DEPARTMENT_LEAD' | 'APPROVER' | 'SPECTATOR';
/**
* Claim Management Request structure for frontend
*/
export interface ClaimManagementRequest {
// Activity Information
activityInfo: {
activityName: string;
activityType: string;
requestedDate?: string;
location: string;
period?: {
startDate: string;
endDate: string;
};
estimatedBudget?: number;
closedExpenses?: number;
closedExpensesBreakdown?: Array<{ description: string; amount: number }>;
description?: string;
};
// Dealer Information
dealerInfo: {
dealerCode: string;
dealerName: string;
email?: string;
phone?: string;
address?: string;
};
// Proposal Details (Step 1)
proposalDetails?: {
proposalDocumentUrl?: string;
costBreakup: Array<{ description: string; amount: number }>;
totalEstimatedBudget: number;
timelineMode?: 'date' | 'days';
expectedCompletionDate?: string;
expectedCompletionDays?: number;
dealerComments?: string;
submittedAt?: string;
};
// IO Details (Step 3)
ioDetails?: {
ioNumber?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
};
// DMS Details (Step 7)
dmsDetails?: {
eInvoiceNumber?: string;
eInvoiceDate?: string;
dmsNumber?: string;
creditNoteNumber?: string;
creditNoteDate?: string;
creditNoteAmount?: number;
};
// Claim Amount
claimAmount: {
estimated: number;
closed: number;
};
}
/**
* Role-based visibility configuration
*/
export interface RoleVisibility {
showDealerInfo: boolean;
showProposalDetails: boolean;
showIODetails: boolean;
showDMSDetails: boolean;
showClaimAmount: boolean;
canEditClaimAmount: boolean;
}
/**
* Map API request data to ClaimManagementRequest structure
*/
export function mapToClaimManagementRequest(
apiRequest: any,
currentUserId: string
): ClaimManagementRequest | null {
try {
if (!isClaimManagementRequest(apiRequest)) {
return null;
}
// Extract claim details from API response
const claimDetails = apiRequest.claimDetails || {};
const proposalDetails = apiRequest.proposalDetails || {};
const completionDetails = apiRequest.completionDetails || {};
// Debug: Log raw claim details to help troubleshoot
console.debug('[claimDataMapper] Raw claimDetails:', claimDetails);
console.debug('[claimDataMapper] Raw apiRequest:', {
hasClaimDetails: !!apiRequest.claimDetails,
hasProposalDetails: !!apiRequest.proposalDetails,
hasCompletionDetails: !!apiRequest.completionDetails,
workflowType: apiRequest.workflowType,
});
// Map activity information (matching ActivityInformationCard expectations)
// Handle both camelCase and snake_case field names from Sequelize
const periodStartDate = claimDetails.periodStartDate || claimDetails.period_start_date;
const periodEndDate = claimDetails.periodEndDate || claimDetails.period_end_date;
const activityName = claimDetails.activityName || claimDetails.activity_name || '';
const activityType = claimDetails.activityType || claimDetails.activity_type || '';
const location = claimDetails.location || '';
console.debug('[claimDataMapper] Mapped activity fields:', {
activityName,
activityType,
location,
hasActivityName: !!activityName,
hasActivityType: !!activityType,
hasLocation: !!location,
});
const activityInfo = {
activityName,
activityType,
requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt
location,
period: (periodStartDate && periodEndDate) ? {
startDate: periodStartDate,
endDate: periodEndDate,
} : undefined,
estimatedBudget: claimDetails.estimatedBudget || claimDetails.estimated_budget,
closedExpenses: claimDetails.closedExpenses || claimDetails.closed_expenses,
closedExpensesBreakdown: completionDetails.closedExpenses ||
completionDetails.closed_expenses ||
completionDetails.closedExpensesBreakdown ||
[],
description: apiRequest.description || '', // Get description from workflow request
};
// Map dealer information (matching DealerInformationCard expectations)
const dealerInfo = {
dealerCode: claimDetails.dealerCode || claimDetails.dealer_code || '',
dealerName: claimDetails.dealerName || claimDetails.dealer_name || '',
email: claimDetails.dealerEmail || claimDetails.dealer_email || '',
phone: claimDetails.dealerPhone || claimDetails.dealer_phone || '',
address: claimDetails.dealerAddress || claimDetails.dealer_address || '',
};
// Map proposal details
const proposal = proposalDetails ? {
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
expectedCompletionDate: proposalDetails.expectedCompletionDate || proposalDetails.expected_completion_date,
expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days,
dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments,
submittedAt: proposalDetails.submittedAt || proposalDetails.submitted_at,
} : undefined;
// Map IO details
const ioDetails = {
ioNumber: claimDetails.ioNumber || claimDetails.io_number,
availableBalance: claimDetails.ioAvailableBalance || claimDetails.io_available_balance,
blockedAmount: claimDetails.ioBlockedAmount || claimDetails.io_blocked_amount,
remainingBalance: claimDetails.ioRemainingBalance || claimDetails.io_remaining_balance,
};
// Map DMS details
const dmsDetails = {
eInvoiceNumber: claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
eInvoiceDate: claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
dmsNumber: claimDetails.dmsNumber || claimDetails.dms_number,
creditNoteNumber: claimDetails.creditNoteNumber || claimDetails.credit_note_number,
creditNoteDate: claimDetails.creditNoteDate || claimDetails.credit_note_date,
creditNoteAmount: claimDetails.creditNoteAmount || claimDetails.credit_note_amount,
};
// Map claim amounts
const claimAmount = {
estimated: activityInfo.estimatedBudget || 0,
closed: activityInfo.closedExpenses || 0,
};
return {
activityInfo,
dealerInfo,
proposalDetails: proposal,
ioDetails: Object.keys(ioDetails).some(k => ioDetails[k as keyof typeof ioDetails]) ? ioDetails : undefined,
dmsDetails: Object.keys(dmsDetails).some(k => dmsDetails[k as keyof typeof dmsDetails]) ? dmsDetails : undefined,
claimAmount,
};
} catch (error) {
console.error('[claimDataMapper] Error mapping claim data:', error);
return null;
}
}
/**
* Determine user's role in the request
*/
export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole {
try {
// Check if user is the initiator
if (apiRequest.initiatorId === currentUserId ||
apiRequest.initiator?.userId === currentUserId ||
apiRequest.requestedBy?.userId === currentUserId) {
return 'INITIATOR';
}
// Check if user is a dealer (participant with DEALER type)
const participants = apiRequest.participants || [];
const dealerParticipant = participants.find((p: any) =>
(p.userId === currentUserId || p.user?.userId === currentUserId) &&
(p.participantType === 'DEALER' || p.type === 'DEALER')
);
if (dealerParticipant) {
return 'DEALER';
}
// Check if user is a department lead (approver at level 3)
const approvalLevels = apiRequest.approvalLevels || [];
const deptLeadLevel = approvalLevels.find((level: any) =>
level.levelNumber === 3 &&
(level.approverId === currentUserId || level.approver?.userId === currentUserId)
);
if (deptLeadLevel) {
return 'DEPARTMENT_LEAD';
}
// Check if user is an approver
const approverLevel = approvalLevels.find((level: any) =>
(level.approverId === currentUserId || level.approver?.userId === currentUserId) &&
level.status === 'PENDING'
);
if (approverLevel) {
return 'APPROVER';
}
// Default to spectator
return 'SPECTATOR';
} catch (error) {
console.error('[claimDataMapper] Error determining user role:', error);
return 'SPECTATOR';
}
}
/**
* Get role-based visibility settings
*/
export function getRoleBasedVisibility(role: RequestRole): RoleVisibility {
switch (role) {
case 'INITIATOR':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false, // Can only edit in specific scenarios
};
case 'DEALER':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: false,
showDMSDetails: false,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'DEPARTMENT_LEAD':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'APPROVER':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'SPECTATOR':
default:
return {
showDealerInfo: false,
showProposalDetails: false,
showIODetails: false,
showDMSDetails: false,
showClaimAmount: false,
canEditClaimAmount: false,
};
}
}

View File

@ -0,0 +1,57 @@
/**
* Utility functions for identifying and handling Claim Management requests
* Works with both old format (templateType: 'claim-management') and new format (workflowType: 'CLAIM_MANAGEMENT')
*/
/**
* Check if a request is a Claim Management request
* Supports both old and new backend formats
*/
export function isClaimManagementRequest(request: any): boolean {
if (!request) return false;
// New format: Check workflowType
if (request.workflowType === 'CLAIM_MANAGEMENT') {
return true;
}
// Old format: Check templateType (for backward compatibility)
if (request.templateType === 'claim-management' || request.template === 'claim-management') {
return true;
}
// Check template name/code
if (request.templateName === 'Claim Management' || request.templateCode === 'CLAIM_MANAGEMENT') {
return true;
}
return false;
}
/**
* Get workflow type from request
* Returns 'CLAIM_MANAGEMENT' for claim requests, 'NON_TEMPLATIZED' for others
*/
export function getWorkflowType(request: any): string {
if (!request) return 'NON_TEMPLATIZED';
// New format
if (request.workflowType) {
return request.workflowType;
}
// Old format: Map templateType to workflowType
if (request.templateType === 'claim-management' || request.template === 'claim-management') {
return 'CLAIM_MANAGEMENT';
}
return 'NON_TEMPLATIZED';
}
/**
* Check if request needs claim-specific UI components
*/
export function shouldUseClaimManagementUI(request: any): boolean {
return isClaimManagementRequest(request);
}