claim management related tabls added dealers seeded untilt real dealers available tdb droped multiple times to make fresh setup
This commit is contained in:
parent
2c0378c63a
commit
0e9f8adbf6
231
docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md
Normal file
231
docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md
Normal 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
|
||||
|
||||
47
src/App.tsx
47
src/App.tsx
@ -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 (
|
||||
|
||||
@ -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}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{dealer.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
{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.dealerCode}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{dealer.dealerName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.dealerCode && (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,24 +473,33 @@ 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">
|
||||
<OverviewTab
|
||||
request={request}
|
||||
isInitiator={isInitiator}
|
||||
needsClosure={needsClosure}
|
||||
conclusionRemark={conclusionRemark}
|
||||
setConclusionRemark={setConclusionRemark}
|
||||
conclusionLoading={conclusionLoading}
|
||||
conclusionSubmitting={conclusionSubmitting}
|
||||
aiGenerated={aiGenerated}
|
||||
handleGenerateConclusion={handleGenerateConclusion}
|
||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRetrigger={handleRetrigger}
|
||||
currentUserIsApprover={!!currentApprovalLevel}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
/>
|
||||
{isClaimManagementRequest(apiRequest) ? (
|
||||
<ClaimManagementOverviewTab
|
||||
request={request}
|
||||
apiRequest={apiRequest}
|
||||
currentUserId={(user as any)?.userId || ''}
|
||||
isInitiator={isInitiator}
|
||||
/>
|
||||
) : (
|
||||
<OverviewTab
|
||||
request={request}
|
||||
isInitiator={isInitiator}
|
||||
needsClosure={needsClosure}
|
||||
conclusionRemark={conclusionRemark}
|
||||
setConclusionRemark={setConclusionRemark}
|
||||
conclusionLoading={conclusionLoading}
|
||||
conclusionSubmitting={conclusionSubmitting}
|
||||
aiGenerated={aiGenerated}
|
||||
handleGenerateConclusion={handleGenerateConclusion}
|
||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRetrigger={handleRetrigger}
|
||||
currentUserIsApprover={!!currentApprovalLevel}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{isClosed && (
|
||||
@ -502,20 +514,37 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
)}
|
||||
|
||||
<TabsContent value="workflow" className="mt-0">
|
||||
<WorkflowTab
|
||||
request={request}
|
||||
user={user}
|
||||
isInitiator={isInitiator}
|
||||
onSkipApprover={(data) => {
|
||||
if (!data.levelId) {
|
||||
alert('Level ID not available');
|
||||
return;
|
||||
}
|
||||
setSkipApproverData(data);
|
||||
setShowSkipApproverModal(true);
|
||||
}}
|
||||
onRefresh={refreshDetails}
|
||||
/>
|
||||
{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}
|
||||
isInitiator={isInitiator}
|
||||
onSkipApprover={(data) => {
|
||||
if (!data.levelId) {
|
||||
alert('Level ID not available');
|
||||
return;
|
||||
}
|
||||
setSkipApproverData(data);
|
||||
setShowSkipApproverModal(true);
|
||||
}}
|
||||
onRefresh={refreshDetails}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="documents" className="mt-0">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
12
src/pages/RequestDetail/components/claim-cards/index.ts
Normal file
12
src/pages/RequestDetail/components/claim-cards/index.ts
Normal 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';
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
435
src/pages/RequestDetail/components/tabs/IOTab.tsx
Normal file
435
src/pages/RequestDetail/components/tabs/IOTab.tsx
Normal 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
72
src/services/dealerApi.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
245
src/services/dealerClaimApi.ts
Normal file
245
src/services/dealerClaimApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
318
src/utils/claimDataMapper.ts
Normal file
318
src/utils/claimDataMapper.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
57
src/utils/claimRequestUtils.ts
Normal file
57
src/utils/claimRequestUtils.ts
Normal 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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user