Merge branch 'dealer_claim' of https://git.tech4biz.wiki/laxmanhalaki/Re_Figma_Code into dealer_claim

This commit is contained in:
laxmanhalaki 2025-12-13 10:44:28 +05:30
commit ac9d4aefe4
31 changed files with 7413 additions and 69 deletions

View File

@ -0,0 +1,231 @@
# Frontend Updates for Dealer Claim Management
## Overview
This document outlines the frontend changes needed to support the new backend structure with `workflowType` field for dealer claim management.
## ✅ Completed
1. **Created utility function** (`src/utils/claimRequestUtils.ts`)
- `isClaimManagementRequest()` - Checks if request is claim management (supports both old and new formats)
- `getWorkflowType()` - Gets workflow type from request
- `shouldUseClaimManagementUI()` - Determines if claim-specific UI should be used
## 🔧 Required Updates
### 1. Update RequestDetail Component
**File**: `src/pages/RequestDetail/RequestDetail.tsx`
**Changes Needed**:
- Import `isClaimManagementRequest` from `@/utils/claimRequestUtils`
- Conditionally render `ClaimManagementOverviewTab` instead of `OverviewTab` when it's a claim management request
- Conditionally render `DealerClaimWorkflowTab` instead of `WorkflowTab` when it's a claim management request
**Example**:
```typescript
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab';
import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab';
// In the component:
const isClaimManagement = isClaimManagementRequest(apiRequest);
<TabsContent value="overview">
{isClaimManagement ? (
<ClaimManagementOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={(user as any)?.userId}
isInitiator={isInitiator}
/>
) : (
<OverviewTab {...overviewProps} />
)}
</TabsContent>
<TabsContent value="workflow">
{isClaimManagement ? (
<DealerClaimWorkflowTab
request={request}
user={user}
isInitiator={isInitiator}
onRefresh={refreshDetails}
/>
) : (
<WorkflowTab {...workflowProps} />
)}
</TabsContent>
```
### 2. Create Missing Utility Functions
**File**: `src/pages/RequestDetail/utils/claimDataMapper.ts` (NEW FILE)
**Functions Needed**:
- `mapToClaimManagementRequest(apiRequest, userId)` - Maps backend API response to claim management structure
- `determineUserRole(apiRequest, userId)` - Determines user's role (INITIATOR, DEALER, APPROVER, SPECTATOR)
- Helper functions to extract claim-specific data from API response
**Structure**:
```typescript
export interface ClaimManagementRequest {
activityInfo: {
activityName: string;
activityType: string;
activityDate: string;
location: string;
periodStart?: string;
periodEnd?: string;
estimatedBudget?: number;
closedExpensesBreakdown?: any[];
};
dealerInfo: {
dealerCode: string;
dealerName: string;
dealerEmail?: string;
dealerPhone?: string;
dealerAddress?: string;
};
proposalDetails?: {
costBreakup: any[];
totalEstimatedBudget: number;
timeline: string;
dealerComments?: string;
};
ioDetails?: {
ioNumber?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
};
dmsDetails?: {
dmsNumber?: string;
eInvoiceNumber?: string;
creditNoteNumber?: string;
};
claimAmount?: number;
}
export function mapToClaimManagementRequest(
apiRequest: any,
userId: string
): ClaimManagementRequest | null {
// Extract data from apiRequest.claimDetails (from backend)
// Map to ClaimManagementRequest structure
}
export function determineUserRole(
apiRequest: any,
userId: string
): 'INITIATOR' | 'DEALER' | 'APPROVER' | 'SPECTATOR' {
// Check if user is initiator
// Check if user is dealer (from participants or claimDetails)
// Check if user is approver (from approval levels)
// Check if user is spectator (from participants)
}
```
### 3. Update ClaimManagementOverviewTab
**File**: `src/pages/RequestDetail/components/tabs/ClaimManagementOverviewTab.tsx`
**Changes Needed**:
- Import the new utility functions from `claimDataMapper.ts`
- Remove TODO comments
- Ensure it properly handles both old and new API response formats
### 4. Update API Service
**File**: `src/services/workflowApi.ts`
**Changes Needed**:
- Add `workflowType` field to `CreateWorkflowFromFormPayload` interface
- Include `workflowType: 'CLAIM_MANAGEMENT'` when creating claim management requests
- Update response types to include `workflowType` field
**Example**:
```typescript
export interface CreateWorkflowFromFormPayload {
templateId?: string | null;
templateType: 'CUSTOM' | 'TEMPLATE';
workflowType?: string; // NEW: 'CLAIM_MANAGEMENT' | 'NON_TEMPLATIZED' | etc.
title: string;
// ... rest of fields
}
```
### 5. Update ClaimManagementWizard
**File**: `src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx`
**Changes Needed**:
- When submitting, include `workflowType: 'CLAIM_MANAGEMENT'` in the payload
- Update to call the new backend API endpoint for creating claim requests
### 6. Update Request List Components
**Files**:
- `src/pages/MyRequests/components/RequestCard.tsx`
- `src/pages/Requests/components/RequestCard.tsx`
- `src/pages/OpenRequests/components/RequestCard.tsx`
**Changes Needed**:
- Display template/workflow type badge based on `workflowType` field
- Support both old (`templateType`) and new (`workflowType`) formats
**Example**:
```typescript
const workflowType = request.workflowType ||
(request.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED');
{workflowType === 'CLAIM_MANAGEMENT' && (
<Badge variant="secondary">Claim Management</Badge>
)}
```
### 7. Create API Service for Claim Management
**File**: `src/services/dealerClaimApi.ts` (NEW FILE)
**Endpoints Needed**:
- `createClaimRequest(data)` - POST `/api/v1/dealer-claims`
- `getClaimDetails(requestId)` - GET `/api/v1/dealer-claims/:requestId`
- `submitProposal(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/proposal`
- `submitCompletion(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/completion`
- `updateIODetails(requestId, data)` - PUT `/api/v1/dealer-claims/:requestId/io`
### 8. Update Type Definitions
**File**: `src/types/index.ts`
**Changes Needed**:
- Add `workflowType?: string` to request interfaces
- Update `ClaimFormData` interface to match backend structure
## 📋 Implementation Order
1. ✅ Create `claimRequestUtils.ts` (DONE)
2. ⏳ Create `claimDataMapper.ts` with mapping functions
3. ⏳ Update `RequestDetail.tsx` to conditionally render claim components
4. ⏳ Update `workflowApi.ts` to include `workflowType`
5. ⏳ Update `ClaimManagementWizard.tsx` to send `workflowType`
6. ⏳ Create `dealerClaimApi.ts` for claim-specific endpoints
7. ⏳ Update request card components to show workflow type
8. ⏳ Test end-to-end flow
## 🔄 Backward Compatibility
The frontend should support both:
- **Old Format**: `templateType: 'claim-management'`
- **New Format**: `workflowType: 'CLAIM_MANAGEMENT'`
The `isClaimManagementRequest()` utility function handles both formats automatically.
## 📝 Notes
- All existing claim management UI components are already created
- The main work is connecting them to the new backend API structure
- The `workflowType` field allows the system to support multiple template types in the future
- All requests (claim management, non-templatized, future templates) will appear in "My Requests" and "Open Requests" automatically

View File

@ -24,15 +24,9 @@ import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback'; import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
// Combined Request Database for backward compatibility import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
// This combines both custom and claim management requests
export const REQUEST_DATABASE: any = {
...CUSTOM_REQUEST_DATABASE,
...CLAIM_MANAGEMENT_DATABASE
};
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -61,6 +55,20 @@ function AppRoutes({ onLogout }: AppProps) {
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]); const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
const [selectedRequestId, setSelectedRequestId] = useState<string>(''); const [selectedRequestId, setSelectedRequestId] = useState<string>('');
const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>(''); const [selectedRequestTitle, setSelectedRequestTitle] = useState<string>('');
const [managerModalOpen, setManagerModalOpen] = useState(false);
const [managerModalData, setManagerModalData] = useState<{
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
managers?: Array<{
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}>;
message?: string;
pendingClaimData?: any;
} | null>(null);
// Retrieve dynamic requests from localStorage on mount // Retrieve dynamic requests from localStorage on mount
useEffect(() => { useEffect(() => {
@ -265,7 +273,85 @@ function AppRoutes({ onLogout }: AppProps) {
setApprovalAction(null); setApprovalAction(null);
}; };
const handleClaimManagementSubmit = (claimData: any) => { const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
try {
// Prepare payload for API
const payload = {
activityName: claimData.activityName,
activityType: claimData.activityType,
dealerCode: claimData.dealerCode,
dealerName: claimData.dealerName,
dealerEmail: claimData.dealerEmail || undefined,
dealerPhone: claimData.dealerPhone || undefined,
dealerAddress: claimData.dealerAddress || undefined,
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined,
location: claimData.location,
requestDescription: claimData.requestDescription,
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
estimatedBudget: claimData.estimatedBudget || undefined,
selectedManagerEmail: selectedManagerEmail || undefined,
};
// Call API to create claim request
const response = await createClaimRequest(payload);
const createdRequest = response.request;
// Close manager modal if open
setManagerModalOpen(false);
setManagerModalData(null);
toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.',
});
// Navigate to the created request detail page
if (createdRequest?.requestId) {
navigate(`/request/${createdRequest.requestId}`);
} else {
navigate('/my-requests');
}
} catch (error: any) {
console.error('[App] Error creating claim request:', error);
// Check for manager-related errors
const errorData = error?.response?.data;
const errorCode = errorData?.code || errorData?.error?.code;
if (errorCode === 'NO_MANAGER_FOUND') {
// Show modal for no manager found
setManagerModalData({
errorType: 'NO_MANAGER_FOUND',
message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
pendingClaimData: claimData,
});
setManagerModalOpen(true);
return;
}
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
// Show modal with manager list for selection
const managers = errorData?.managers || errorData?.error?.managers || [];
setManagerModalData({
errorType: 'MULTIPLE_MANAGERS_FOUND',
managers: managers,
message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.',
pendingClaimData: claimData,
});
setManagerModalOpen(true);
return;
}
// Other errors - show toast
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
toast.error('Failed to Submit Claim Request', {
description: errorMessage,
});
}
// Keep the old code below for backward compatibility (local storage fallback)
// This can be removed once API integration is fully tested
/*
// Generate unique ID for the new claim request // Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`; const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
@ -457,6 +543,7 @@ function AppRoutes({ onLogout }: AppProps) {
description: 'Your claim management request has been created successfully.', description: 'Your claim management request has been created successfully.',
}); });
navigate('/my-requests'); navigate('/my-requests');
*/
}; };
return ( return (
@ -674,6 +761,27 @@ function AppRoutes({ onLogout }: AppProps) {
}} }}
/> />
{/* Manager Selection Modal */}
<ManagerSelectionModal
open={managerModalOpen}
onClose={() => {
setManagerModalOpen(false);
setManagerModalData(null);
}}
onSelect={async (managerEmail: string) => {
if (managerModalData?.pendingClaimData) {
// Retry creating claim request with selected manager
// The pendingClaimData contains all the form data from the wizard
// This preserves the entire submission state while waiting for manager selection
await handleClaimManagementSubmit(managerModalData.pendingClaimData, managerEmail);
}
}}
managers={managerModalData?.managers}
errorType={managerModalData?.errorType || 'NO_MANAGER_FOUND'}
message={managerModalData?.message}
isLoading={false} // Will be set to true during retry if needed
/>
{/* Approval Action Modal */} {/* Approval Action Modal */}
{approvalAction && ( {approvalAction && (
<ApprovalActionModal <ApprovalActionModal

View File

@ -0,0 +1,164 @@
/**
* Manager Selection Modal
* Shows when multiple managers are found or no manager is found
* Allows user to select a manager from the list
*/
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle, CheckCircle2, User, Mail, Building2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
interface Manager {
userId: string;
email: string;
displayName: string;
firstName?: string;
lastName?: string;
department?: string;
}
interface ManagerSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (managerEmail: string) => void;
managers?: Manager[];
errorType: 'NO_MANAGER_FOUND' | 'MULTIPLE_MANAGERS_FOUND';
message?: string;
isLoading?: boolean;
}
export function ManagerSelectionModal({
open,
onClose,
onSelect,
managers = [],
errorType,
message,
isLoading = false,
}: ManagerSelectionModalProps) {
const handleSelect = (managerEmail: string) => {
onSelect(managerEmail);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{errorType === 'NO_MANAGER_FOUND' ? (
<>
<AlertCircle className="w-5 h-5 text-amber-500" />
Manager Not Found
</>
) : (
<>
<CheckCircle2 className="w-5 h-5 text-blue-500" />
Select Your Manager
</>
)}
</DialogTitle>
<DialogDescription>
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'No reporting manager found in the system. Please ensure your manager is correctly configured in your profile.'}
</p>
<p className="text-sm text-gray-500 mt-2">
Please contact your administrator to update your manager information, or try again later.
</p>
</div>
) : (
<div className="mt-2">
<p className="text-sm text-gray-600">
{message || 'Multiple managers were found with the same name. Please select the correct manager from the list below.'}
</p>
</div>
)}
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{errorType === 'NO_MANAGER_FOUND' ? (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900">
Unable to Proceed
</p>
<p className="text-sm text-amber-700 mt-1">
We couldn't find your reporting manager in the system. The claim request cannot be created without a valid manager assigned.
</p>
</div>
</div>
</div>
) : (
<div className="space-y-3 max-h-[400px] overflow-y-auto">
{managers.map((manager) => (
<div
key={manager.userId}
className="border rounded-lg p-4 hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => !isLoading && handleSelect(manager.email)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-600" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<p className="font-medium text-gray-900">
{manager.displayName || `${manager.firstName || ''} ${manager.lastName || ''}`.trim() || 'Unknown'}
</p>
</div>
<div className="space-y-1">
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span className="truncate">{manager.email}</span>
</div>
{manager.department && (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Building2 className="w-4 h-4" />
<span>{manager.department}</span>
</div>
)}
</div>
</div>
</div>
<Button
onClick={(e) => {
e.stopPropagation();
handleSelect(manager.email);
}}
disabled={isLoading}
className="flex-shrink-0"
size="sm"
>
Select
</Button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 mt-6">
{errorType === 'NO_MANAGER_FOUND' ? (
<Button onClick={onClose} variant="outline">
Close
</Button>
) : (
<>
<Button onClick={onClose} variant="outline" disabled={isLoading}>
Cancel
</Button>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -25,7 +25,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase'; import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
interface ClaimManagementWizardProps { interface ClaimManagementWizardProps {
onBack?: () => void; onBack?: () => void;
@ -41,9 +41,6 @@ const CLAIM_TYPES = [
'Service Campaign' 'Service Campaign'
]; ];
// Fetch dealers from database
const DEALERS = getAllDealers();
const STEP_NAMES = [ const STEP_NAMES = [
'Claim Details', 'Claim Details',
'Review & Submit' 'Review & Submit'
@ -51,6 +48,8 @@ const STEP_NAMES = [
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) { export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [dealers, setDealers] = useState<DealerInfo[]>([]);
const [loadingDealers, setLoadingDealers] = useState(true);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
activityName: '', activityName: '',
@ -70,6 +69,23 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const totalSteps = STEP_NAMES.length; 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) => { const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => ({ ...prev, [field]: value }));
}; };
@ -103,14 +119,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
} }
}; };
const handleDealerChange = (dealerCode: string) => { const handleDealerChange = async (dealerCode: string) => {
const dealer = getDealerInfo(dealerCode); const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
if (dealer) { if (selectedDealer) {
updateFormData('dealerCode', dealer.code); updateFormData('dealerCode', dealerCode);
updateFormData('dealerName', dealer.name); updateFormData('dealerName', selectedDealer.dealerName);
updateFormData('dealerEmail', dealer.email); updateFormData('dealerEmail', selectedDealer.email || '');
updateFormData('dealerPhone', dealer.phone); updateFormData('dealerPhone', selectedDealer.phone || '');
updateFormData('dealerAddress', formatDealerAddress(dealer)); 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 */} {/* Dealer Selection */}
<div> <div>
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label> <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"> <SelectTrigger className="mt-2 h-12">
<SelectValue placeholder="Select dealer"> <SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
{formData.dealerCode && ( {formData.dealerCode && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono text-sm">{formData.dealerCode}</span> <span className="font-mono text-sm">{formData.dealerCode}</span>
@ -248,15 +276,21 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{DEALERS.map((dealer) => ( {dealers.length === 0 && !loadingDealers ? (
<SelectItem key={dealer.code} value={dealer.code}> <div className="p-2 text-sm text-gray-500">No dealers available</div>
<div className="flex items-center gap-2"> ) : (
<span className="font-mono text-sm font-semibold">{dealer.code}</span> dealers
<span className="text-gray-400"></span> .filter((dealer) => dealer.dealerCode && dealer.dealerCode.trim() !== '')
<span>{dealer.name}</span> .map((dealer) => (
</div> <SelectItem key={dealer.dealerCode} value={dealer.dealerCode}>
</SelectItem> <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> </SelectContent>
</Select> </Select>
{formData.dealerCode && ( {formData.dealerCode && (

View File

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useCallback } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import workflowApi, { getPauseDetails } from '@/services/workflowApi'; import workflowApi, { getPauseDetails } from '@/services/workflowApi';
import apiClient from '@/services/authApi';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase'; import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { getSocket } from '@/utils/socket'; import { getSocket } from '@/utils/socket';
@ -229,6 +230,87 @@ export function useRequestDetails(
console.debug('Pause details not available:', error); console.debug('Pause details not available:', error);
} }
/**
* Fetch: Get claim details if this is a claim management request
*/
let claimDetails = null;
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
console.debug('[useRequestDetails] Fetching claim details for requestId:', wf.requestId);
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
console.debug('[useRequestDetails] Claim API response:', {
status: claimResponse.status,
hasData: !!claimResponse.data,
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
fullResponse: claimResponse.data,
});
const claimData = claimResponse.data?.data || claimResponse.data;
console.debug('[useRequestDetails] Extracted claimData:', {
hasClaimData: !!claimData,
claimDataKeys: claimData ? Object.keys(claimData) : [],
hasClaimDetails: !!(claimData?.claimDetails || claimData?.claim_details),
hasProposalDetails: !!(claimData?.proposalDetails || claimData?.proposal_details),
hasCompletionDetails: !!(claimData?.completionDetails || claimData?.completion_details),
hasInternalOrder: !!(claimData?.internalOrder || claimData?.internal_order),
});
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null;
// New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking;
(claimDetails as any).invoice = invoice;
(claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses;
}
console.debug('[useRequestDetails] Extracted details:', {
claimDetails: claimDetails ? {
hasActivityName: !!(claimDetails.activityName || claimDetails.activity_name),
hasActivityType: !!(claimDetails.activityType || claimDetails.activity_type),
hasLocation: !!(claimDetails.location),
activityName: claimDetails.activityName || claimDetails.activity_name,
activityType: claimDetails.activityType || claimDetails.activity_type,
location: claimDetails.location,
allKeys: Object.keys(claimDetails),
} : null,
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
hasInternalOrder: !!internalOrder,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
});
} else {
console.warn('[useRequestDetails] No claimData found in response');
}
} catch (error: any) {
// Claim details not available - request might not be fully initialized yet
console.error('[useRequestDetails] Error fetching claim details:', {
error: error?.message || error,
status: error?.response?.status,
statusText: error?.response?.statusText,
responseData: error?.response?.data,
requestId: wf.requestId,
});
}
}
/** /**
* Build: Complete request object with all transformed data * Build: Complete request object with all transformed data
* This object is used throughout the UI * This object is used throughout the UI
@ -242,6 +324,7 @@ export function useRequestDetails(
description: wf.description, description: wf.description,
status: statusMap(wf.status), status: statusMap(wf.status),
priority: (wf.priority || '').toString().toLowerCase(), priority: (wf.priority || '').toString().toLowerCase(),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
approvalFlow, approvalFlow,
approvals, // Raw approvals for SLA calculations approvals, // Raw approvals for SLA calculations
participants, participants,
@ -266,6 +349,16 @@ export function useRequestDetails(
conclusionRemark: wf.conclusionRemark || null, conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null, closureDate: wf.closureDate || null,
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
// Claim management specific data
claimDetails: claimDetails || null,
proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null,
internalOrder: internalOrder || null,
// New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
@ -441,6 +534,49 @@ export function useRequestDetails(
console.debug('Pause details not available:', error); console.debug('Pause details not available:', error);
} }
/**
* Fetch: Get claim details if this is a claim management request
*/
let claimDetails = null;
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
console.debug('[useRequestDetails] Initial load - Fetching claim details for requestId:', wf.requestId);
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
console.debug('[useRequestDetails] Initial load - Claim API response:', {
status: claimResponse.status,
hasData: !!claimResponse.data,
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
});
const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null;
console.debug('[useRequestDetails] Initial load - Extracted details:', {
hasClaimDetails: !!claimDetails,
claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [],
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
hasInternalOrder: !!internalOrder,
});
}
} catch (error: any) {
// Claim details not available - request might not be fully initialized yet
console.error('[useRequestDetails] Initial load - Error fetching claim details:', {
error: error?.message || error,
status: error?.response?.status,
requestId: wf.requestId,
});
}
}
// Build complete request object // Build complete request object
const mapped = { const mapped = {
id: wf.requestNumber || wf.requestId, id: wf.requestNumber || wf.requestId,
@ -449,6 +585,7 @@ export function useRequestDetails(
description: wf.description, description: wf.description,
priority, priority,
status: statusMap(wf.status), status: statusMap(wf.status),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
summary, summary,
initiator: { initiator: {
name: wf.initiator?.displayName || wf.initiator?.email, name: wf.initiator?.displayName || wf.initiator?.email,
@ -472,6 +609,11 @@ export function useRequestDetails(
conclusionRemark: wf.conclusionRemark || null, conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null, closureDate: wf.closureDate || null,
pauseInfo: pauseInfo || null, pauseInfo: pauseInfo || null,
// Claim management specific data
claimDetails: claimDetails || null,
proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null,
internalOrder: internalOrder || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);

View File

@ -26,6 +26,7 @@ import {
ShieldX, ShieldX,
RefreshCw, RefreshCw,
ArrowLeft, ArrowLeft,
DollarSign,
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -44,11 +45,15 @@ import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi'; import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { OverviewTab } from './components/tabs/OverviewTab'; import { OverviewTab } from './components/tabs/OverviewTab';
import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab';
import { WorkflowTab } from './components/tabs/WorkflowTab'; import { WorkflowTab } from './components/tabs/WorkflowTab';
import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab';
import { DocumentsTab } from './components/tabs/DocumentsTab'; import { DocumentsTab } from './components/tabs/DocumentsTab';
import { ActivityTab } from './components/tabs/ActivityTab'; import { ActivityTab } from './components/tabs/ActivityTab';
import { WorkNotesTab } from './components/tabs/WorkNotesTab'; import { WorkNotesTab } from './components/tabs/WorkNotesTab';
import { SummaryTab } from './components/tabs/SummaryTab'; import { SummaryTab } from './components/tabs/SummaryTab';
import { IOTab } from './components/tabs/IOTab';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import { QuickActionsSidebar } from './components/QuickActionsSidebar'; import { QuickActionsSidebar } from './components/QuickActionsSidebar';
import { RequestDetailModals } from './components/RequestDetailModals'; import { RequestDetailModals } from './components/RequestDetailModals';
import { RequestDetailProps } from './types/requestDetail.types'; import { RequestDetailProps } from './types/requestDetail.types';
@ -130,6 +135,74 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
accessDenied, accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user); } = useRequestDetails(requestIdentifier, dynamicRequests, user);
// Determine if user is initiator (from overview tab initiator info)
const currentUserId = (user as any)?.userId || '';
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
const initiatorUserId = apiRequest?.initiator?.userId;
const initiatorEmail = apiRequest?.initiator?.email?.toLowerCase();
const isUserInitiator = apiRequest?.initiator && (
(initiatorUserId && initiatorUserId === currentUserId) ||
(initiatorEmail && initiatorEmail === currentUserEmail)
);
// Determine if user is department lead (whoever is in step 3 / approval level 3)
const approvalLevels = apiRequest?.approvalLevels || [];
const step3Level = approvalLevels.find((level: any) =>
(level.levelNumber || level.level_number) === 3
);
const deptLeadUserId = step3Level?.approverId || step3Level?.approver?.userId;
const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver?.email || step3Level?.approverEmail || '').toLowerCase().trim();
// Check if user is department lead by userId or email (case-insensitive)
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
// Get step 3 status (case-insensitive check)
const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : '';
const isStep3PendingOrInProgress = step3Status === 'PENDING' ||
step3Status === 'IN_PROGRESS';
// Check if user is current approver for step 3 (can access IO tab when step is pending/in-progress)
// Also check if currentLevel is 3 (workflow is at step 3)
const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || 0;
const isStep3CurrentLevel = currentLevel === 3;
const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && (
(deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail)
);
// Check if IO tab should be visible (for initiator and department lead in claim management requests)
// Department lead can access IO tab when they are the current approver for step 3 (to fetch and block IO)
const showIOTab = isClaimManagementRequest(apiRequest) &&
(isUserInitiator || isDeptLead || isStep3CurrentApprover);
// Debug logging for troubleshooting
console.debug('[RequestDetail] IO Tab visibility:', {
isClaimManagement: isClaimManagementRequest(apiRequest),
isUserInitiator,
isDeptLead,
isStep3CurrentApprover,
currentUserId,
currentUserEmail,
initiatorUserId,
initiatorEmail,
currentLevel,
isStep3CurrentLevel,
step3Level: step3Level ? {
levelNumber: step3Level.levelNumber || step3Level.level_number,
approverId: step3Level.approverId || step3Level.approver?.userId,
approverEmail: step3Level.approverEmail || step3Level.approver?.email,
status: step3Level.status,
statusUpper: step3Status,
isPendingOrInProgress: isStep3PendingOrInProgress
} : null,
deptLeadUserId,
deptLeadEmail,
emailMatch: deptLeadEmail && currentUserEmail ? deptLeadEmail === currentUserEmail : false,
showIOTab,
});
const { const {
mergedMessages, mergedMessages,
unreadWorkNotes, unreadWorkNotes,
@ -430,6 +503,16 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" /> <TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">Workflow</span> <span className="truncate">Workflow</span>
</TabsTrigger> </TabsTrigger>
{showIOTab && (
<TabsTrigger
value="io"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
data-testid="tab-io"
>
<DollarSign className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">IO</span>
</TabsTrigger>
)}
<TabsTrigger <TabsTrigger
value="documents" value="documents"
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900" className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
@ -470,24 +553,33 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
{/* Left Column: Tab content */} {/* Left Column: Tab content */}
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}> <div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content"> <TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
<OverviewTab {isClaimManagementRequest(apiRequest) ? (
request={request} <ClaimManagementOverviewTab
isInitiator={isInitiator} request={request}
needsClosure={needsClosure} apiRequest={apiRequest}
conclusionRemark={conclusionRemark} currentUserId={(user as any)?.userId || ''}
setConclusionRemark={setConclusionRemark} isInitiator={isInitiator}
conclusionLoading={conclusionLoading} />
conclusionSubmitting={conclusionSubmitting} ) : (
aiGenerated={aiGenerated} <OverviewTab
handleGenerateConclusion={handleGenerateConclusion} request={request}
handleFinalizeConclusion={handleFinalizeConclusion} isInitiator={isInitiator}
onPause={handlePause} needsClosure={needsClosure}
onResume={handleResume} conclusionRemark={conclusionRemark}
onRetrigger={handleRetrigger} setConclusionRemark={setConclusionRemark}
currentUserIsApprover={!!currentApprovalLevel} conclusionLoading={conclusionLoading}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId} conclusionSubmitting={conclusionSubmitting}
currentUserId={(user as any)?.userId} 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> </TabsContent>
{isClosed && ( {isClosed && (
@ -502,22 +594,49 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
)} )}
<TabsContent value="workflow" className="mt-0"> <TabsContent value="workflow" className="mt-0">
<WorkflowTab {isClaimManagementRequest(apiRequest) ? (
request={request} <DealerClaimWorkflowTab
user={user} request={request}
isInitiator={isInitiator} user={user}
onSkipApprover={(data) => { isInitiator={isInitiator}
if (!data.levelId) { onSkipApprover={(data) => {
alert('Level ID not available'); if (!data.levelId) {
return; alert('Level ID not available');
} return;
setSkipApproverData(data); }
setShowSkipApproverModal(true); setSkipApproverData(data);
}} setShowSkipApproverModal(true);
onRefresh={refreshDetails} }}
/> 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>
{showIOTab && (
<TabsContent value="io" className="mt-0">
<IOTab
request={request}
apiRequest={apiRequest}
onRefresh={refreshDetails}
/>
</TabsContent>
)}
<TabsContent value="documents" className="mt-0"> <TabsContent value="documents" className="mt-0">
<DocumentsTab <DocumentsTab
request={request} request={request}
@ -566,6 +685,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshTrigger={sharedRecipientsRefreshTrigger} refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId} pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId} currentUserId={(user as any)?.userId}
apiRequest={apiRequest}
/> />
)} )}
</div> </div>

View File

@ -2,7 +2,7 @@
* Quick Actions Sidebar Component * Quick Actions Sidebar Component
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
@ -10,6 +10,9 @@ import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle }
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi'; import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import notificationApi, { type Notification } from '@/services/notificationApi'; import notificationApi, { type Notification } from '@/services/notificationApi';
import { ProcessDetailsCard } from './claim-cards';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import { determineUserRole, getRoleBasedVisibility, mapToClaimManagementRequest } from '@/utils/claimDataMapper';
interface QuickActionsSidebarProps { interface QuickActionsSidebarProps {
request: any; request: any;
@ -27,6 +30,8 @@ interface QuickActionsSidebarProps {
refreshTrigger?: number; // Trigger to refresh shared recipients list refreshTrigger?: number; // Trigger to refresh shared recipients list
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility) pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
currentUserId?: string; // Current user's ID (kept for backwards compatibility) currentUserId?: string; // Current user's ID (kept for backwards compatibility)
apiRequest?: any;
onEditClaimAmount?: () => void;
} }
export function QuickActionsSidebar({ export function QuickActionsSidebar({
@ -43,6 +48,10 @@ export function QuickActionsSidebar({
onRetrigger, onRetrigger,
summaryId, summaryId,
refreshTrigger, refreshTrigger,
pausedByUserId: pausedByUserIdProp,
currentUserId: currentUserIdProp,
apiRequest,
onEditClaimAmount,
}: QuickActionsSidebarProps) { }: QuickActionsSidebarProps) {
const { user } = useAuth(); const { user } = useAuth();
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]); const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
@ -50,8 +59,8 @@ export function QuickActionsSidebar({
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false); const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
const isClosed = request?.status === 'closed'; const isClosed = request?.status === 'closed';
const isPaused = request?.pauseInfo?.isPaused || false; const isPaused = request?.pauseInfo?.isPaused || false;
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId; const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
const currentUserId = (user as any)?.userId || ''; const currentUserId = currentUserIdProp || (user as any)?.userId || '';
// Both approver AND initiator can pause (when not already paused and not closed) // Both approver AND initiator can pause (when not already paused and not closed)
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator); const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
@ -117,6 +126,16 @@ export function QuickActionsSidebar({
fetchSharedRecipients(); fetchSharedRecipients();
}, [isClosed, summaryId, isInitiator, refreshTrigger]); }, [isClosed, summaryId, isInitiator, refreshTrigger]);
// Claim details for sidebar (only for claim management requests)
const claimSidebarData = useMemo(() => {
if (!apiRequest || !isClaimManagementRequest(apiRequest)) return null;
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
if (!claimRequest) return null;
const userRole = determineUserRole(apiRequest, currentUserId);
const visibility = getRoleBasedVisibility(userRole);
return { claimRequest, visibility };
}, [apiRequest, currentUserId]);
return ( return (
<div className="space-y-4 sm:space-y-6"> <div className="space-y-4 sm:space-y-6">
{/* Quick Actions Card - Hide entire card for spectators and closed requests */} {/* Quick Actions Card - Hide entire card for spectators and closed requests */}
@ -339,6 +358,21 @@ export function QuickActionsSidebar({
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Process details anchored at the bottom of the action sidebar for claim workflows */}
{claimSidebarData && (
<ProcessDetailsCard
ioDetails={claimSidebarData.claimRequest.ioDetails}
dmsDetails={claimSidebarData.claimRequest.dmsDetails}
claimAmount={{
amount: claimSidebarData.claimRequest.claimAmount.closed || claimSidebarData.claimRequest.claimAmount.estimated || 0,
}}
estimatedBudgetBreakdown={claimSidebarData.claimRequest.proposalDetails?.costBreakup}
closedExpensesBreakdown={claimSidebarData.claimRequest.activityInfo?.closedExpensesBreakdown}
visibility={claimSidebarData.visibility}
onEditClaimAmount={onEditClaimAmount}
/>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,177 @@
/**
* ActivityInformationCard Component
* Displays activity details for Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Calendar, MapPin, DollarSign, Receipt } from 'lucide-react';
import { ClaimActivityInfo } from '../../types/claimManagement.types';
import { format } from 'date-fns';
interface ActivityInformationCardProps {
activityInfo: ClaimActivityInfo;
className?: string;
}
export function ActivityInformationCard({ activityInfo, className }: ActivityInformationCardProps) {
// Defensive check: Ensure activityInfo exists
if (!activityInfo) {
console.warn('[ActivityInformationCard] activityInfo is missing');
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Activity information not available</p>
</CardContent>
</Card>
);
}
const formatCurrency = (amount: string | number) => {
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
if (isNaN(numAmount)) return 'N/A';
return `${numAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return dateString;
}
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Calendar className="w-5 h-5 text-blue-600" />
Activity Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
{/* Activity Name */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Activity Name
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{activityInfo.activityName}
</p>
</div>
{/* Activity Type */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Activity Type
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{activityInfo.activityType}
</p>
</div>
{/* Location */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Location
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
{activityInfo.location}
</p>
</div>
{/* Requested Date */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Requested Date
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{formatDate(activityInfo.requestedDate)}
</p>
</div>
{/* Estimated Budget */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Estimated Budget
</label>
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
<DollarSign className="w-4 h-4 text-green-600" />
{activityInfo.estimatedBudget
? formatCurrency(activityInfo.estimatedBudget)
: 'TBD'}
</p>
</div>
{/* Closed Expenses - Show if value exists (including 0) */}
{activityInfo.closedExpenses !== undefined && activityInfo.closedExpenses !== null && (
<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: { description: string; amount: number }, index: number) => (
<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: number, item: { description: string; amount: number }) => sum + item.amount, 0)
)}
</span>
</div>
</div>
</div>
)}
{/* Description */}
{activityInfo.description && (
<div className="pt-4 border-t">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Description
</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{activityInfo.description}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,100 @@
/**
* DealerInformationCard Component
* Displays dealer details for Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Building, Mail, Phone, MapPin } from 'lucide-react';
import { DealerInfo } from '../../types/claimManagement.types';
interface DealerInformationCardProps {
dealerInfo: DealerInfo;
className?: string;
}
export function DealerInformationCard({ dealerInfo, className }: DealerInformationCardProps) {
// Defensive check: Ensure dealerInfo exists
if (!dealerInfo) {
console.warn('[DealerInformationCard] dealerInfo is missing');
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Dealer information not available</p>
</CardContent>
</Card>
);
}
// Check if essential fields are present
if (!dealerInfo.dealerCode && !dealerInfo.dealerName) {
console.warn('[DealerInformationCard] Dealer info missing essential fields:', dealerInfo);
return (
<Card className={className}>
<CardContent className="py-8 text-center text-gray-500">
<p>Dealer information incomplete</p>
</CardContent>
</Card>
);
}
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="w-5 h-5 text-purple-600" />
Dealer Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Dealer Code and Name */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Code
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{dealerInfo.dealerCode}
</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Name
</label>
<p className="text-sm text-gray-900 font-medium mt-1">
{dealerInfo.dealerName}
</p>
</div>
</div>
{/* Contact Information */}
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Contact Information
</label>
<div className="mt-2 space-y-2">
{/* Email */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<Mail className="w-4 h-4 text-gray-400" />
<span>{dealerInfo.email}</span>
</div>
{/* Phone */}
<div className="flex items-center gap-2 text-sm text-gray-700">
<Phone className="w-4 h-4 text-gray-400" />
<span>{dealerInfo.phone}</span>
</div>
{/* Address */}
<div className="flex items-start gap-2 text-sm text-gray-700">
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
<span>{dealerInfo.address}</span>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

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

View File

@ -0,0 +1,161 @@
/**
* ProposalDetailsCard Component
* Displays proposal details submitted by dealer for Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Receipt, Calendar } from 'lucide-react';
import { format } from 'date-fns';
// Minimal local types to avoid missing imports during runtime
interface ProposalCostItem {
description: string;
amount?: number | null;
}
interface ProposalDetails {
costBreakup: ProposalCostItem[];
estimatedBudgetTotal?: number | null;
timelineForClosure?: string | null;
dealerComments?: string | null;
submittedOn?: string | null;
}
interface ProposalDetailsCardProps {
proposalDetails: ProposalDetails;
className?: string;
}
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
// Calculate estimated total from costBreakup if not provided
const calculateEstimatedTotal = () => {
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
return proposalDetails.estimatedBudgetTotal;
}
// Calculate sum from costBreakup items
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
const total = proposalDetails.costBreakup.reduce((sum, item) => {
const amount = item.amount || 0;
return sum + (Number.isNaN(amount) ? 0 : amount);
}, 0);
return total;
}
return 0;
};
const estimatedTotal = calculateEstimatedTotal();
const formatCurrency = (amount?: number | null) => {
if (amount === undefined || amount === null || Number.isNaN(amount)) {
return '₹0.00';
}
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
};
const formatDate = (dateString?: string | null) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString || '';
}
};
const formatTimelineDate = (dateString?: string | null) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return dateString || '-';
}
};
return (
<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(estimatedTotal)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
{/* Timeline for Closure */}
<div className="pt-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Timeline for Closure
</label>
<div className="mt-2 bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-gray-900">
Expected completion by: {formatTimelineDate(proposalDetails.timelineForClosure)}
</span>
</div>
</div>
</div>
{/* Dealer Comments */}
{proposalDetails.dealerComments && (
<div className="pt-2">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Dealer Comments
</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{proposalDetails.dealerComments}
</p>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,77 @@
/**
* RequestInitiatorCard Component
* Displays initiator/requester details - can be used for both claim management and regular workflows
*/
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Mail, Phone } from 'lucide-react';
interface InitiatorInfo {
name: string;
role?: string;
department?: string;
email: string;
phone?: string;
}
interface RequestInitiatorCardProps {
initiatorInfo: InitiatorInfo;
className?: string;
}
export function RequestInitiatorCard({ initiatorInfo, className }: RequestInitiatorCardProps) {
// Generate initials from name
const getInitials = (name: string) => {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<Card className={className}>
<CardHeader>
<CardTitle className="text-base">Request Initiator</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-4">
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
<AvatarFallback className="bg-gray-700 text-white font-semibold text-lg">
{getInitials(initiatorInfo.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h3 className="font-semibold text-gray-900">{initiatorInfo.name}</h3>
{initiatorInfo.role && (
<p className="text-sm text-gray-600">{initiatorInfo.role}</p>
)}
{initiatorInfo.department && (
<p className="text-sm text-gray-500">{initiatorInfo.department}</p>
)}
<div className="mt-3 space-y-2">
{/* Email */}
<div className="flex items-center gap-2 text-sm text-gray-600">
<Mail className="w-4 h-4" />
<span>{initiatorInfo.email}</span>
</div>
{/* Phone */}
{initiatorInfo.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Phone className="w-4 h-4" />
<span>{initiatorInfo.phone}</span>
</div>
)}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,12 @@
/**
* Claim Management Card Components
* Re-export all claim-specific card components for easy imports
*/
export { ActivityInformationCard } from './ActivityInformationCard';
export { DealerInformationCard } from './DealerInformationCard';
export { ProposalDetailsCard } from './ProposalDetailsCard';
export { ProcessDetailsCard } from './ProcessDetailsCard';
export { RequestInitiatorCard } from './RequestInitiatorCard';

View File

@ -0,0 +1,270 @@
/**
* CreditNoteSAPModal Component
* Modal for Step 8: Credit Note from SAP
* Allows Finance team to review credit note details and send to dealer
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Receipt, CircleCheckBig, Hash, Calendar, DollarSign, Building, FileText, Download, Send } from 'lucide-react';
import { toast } from 'sonner';
import { formatDateTime } from '@/utils/dateFormatter';
interface CreditNoteSAPModalProps {
isOpen: boolean;
onClose: () => void;
onDownload?: () => Promise<void>;
onSendToDealer?: () => Promise<void>;
creditNoteData?: {
creditNoteNumber?: string;
creditNoteDate?: string;
creditNoteAmount?: number;
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT';
};
dealerInfo?: {
dealerName?: string;
dealerCode?: string;
dealerEmail?: string;
};
activityName?: string;
requestNumber?: string;
requestId?: string;
dueDate?: string;
}
export function CreditNoteSAPModal({
isOpen,
onClose,
onDownload,
onSendToDealer,
creditNoteData,
dealerInfo,
activityName,
requestNumber,
requestId: _requestId,
dueDate,
}: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false);
const creditNoteNumber = creditNoteData?.creditNoteNumber || 'CN-RE-REQ-2024-CM-101-312580';
const creditNoteDate = creditNoteData?.creditNoteDate
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: 'Dec 5, 2025';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 800;
const status = creditNoteData?.status || 'APPROVED';
const dealerName = dealerInfo?.dealerName || 'Jaipur Royal Enfield';
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
const activity = activityName || 'Activity';
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
const dueDateDisplay = dueDate
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026';
const handleDownload = async () => {
if (onDownload) {
try {
setDownloading(true);
await onDownload();
toast.success('Credit note downloaded successfully');
} catch (error) {
console.error('Failed to download credit note:', error);
toast.error('Failed to download credit note. Please try again.');
} finally {
setDownloading(false);
}
} else {
// Default behavior: show info message
toast.info('Credit note will be automatically saved to Documents tab');
}
};
const handleSendToDealer = async () => {
if (onSendToDealer) {
try {
setSending(true);
await onSendToDealer();
toast.success('Credit note sent to dealer successfully');
onClose();
} catch (error) {
console.error('Failed to send credit note to dealer:', error);
toast.error('Failed to send credit note. Please try again.');
} finally {
setSending(false);
}
} else {
// Default behavior: show info message
toast.info('Email notification will be sent to dealer with credit note attachment');
}
};
const formatCurrency = (amount: number) => {
return `${amount.toLocaleString('en-IN', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP
</DialogTitle>
<DialogDescription className="text-base">
Review and send credit note to dealer
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-4">
{/* Credit Note Document Card */}
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border-2 border-green-200 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-semibold text-green-900 text-xl mb-1">Royal Enfield</h3>
<p className="text-sm text-green-700">Credit Note Document</p>
</div>
<Badge className="bg-green-600 text-white px-4 py-2 text-base">
<CircleCheckBig className="w-4 h-4 mr-2" />
{status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 mt-4">
<div className="bg-white rounded-lg p-3 border border-green-100">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1">
<Hash className="w-3 h-3" />
Credit Note Number
</Label>
<p className="font-bold text-gray-900 mt-1 text-lg">{creditNoteNumber}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-green-100">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1">
<Calendar className="w-3 h-3" />
Issue Date
</Label>
<p className="font-semibold text-gray-900 mt-1">{creditNoteDate}</p>
</div>
</div>
</div>
{/* Credit Note Amount */}
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-5">
<Label className="font-medium text-xs text-gray-600 uppercase tracking-wider flex items-center gap-1 mb-3">
<DollarSign className="w-4 h-4" />
Credit Note Amount
</Label>
<p className="text-4xl font-bold text-blue-700">{formatCurrency(creditNoteAmount)}</p>
</div>
{/* Dealer Information */}
<div className="bg-purple-50 border-2 border-purple-200 rounded-lg p-5">
<h3 className="font-semibold text-purple-900 mb-4 flex items-center gap-2">
<Building className="w-5 h-5" />
Dealer Information
</h3>
<div className="grid grid-cols-2 gap-4">
<div className="bg-white rounded-lg p-3 border border-purple-100">
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
Dealer Name
</Label>
<p className="font-semibold text-gray-900 mt-1">{dealerName}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-purple-100">
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
Dealer Code
</Label>
<p className="font-semibold text-gray-900 mt-1">{dealerCode}</p>
</div>
<div className="bg-white rounded-lg p-3 border border-purple-100">
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600 uppercase tracking-wider">
Activity
</Label>
<p className="font-semibold text-gray-900 mt-1">{activity}</p>
</div>
</div>
</div>
{/* Reference Details */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2">
<FileText className="w-4 h-4" />
Reference Details
</h3>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600">
Request ID
</Label>
<p className="font-medium text-gray-900 mt-1">{requestIdDisplay}</p>
</div>
<div>
<Label className="flex items-center gap-2 font-medium text-xs text-gray-600">
Due Date
</Label>
<p className="font-medium text-gray-900 mt-1">{dueDateDisplay}</p>
</div>
</div>
</div>
{/* Available Actions Info */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<FileText className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800">
<p className="font-semibold mb-2">Available Actions</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>
<strong>Download:</strong> Credit note will be automatically saved to Documents tab
</li>
<li>
<strong>Send to Dealer:</strong> Email notification will be sent to dealer with credit note attachment
</li>
<li>All actions will be recorded in activity trail for audit purposes</li>
</ul>
</div>
</div>
</div>
<DialogFooter className="flex-col-reverse gap-2 sm:flex-row flex items-center justify-between sm:justify-between">
<Button
variant="outline"
onClick={onClose}
disabled={downloading || sending}
className="border-2"
>
Close
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDownload}
disabled={downloading || sending}
className="border-blue-600 text-blue-600 hover:bg-blue-50"
>
<Download className="w-4 h-4 mr-2" />
{downloading ? 'Downloading...' : 'Download'}
</Button>
<Button
onClick={handleSendToDealer}
disabled={downloading || sending}
className="bg-green-600 hover:bg-green-700 text-white shadow-md"
>
<Send className="w-4 h-4 mr-2" />
{sending ? 'Sending...' : 'Send to Dealer'}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,616 @@
/**
* DealerCompletionDocumentsModal Component
* Modal for Step 5: Activity Completion Documents
* Allows dealers to upload completion documents, photos, expenses, and provide completion details
*/
import { useState, useRef, useMemo } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert } from 'lucide-react';
import { toast } from 'sonner';
interface ExpenseItem {
id: string;
description: string;
amount: number;
}
interface DealerCompletionDocumentsModalProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: {
activityCompletionDate: string;
numberOfParticipants?: number;
closedExpenses: ExpenseItem[];
totalClosedExpenses: number;
completionDocuments: File[];
activityPhotos: File[];
invoicesReceipts?: File[];
attendanceSheet?: File;
completionDescription: string;
}) => Promise<void>;
dealerName?: string;
activityName?: string;
requestId?: string;
}
export function DealerCompletionDocumentsModal({
isOpen,
onClose,
onSubmit,
dealerName = 'Jaipur Royal Enfield',
activityName = 'Activity',
requestId: _requestId,
}: DealerCompletionDocumentsModalProps) {
const [activityCompletionDate, setActivityCompletionDate] = useState('');
const [numberOfParticipants, setNumberOfParticipants] = useState('');
const [expenseItems, setExpenseItems] = useState<ExpenseItem[]>([]);
const [completionDocuments, setCompletionDocuments] = useState<File[]>([]);
const [activityPhotos, setActivityPhotos] = useState<File[]>([]);
const [invoicesReceipts, setInvoicesReceipts] = useState<File[]>([]);
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
const [completionDescription, setCompletionDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const completionDocsInputRef = useRef<HTMLInputElement>(null);
const photosInputRef = useRef<HTMLInputElement>(null);
const invoicesInputRef = useRef<HTMLInputElement>(null);
const attendanceInputRef = useRef<HTMLInputElement>(null);
// Calculate total closed expenses
const totalClosedExpenses = useMemo(() => {
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0);
}, [expenseItems]);
// Check if all required fields are filled
const isFormValid = useMemo(() => {
const hasCompletionDate = activityCompletionDate !== '';
const hasDocuments = completionDocuments.length > 0;
const hasPhotos = activityPhotos.length > 0;
const hasDescription = completionDescription.trim().length > 0;
return hasCompletionDate && hasDocuments && hasPhotos && hasDescription;
}, [activityCompletionDate, completionDocuments, activityPhotos, completionDescription]);
// Get today's date in YYYY-MM-DD format for max date
const maxDate = new Date().toISOString().split('T')[0];
const handleAddExpense = () => {
setExpenseItems([
...expenseItems,
{ id: Date.now().toString(), description: '', amount: 0 },
]);
};
const handleExpenseChange = (id: string, field: 'description' | 'amount', value: string | number) => {
setExpenseItems(
expenseItems.map((item) =>
item.id === id ? { ...item, [field]: value } : item
)
);
};
const handleRemoveExpense = (id: string) => {
setExpenseItems(expenseItems.filter((item) => item.id !== id));
};
const handleCompletionDocsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Validate file types
const allowedTypes = ['.pdf', '.doc', '.docx', '.zip', '.rar'];
const invalidFiles = files.filter(
(file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext))
);
if (invalidFiles.length > 0) {
toast.error('Please upload PDF, DOC, DOCX, ZIP, or RAR files only');
return;
}
setCompletionDocuments([...completionDocuments, ...files]);
}
};
const handleRemoveCompletionDoc = (index: number) => {
setCompletionDocuments(completionDocuments.filter((_, i) => i !== index));
};
const handlePhotosChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Validate image files
const invalidFiles = files.filter(
(file) => !file.type.startsWith('image/')
);
if (invalidFiles.length > 0) {
toast.error('Please upload image files only (JPG, PNG, etc.)');
return;
}
setActivityPhotos([...activityPhotos, ...files]);
}
};
const handleRemovePhoto = (index: number) => {
setActivityPhotos(activityPhotos.filter((_, i) => i !== index));
};
const handleInvoicesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
// Validate file types
const allowedTypes = ['.pdf', '.jpg', '.jpeg', '.png'];
const invalidFiles = files.filter(
(file) => !allowedTypes.some((ext) => file.name.toLowerCase().endsWith(ext))
);
if (invalidFiles.length > 0) {
toast.error('Please upload PDF, JPG, or PNG files only');
return;
}
setInvoicesReceipts([...invoicesReceipts, ...files]);
}
};
const handleRemoveInvoice = (index: number) => {
setInvoicesReceipts(invoicesReceipts.filter((_, i) => i !== index));
};
const handleAttendanceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file types
const allowedTypes = ['.pdf', '.xlsx', '.xls', '.csv'];
const fileExtension = '.' + file.name.split('.').pop()?.toLowerCase();
if (!allowedTypes.includes(fileExtension)) {
toast.error('Please upload PDF, Excel, or CSV files only');
return;
}
setAttendanceSheet(file);
}
};
const handleSubmit = async () => {
if (!isFormValid) {
toast.error('Please fill all required fields');
return;
}
// Filter valid expense items
const validExpenses = expenseItems.filter(
(item) => item.description.trim() !== '' && item.amount > 0
);
try {
setSubmitting(true);
await onSubmit({
activityCompletionDate,
numberOfParticipants: numberOfParticipants ? parseInt(numberOfParticipants) : undefined,
closedExpenses: validExpenses,
totalClosedExpenses,
completionDocuments,
activityPhotos,
invoicesReceipts: invoicesReceipts.length > 0 ? invoicesReceipts : undefined,
attendanceSheet: attendanceSheet || undefined,
completionDescription,
});
handleReset();
onClose();
} catch (error) {
console.error('Failed to submit completion documents:', error);
toast.error('Failed to submit completion documents. Please try again.');
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setActivityCompletionDate('');
setNumberOfParticipants('');
setExpenseItems([]);
setCompletionDocuments([]);
setActivityPhotos([]);
setInvoicesReceipts([]);
setAttendanceSheet(null);
setCompletionDescription('');
if (completionDocsInputRef.current) completionDocsInputRef.current.value = '';
if (photosInputRef.current) photosInputRef.current.value = '';
if (invoicesInputRef.current) invoicesInputRef.current.value = '';
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-lg max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<Upload className="w-6 h-6 text-[--re-green]" />
Activity Completion Documents
</DialogTitle>
<DialogDescription className="text-base">
Step 5: Upload completion proof and final documents
</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 completion documents, photos, and provide details about the completed activity.
</div>
</div>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Activity Completion Date */}
<div className="space-y-2">
<Label className="text-base font-semibold flex items-center gap-2" htmlFor="completionDate">
<Calendar className="w-4 h-4" />
Activity Completion Date *
</Label>
<Input
type="date"
id="completionDate"
max={maxDate}
value={activityCompletionDate}
onChange={(e) => setActivityCompletionDate(e.target.value)}
/>
</div>
{/* Closed Expenses 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">Closed Expenses</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
<Button
type="button"
onClick={handleAddExpense}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white"
size="sm"
>
<Plus className="w-4 h-4 mr-1" />
Add Expense
</Button>
</div>
<div className="space-y-3">
{expenseItems.map((item) => (
<div key={item.id} className="flex gap-2 items-start">
<div className="flex-1">
<Input
placeholder="Item name (e.g., Venue rental, Refreshments, Printing)"
value={item.description}
onChange={(e) =>
handleExpenseChange(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) =>
handleExpenseChange(item.id, 'amount', parseFloat(e.target.value) || 0)
}
/>
</div>
<Button
type="button"
variant="ghost"
size="sm"
className="mt-0.5 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveExpense(item.id)}
>
<X className="w-4 h-4" />
</Button>
</div>
))}
{expenseItems.length === 0 && (
<p className="text-sm text-gray-500 italic">
No expenses added. Click "Add Expense" to add expense items.
</p>
)}
{expenseItems.length > 0 && totalClosedExpenses > 0 && (
<div className="pt-2 border-t">
<div className="flex justify-between items-center">
<span className="font-semibold">Total Closed Expenses:</span>
<span className="font-semibold text-lg">
{totalClosedExpenses.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
</div>
{/* Completion Evidence Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Completion Evidence</h3>
<Badge className="bg-destructive text-white text-xs">Required</Badge>
</div>
{/* Completion Documents */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<FileText className="w-4 h-4" />
Completion Documents *
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload documents proving activity completion (reports, certificates, etc.) - Can upload multiple files or ZIP folder
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
ref={completionDocsInputRef}
type="file"
multiple
accept=".pdf,.doc,.docx,.zip,.rar"
className="hidden"
id="completionDocs"
onChange={handleCompletionDocsChange}
/>
<label
htmlFor="completionDocs"
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 documents (PDF, DOC, ZIP - multiple files allowed)
</span>
</label>
</div>
{completionDocuments.length > 0 && (
<div className="mt-2 space-y-1">
{completionDocuments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
>
<span className="truncate flex-1">{file.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveCompletionDoc(index)}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Activity Photos */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Image className="w-4 h-4" />
Activity Photos *
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload photos from the completed activity (event photos, installations, etc.)
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
ref={photosInputRef}
type="file"
multiple
accept="image/*"
className="hidden"
id="completionPhotos"
onChange={handlePhotosChange}
/>
<label
htmlFor="completionPhotos"
className="cursor-pointer flex flex-col items-center gap-2"
>
<Image className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload photos (JPG, PNG - multiple files allowed)
</span>
</label>
</div>
{activityPhotos.length > 0 && (
<div className="mt-2 space-y-1">
{activityPhotos.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
>
<span className="truncate flex-1">{file.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemovePhoto(index)}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
{/* Supporting Documents Section */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-lg">Supporting Documents</h3>
<Badge className="bg-secondary text-secondary-foreground text-xs">Optional</Badge>
</div>
{/* Invoices/Receipts */}
<div>
<Label className="text-base font-semibold flex items-center gap-2">
<Receipt className="w-4 h-4" />
Invoices / Receipts
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload invoices and receipts for expenses incurred
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
ref={invoicesInputRef}
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png"
className="hidden"
id="invoiceReceipts"
onChange={handleInvoicesChange}
/>
<label
htmlFor="invoiceReceipts"
className="cursor-pointer flex flex-col items-center gap-2"
>
<Receipt className="w-8 h-8 text-gray-400" />
<span className="text-sm text-gray-600">
Click to upload invoices/receipts (PDF, JPG, PNG)
</span>
</label>
</div>
{invoicesReceipts.length > 0 && (
<div className="mt-2 space-y-1">
{invoicesReceipts.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-50 p-2 rounded text-sm"
>
<span className="truncate flex-1">{file.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveInvoice(index)}
>
<X className="w-3 h-3" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Attendance Sheet */}
<div>
<Label className="text-base font-semibold">
Attendance Sheet / Participant List
</Label>
<p className="text-sm text-gray-600 mb-2">
Upload attendance records or participant lists (if applicable)
</p>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-4 hover:border-blue-500 transition-colors">
<input
ref={attendanceInputRef}
type="file"
accept=".pdf,.xlsx,.xls,.csv"
className="hidden"
id="attendanceDoc"
onChange={handleAttendanceChange}
/>
<label
htmlFor="attendanceDoc"
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 attendance sheet (Excel, PDF, CSV)
</span>
</label>
</div>
{attendanceSheet && (
<div className="mt-2 flex items-center justify-between bg-gray-50 p-2 rounded text-sm">
<span className="truncate flex-1">{attendanceSheet.name}</span>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => {
setAttendanceSheet(null);
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
}}
>
<X className="w-3 h-3" />
</Button>
</div>
)}
</div>
</div>
{/* Completion Description */}
<div className="space-y-2">
<Label className="text-base font-semibold flex items-center gap-2" htmlFor="completionDescription">
Brief Description of Completion *
</Label>
<Textarea
id="completionDescription"
placeholder="Provide a brief description of the completed activity, including key highlights, outcomes, challenges faced, and any relevant observations..."
value={completionDescription}
onChange={(e) => setCompletionDescription(e.target.value)}
className="min-h-[120px]"
/>
<p className="text-xs text-gray-500">{completionDescription.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 completion date, at least one document/photo, and description 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={submitting || !isFormValid}
className="bg-[#2d4a3e] hover:bg-[#1f3329] text-white disabled:bg-gray-300 disabled:text-gray-500"
>
{submitting ? 'Submitting...' : 'Submit Documents'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,148 @@
/**
* EmailNotificationTemplateModal Component
* Modal for displaying email notification templates for automated workflow steps
* Used for Step 4: Activity Creation and other auto-triggered steps
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Mail, User, Building, Calendar, X } from 'lucide-react';
interface EmailNotificationTemplateModalProps {
isOpen: boolean;
onClose: () => void;
stepNumber: number;
stepName: string;
requestNumber?: string;
recipientEmail?: string;
subject?: string;
emailBody?: string;
}
export function EmailNotificationTemplateModal({
isOpen,
onClose,
stepNumber,
stepName,
requestNumber = 'RE-REQ-2024-CM-101',
recipientEmail = 'system@royalenfield.com',
subject,
emailBody,
}: EmailNotificationTemplateModalProps) {
// Default subject if not provided
const defaultSubject = `System Notification: Activity Created - ${requestNumber}`;
const finalSubject = subject || defaultSubject;
// Default email body if not provided
const defaultEmailBody = `System Notification
Activity has been automatically created for claim ${requestNumber}.
All stakeholders have been notified.
This is an automated message.`;
const finalEmailBody = emailBody || defaultEmailBody;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl max-w-2xl">
<DialogHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center">
<Mail className="w-5 h-5 text-blue-600" />
</div>
<div>
<DialogTitle className="text-lg leading-none font-semibold">
Email Notification Template
</DialogTitle>
<DialogDescription className="text-sm">
Step {stepNumber}: {stepName}
</DialogDescription>
</div>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Email Header Section */}
<div className="bg-gradient-to-r from-blue-50 to-purple-50 rounded-lg p-4 border border-blue-200">
<div className="space-y-2">
<div className="flex items-start gap-2">
<User className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">To:</p>
<p className="text-sm font-medium text-gray-900">{recipientEmail}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Mail className="w-4 h-4 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-xs text-gray-600">Subject:</p>
<p className="text-sm font-semibold text-gray-900">{finalSubject}</p>
</div>
</div>
</div>
</div>
{/* Email Body Section */}
<div className="bg-white rounded-lg border border-gray-200 p-6">
<div className="space-y-4">
{/* Company Header */}
<div className="flex items-center gap-2 pb-3 border-b border-gray-200">
<Building className="w-5 h-5 text-purple-600" />
<span className="font-semibold text-gray-900">Royal Enfield</span>
</div>
{/* Email Content */}
<div className="prose prose-sm max-w-none">
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-700 leading-relaxed bg-transparent p-0 border-0">
{finalEmailBody}
</pre>
</div>
{/* Footer */}
<div className="pt-3 border-t border-gray-200">
<div className="flex items-center gap-2 text-xs text-gray-500">
<Calendar className="w-3 h-3" />
<span>Automated email Royal Enfield Claims Portal</span>
</div>
</div>
</div>
</div>
{/* Badges */}
<div className="flex items-center gap-2">
<Badge className="bg-blue-50 text-blue-700 border-blue-200">
Step {stepNumber}
</Badge>
<Badge className="bg-purple-50 text-purple-700 border-purple-200">
Auto-triggered
</Badge>
</div>
</div>
{/* Footer */}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={onClose}
className="h-9"
>
<X className="w-4 h-4 mr-2" />
Close
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@ -0,0 +1,219 @@
# Credit Note from SAP Modal - Integration Guide
## Overview
The `CreditNoteSAPModal` component is ready for implementation in Step 8 of the dealer claim workflow. This modal allows Finance team to review credit note details (generated from SAP) and send it to the dealer.
## Component Location
**File:** `Re_Figma_Code/src/pages/RequestDetail/components/modals/CreditNoteSAPModal.tsx`
## Features
### Display Sections:
1. **Credit Note Document Card** (Green gradient)
- Royal Enfield branding
- Status badge (Approved/Issued/Sent/Pending)
- Credit Note Number
- Issue Date
2. **Credit Note Amount** (Blue box)
- Large display of credit note amount in ₹
3. **Dealer Information** (Purple box)
- Dealer Name
- Dealer Code
- Activity Name
4. **Reference Details** (Gray box)
- Request ID
- Due Date
5. **Available Actions Info** (Blue info box)
- Explains what Download and Send actions do
### Actions:
- **Download**: Downloads/saves credit note to Documents tab
- **Send to Dealer**: Sends email notification to dealer with credit note attachment
- **Close**: Closes the modal
## Props Interface
```typescript
interface CreditNoteSAPModalProps {
isOpen: boolean;
onClose: () => void;
onDownload?: () => Promise<void>; // Optional: Custom download handler
onSendToDealer?: () => Promise<void>; // Optional: Custom send handler
creditNoteData?: {
creditNoteNumber?: string;
creditNoteDate?: string;
creditNoteAmount?: number;
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT';
};
dealerInfo?: {
dealerName?: string;
dealerCode?: string;
dealerEmail?: string;
};
activityName?: string;
requestNumber?: string;
requestId?: string;
dueDate?: string;
}
```
## Integration Steps
### 1. Import the Modal
In `DealerClaimWorkflowTab.tsx`:
```typescript
import { CreditNoteSAPModal } from '../modals/CreditNoteSAPModal';
```
### 2. Add State
```typescript
const [showCreditNoteModal, setShowCreditNoteModal] = useState(false);
```
### 3. Add Button for Step 8
In the workflow steps rendering, add button for Step 8 (Finance):
```typescript
{step.step === 8 && isFinanceUser && (
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => setShowCreditNoteModal(true)}
>
<Receipt className="w-4 h-4 mr-2" />
View Credit Note
</Button>
)}
```
### 4. Add Modal Component
```typescript
<CreditNoteSAPModal
isOpen={showCreditNoteModal}
onClose={() => setShowCreditNoteModal(false)}
onDownload={handleCreditNoteDownload}
onSendToDealer={handleSendCreditNoteToDealer}
creditNoteData={{
creditNoteNumber: request?.claimDetails?.creditNoteNumber,
creditNoteDate: request?.claimDetails?.creditNoteDate,
creditNoteAmount: request?.claimDetails?.creditNoteAmount,
status: 'APPROVED', // or get from request status
}}
dealerInfo={{
dealerName: request?.claimDetails?.dealerName,
dealerCode: request?.claimDetails?.dealerCode,
dealerEmail: request?.claimDetails?.dealerEmail,
}}
activityName={request?.claimDetails?.activityName}
requestNumber={request?.requestNumber}
requestId={request?.requestId}
dueDate={/* Calculate due date based on business rules */}
/>
```
### 5. Implement Handlers
```typescript
const handleCreditNoteDownload = async () => {
try {
const requestId = request?.id || request?.requestId;
// TODO: Implement download logic
// - Generate/download credit note PDF from SAP
// - Save to Documents tab
// - Create activity log entry
toast.success('Credit note downloaded and saved to Documents');
} catch (error) {
console.error('Failed to download credit note:', error);
throw error;
}
};
const handleSendCreditNoteToDealer = async () => {
try {
const requestId = request?.id || request?.requestId;
const dealerEmail = request?.claimDetails?.dealerEmail;
// TODO: Implement send logic
// - Send email to dealer with credit note attachment
// - Update credit note status to 'SENT'
// - Create activity log entry
// - Possibly approve Step 8
toast.success('Credit note sent to dealer successfully');
} catch (error) {
console.error('Failed to send credit note:', error);
throw error;
}
};
```
## API Integration Points
### Backend Endpoints (to be implemented):
1. **Download Credit Note**
- `GET /api/v1/dealer-claims/:requestId/credit-note/download`
- Returns credit note PDF/document
2. **Send Credit Note to Dealer**
- `POST /api/v1/dealer-claims/:requestId/credit-note/send`
- Sends email notification to dealer
- Updates credit note status
3. **Get Credit Note Details**
- Already available via `getClaimDetails()` API
- Returns `creditNoteNumber`, `creditNoteDate`, `creditNoteAmount` from `claimDetails`
## Data Flow
1. **Credit Note Generation** (Step 7 or Step 8):
- Credit note is generated from SAP/DMS
- Stored in `dealer_claim_details` table
- Fields: `credit_note_number`, `credit_note_date`, `credit_note_amount`
2. **Display in Modal**:
- Modal reads from `request.claimDetails`
- Displays credit note information
- Shows dealer and request details
3. **Actions**:
- Download: Saves credit note to Documents tab
- Send: Emails dealer and updates status
## UI Styling
The modal matches the provided HTML structure:
- ✅ Green gradient card for credit note document
- ✅ Blue box for amount display
- ✅ Purple box for dealer information
- ✅ Gray box for reference details
- ✅ Blue info box for available actions
- ✅ Proper icons (Receipt, Hash, Calendar, DollarSign, Building, FileText, Download, Send)
- ✅ Status badge with checkmark icon
- ✅ Responsive grid layouts
## Status
**Component Created**: Ready for integration
**Integration**: Pending - needs to be added to `DealerClaimWorkflowTab.tsx`
**API Handlers**: Pending - download and send handlers need implementation
**Backend Endpoints**: Pending - download and send endpoints need to be created
## Next Steps
1. Add modal to `DealerClaimWorkflowTab.tsx` when Step 8 is ready
2. Implement download handler (integrate with SAP/DMS)
3. Implement send handler (email notification)
4. Add Step 8 button visibility logic (Finance team only)
5. Test credit note flow end-to-end

View File

@ -0,0 +1,170 @@
/**
* ClaimManagementOverviewTab Component
* Specialized overview tab for Claim Management requests
* Uses modular card components for flexible rendering based on role and request state
*/
import {
ActivityInformationCard,
DealerInformationCard,
ProposalDetailsCard,
RequestInitiatorCard,
} from '../claim-cards';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
import {
mapToClaimManagementRequest,
determineUserRole,
getRoleBasedVisibility,
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: _request,
apiRequest,
currentUserId,
isInitiator: _isInitiator,
onEditClaimAmount: _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,
closedExpenses: claimRequest.activityInfo?.closedExpenses,
closedExpensesBreakdown: claimRequest.activityInfo?.closedExpensesBreakdown,
hasDealerCode: !!claimRequest.dealerInfo?.dealerCode,
hasDealerName: !!claimRequest.dealerInfo?.dealerName,
});
// 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,
showDealerInfo: visibility.showDealerInfo,
dealerInfoPresent: !!(claimRequest.dealerInfo?.dealerCode || claimRequest.dealerInfo?.dealerName),
});
// Extract initiator info from request
// The apiRequest has initiator object with displayName, email, department, phone, etc.
const initiatorInfo = {
name: apiRequest.initiator?.name || apiRequest.initiator?.displayName || apiRequest.initiator?.email || 'Unknown',
role: apiRequest.initiator?.role || apiRequest.initiator?.designation || 'Initiator',
department: apiRequest.initiator?.department || apiRequest.department || '',
email: apiRequest.initiator?.email || 'N/A',
phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile,
};
return (
<div className={`space-y-6 ${className}`}>
{/* Activity Information - Always visible */}
<ActivityInformationCard activityInfo={claimRequest.activityInfo} />
{/* Dealer Information - Always visible */}
<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>
);
}
/**
* Wrapper component that decides whether to show claim management or regular overview
*/
interface AdaptiveOverviewTabProps {
request: any;
apiRequest: any;
currentUserId: string;
isInitiator: boolean;
onEditClaimAmount?: () => void;
// Props for regular overview tab
regularOverviewComponent?: React.ComponentType<any>;
regularOverviewProps?: any;
}
export function AdaptiveOverviewTab({
request,
apiRequest,
currentUserId,
isInitiator,
onEditClaimAmount,
regularOverviewComponent: RegularOverview,
regularOverviewProps,
}: AdaptiveOverviewTabProps) {
// Determine if this is a claim management request
const isClaim = isClaimManagementRequest(apiRequest);
if (isClaim) {
return (
<ClaimManagementOverviewTab
request={request}
apiRequest={apiRequest}
currentUserId={currentUserId}
isInitiator={isInitiator}
onEditClaimAmount={onEditClaimAmount}
/>
);
}
// Render regular overview if provided
if (RegularOverview) {
return <RegularOverview {...regularOverviewProps} />;
}
// Fallback
return (
<div className="text-center py-8 text-gray-500">
<p>No overview available for this request type.</p>
</div>
);
}

View File

@ -0,0 +1,311 @@
/**
* ClaimManagementWorkflowTab Component
* Displays the 8-step workflow process specific to Claim Management requests
*/
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
TrendingUp,
CircleCheckBig,
Clock,
Mail,
Download,
Receipt,
Activity,
AlertCircle,
} from 'lucide-react';
import { format } from 'date-fns';
interface WorkflowStep {
stepNumber: number;
stepName: string;
stepDescription: string;
assignedTo: string;
assignedToType: 'dealer' | 'requestor' | 'department_lead' | 'finance' | 'system';
status: 'pending' | 'in_progress' | 'approved' | 'rejected' | 'skipped';
tatHours: number;
elapsedHours?: number;
remarks?: string;
approvedAt?: string;
approvedBy?: string;
ioDetails?: {
ioNumber: string;
ioRemarks: string;
organisedBy: string;
organisedAt: string;
};
dmsDetails?: {
dmsNumber: string;
dmsRemarks: string;
pushedBy: string;
pushedAt: string;
};
hasEmailNotification?: boolean;
hasDownload?: boolean;
downloadUrl?: string;
}
interface ClaimManagementWorkflowTabProps {
steps: WorkflowStep[];
currentStep: number;
totalSteps?: number;
onViewEmailTemplate?: (stepNumber: number) => void;
onDownloadDocument?: (stepNumber: number, url: string) => void;
className?: string;
}
export function ClaimManagementWorkflowTab({
steps,
currentStep,
totalSteps = 8,
onViewEmailTemplate,
onDownloadDocument,
className = '',
}: ClaimManagementWorkflowTabProps) {
const formatDate = (dateString?: string) => {
if (!dateString) return 'N/A';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString;
}
};
const getStepBorderColor = (status: string) => {
switch (status) {
case 'approved':
return 'border-green-500 bg-green-50';
case 'in_progress':
return 'border-blue-500 bg-blue-50';
case 'rejected':
return 'border-red-500 bg-red-50';
case 'pending':
return 'border-gray-300 bg-white';
case 'skipped':
return 'border-gray-400 bg-gray-50';
default:
return 'border-gray-300 bg-white';
}
};
const getStepIconBg = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100';
case 'in_progress':
return 'bg-blue-100';
case 'rejected':
return 'bg-red-100';
case 'pending':
return 'bg-gray-100';
case 'skipped':
return 'bg-gray-200';
default:
return 'bg-gray-100';
}
};
const getStepIcon = (status: string) => {
switch (status) {
case 'approved':
return <CircleCheckBig className="w-5 h-5 text-green-600" />;
case 'in_progress':
return <Clock className="w-5 h-5 text-blue-600" />;
case 'rejected':
return <AlertCircle className="w-5 h-5 text-red-600" />;
case 'pending':
return <Clock className="w-5 h-5 text-gray-400" />;
default:
return <Clock className="w-5 h-5 text-gray-400" />;
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'approved':
return 'bg-green-100 text-green-800 border-green-200';
case 'in_progress':
return 'bg-blue-100 text-blue-800 border-blue-200';
case 'rejected':
return 'bg-red-100 text-red-800 border-red-200';
case 'pending':
return 'bg-gray-100 text-gray-600 border-gray-200';
case 'skipped':
return 'bg-gray-200 text-gray-700 border-gray-300';
default:
return 'bg-gray-100 text-gray-600 border-gray-200';
}
};
return (
<Card className={className}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="leading-none flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-purple-600" />
Claim Management Workflow
</CardTitle>
<CardDescription className="mt-2">
8-Step approval process for dealer claim management
</CardDescription>
</div>
<Badge variant="outline" className="font-medium">
Step {currentStep} of {totalSteps}
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{steps.map((step) => (
<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>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,439 @@
/**
* 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 { Badge } from '@/components/ui/badge';
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
import { toast } from 'sonner';
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext';
interface IOTabProps {
request: any;
apiRequest?: any;
onRefresh?: () => void;
}
interface IOBlockedDetails {
ioNumber: string;
blockedAmount: number;
availableBalance: number; // Available amount before block
remainingBalance: number; // Remaining amount after block
blockedDate: string;
blockedBy: string; // User who blocked
sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed';
}
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth();
const requestId = apiRequest?.requestId || request?.requestId;
// Load existing IO data from apiRequest or request
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
const organizer = internalOrder?.organizer || null;
const [ioNumber, setIoNumber] = useState(existingIONumber);
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block details from apiRequest
useEffect(() => {
if (internalOrder && existingIONumber && existingBlockedAmount > 0) {
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
// Get blocked by user name from organizer association (who blocked the amount)
// When amount is blocked, organizedBy stores the user who blocked it
const blockedByName = organizer?.displayName ||
organizer?.display_name ||
organizer?.name ||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
organizer?.email ||
'Unknown User';
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
});
setIoNumber(existingIONumber);
// Set fetched amount if available balance exists
if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock);
}
}
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/**
* Fetch available budget from SAP
* Validates IO number and gets available balance (returns dummy data for now)
* Does not store anything in database - only validates
*/
const handleFetchAmount = async () => {
if (!ioNumber.trim()) {
toast.error('Please enter an IO number');
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
setFetchingAmount(true);
try {
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim());
if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with available balance
setAmountToBlock(String(ioData.availableBalance));
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
} else {
toast.error('Invalid IO number or no available balance found');
setFetchedAmount(null);
setAmountToBlock('');
}
} catch (error: any) {
console.error('Failed to fetch IO budget:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to validate IO number or fetch budget from SAP';
toast.error(errorMessage);
setFetchedAmount(null);
} finally {
setFetchingAmount(false);
}
};
/**
* Block budget in SAP system
*/
const handleBlockBudget = async () => {
if (!ioNumber.trim() || fetchedAmount === null) {
toast.error('Please fetch IO amount first');
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
const blockAmount = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) {
toast.error('Please enter a valid amount to block');
return;
}
if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget');
return;
}
setBlockingBudget(true);
try {
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
// This will store in internal_orders and claim_budget_tracking tables
await updateIODetails(requestId, {
ioNumber: ioNumber.trim(),
ioAvailableBalance: fetchedAmount,
ioBlockedAmount: blockAmount,
ioRemainingBalance: fetchedAmount - blockAmount,
});
// Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) {
const currentUser = user as any;
// When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount
const blockedByName = currentUser?.displayName ||
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
availableBalance: fetchedAmount, // Available amount before block
remainingBalance: Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount)),
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked',
};
setBlockedDetails(blocked);
setAmountToBlock(''); // Clear the input
toast.success('IO budget blocked successfully in SAP');
// Refresh request details
onRefresh?.();
} else {
toast.error('IO blocked but failed to fetch updated details');
onRefresh?.();
}
} catch (error: any) {
console.error('Failed to block IO budget:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to block IO budget in SAP';
toast.error(errorMessage);
} finally {
setBlockingBudget(false);
}
};
/**
* Release blocked budget
* Note: This functionality may need a separate backend endpoint for releasing IO budget
* For now, we'll call updateIODetails with blockedAmount=0 to release
*/
const handleReleaseBudget = async () => {
if (!blockedDetails || !requestId) {
toast.error('No blocked budget to release');
return;
}
if (!ioNumber.trim()) {
toast.error('IO number not found');
return;
}
try {
// Release budget by setting blockedAmount to 0
// Note: Backend may need a dedicated release endpoint for proper SAP integration
await updateIODetails(requestId, {
ioNumber: ioNumber.trim(),
ioAvailableBalance: blockedDetails.availableBalance + blockedDetails.blockedAmount,
ioBlockedAmount: 0,
ioRemainingBalance: blockedDetails.availableBalance + blockedDetails.blockedAmount,
});
// Clear local state
setBlockedDetails(null);
setFetchedAmount(null);
setIoNumber('');
toast.success('IO budget released successfully');
// Refresh request details
onRefresh?.();
} catch (error: any) {
console.error('Failed to release IO budget:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to release IO budget';
toast.error(errorMessage);
}
};
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-2 border-green-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-gray-600 uppercase tracking-wide mb-1">Available Amount</p>
<p className="text-2xl font-bold text-green-700">
{fetchedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<CircleCheckBig className="w-8 h-8 text-green-600" />
</div>
<div className="mt-3 pt-3 border-t border-green-200">
<p className="text-xs text-gray-600"><strong>IO Number:</strong> {ioNumber}</p>
<p className="text-xs text-gray-600 mt-1"><strong>Fetched from:</strong> SAP System</p>
</div>
</div>
{/* Amount to Block Input */}
<div className="space-y-3">
<Label htmlFor="blockAmount">Amount to Block *</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500"></span>
<Input
type="number"
id="blockAmount"
placeholder="Enter amount to block"
min="0"
step="0.01"
value={amountToBlock}
onChange={(e) => setAmountToBlock(e.target.value)}
className="pl-8"
/>
</div>
</div>
{/* Block Button */}
<Button
onClick={handleBlockBudget}
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Target className="w-4 h-4 mr-2" />
{blockingBudget ? 'Blocking in SAP...' : 'Block IO in SAP'}
</Button>
</>
)}
</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-green-50 border-2 border-green-500 rounded-lg p-4">
<div className="flex items-start gap-3">
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
</div>
</div>
</div>
{/* Blocked Details */}
<div className="border rounded-lg divide-y">
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
</div>
<div className="p-4 bg-green-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<p className="text-xl font-bold text-green-700">
{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
<p className="text-sm font-medium text-gray-900">
{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4 bg-blue-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
<p className="text-sm font-bold text-blue-700">
{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
<p className="text-sm font-medium text-gray-900">
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})}
</p>
</div>
<div className="p-4 bg-gray-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
Blocked
</Badge>
</div>
</div>
</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>
);
}

View File

@ -0,0 +1,31 @@
/**
* Claim Management TypeScript interfaces
* Types for Claim Management request components
*/
export interface ClaimActivityInfo {
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;
}
export interface DealerInfo {
dealerCode: string;
dealerName: string;
email?: string;
phone?: string;
address?: string;
}

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

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

View File

@ -0,0 +1,282 @@
/**
* 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;
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
}
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;
}
}
/**
* Validate/Fetch IO details from SAP (returns dummy data for now)
* GET /api/v1/dealer-claims/:requestId/io/validate?ioNumber=IO1234
* This only validates and returns IO details, does not store anything
*/
export async function validateIO(
requestId: string,
ioNumber: string
): Promise<{
ioNumber: string;
availableBalance: number;
currency: string;
isValid: boolean;
}> {
try {
const response = await apiClient.get(`/dealer-claims/${requestId}/io/validate`, {
params: { ioNumber }
});
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error validating IO:', error);
throw error;
}
}
/**
* Update IO details and block amount (Step 3)
* PUT /api/v1/dealer-claims/:requestId/io
* Only stores data when blocking amount > 0
*/
export async function updateIODetails(
requestId: string,
ioData: {
ioNumber: string;
ioRemark?: string;
ioAvailableBalance?: number;
ioBlockedAmount?: number;
ioRemainingBalance?: number;
}
): Promise<any> {
try {
// Map frontend field names to backend expected field names
const payload = {
ioNumber: ioData.ioNumber,
ioRemark: ioData.ioRemark || '',
availableBalance: ioData.ioAvailableBalance ?? 0,
blockedAmount: ioData.ioBlockedAmount ?? 0,
remainingBalance: ioData.ioRemainingBalance ?? 0,
};
const response = await apiClient.put(`/dealer-claims/${requestId}/io`, payload);
return response.data?.data || response.data;
} catch (error) {
console.error('Error updating IO details:', error);
throw error;
}
}
/**
* Update E-Invoice details (Step 7)
* PUT /api/v1/dealer-claims/:requestId/e-invoice
*/
export async function updateEInvoice(
requestId: string,
eInvoiceData: {
eInvoiceNumber?: string;
eInvoiceDate: string; // ISO date string
dmsNumber?: string;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/e-invoice`, eInvoiceData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating e-invoice:', error);
throw error;
}
}
/**
* Update Credit Note details (Step 8)
* PUT /api/v1/dealer-claims/:requestId/credit-note
*/
export async function updateCreditNote(
requestId: string,
creditNoteData: {
creditNoteNumber?: string;
creditNoteDate: string; // ISO date string
creditNoteAmount: number;
}
): Promise<any> {
try {
const response = await apiClient.put(`/dealer-claims/${requestId}/credit-note`, creditNoteData);
return response.data?.data || response.data;
} catch (error: any) {
console.error('[DealerClaimAPI] Error updating credit note:', error);
throw error;
}
}

View File

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

View File

@ -0,0 +1,387 @@
/**
* 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) - from internal_orders table
ioDetails?: {
ioNumber?: string;
ioRemark?: string;
availableBalance?: number;
blockedAmount?: number;
remainingBalance?: number;
organizedBy?: string;
organizedAt?: string;
};
// 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 || {};
const internalOrder = apiRequest.internalOrder || apiRequest.internal_order || {};
// Extract new normalized tables
const budgetTracking = apiRequest.budgetTracking || apiRequest.budget_tracking || {};
const invoice = apiRequest.invoice || {};
const creditNote = apiRequest.creditNote || apiRequest.credit_note || {};
const completionExpenses = apiRequest.completionExpenses || apiRequest.completion_expenses || [];
// 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,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
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,
});
// Get budget values from budgetTracking table (new source of truth)
const estimatedBudget = budgetTracking.proposalEstimatedBudget ||
budgetTracking.proposal_estimated_budget ||
budgetTracking.initialEstimatedBudget ||
budgetTracking.initial_estimated_budget ||
claimDetails.estimatedBudget ||
claimDetails.estimated_budget;
// Get closed expenses - check multiple sources with proper number conversion
const closedExpensesRaw = budgetTracking?.closedExpenses ||
budgetTracking?.closed_expenses ||
completionDetails?.totalClosedExpenses ||
completionDetails?.total_closed_expenses ||
claimDetails?.closedExpenses ||
claimDetails?.closed_expenses;
// Convert to number and handle 0 as valid value
const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined
? Number(closedExpensesRaw)
: undefined;
// Get closed expenses breakdown from new completionExpenses table
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
? completionExpenses.map((exp: any) => ({
description: exp.description || exp.itemDescription || '',
amount: Number(exp.amount) || 0
}))
: (completionDetails?.closedExpenses ||
completionDetails?.closed_expenses ||
completionDetails?.closedExpensesBreakdown ||
[]);
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,
closedExpenses,
closedExpensesBreakdown,
description: apiRequest.description || '', // Get description from workflow request
};
// Map dealer information (matching DealerInformationCard expectations)
// Dealer info should always be available from claimDetails (created during claim request creation)
// Handle both camelCase and snake_case from Sequelize JSON serialization
const dealerInfo = {
dealerCode: claimDetails?.dealerCode || claimDetails?.dealer_code || claimDetails?.DealerCode || '',
dealerName: claimDetails?.dealerName || claimDetails?.dealer_name || claimDetails?.DealerName || '',
email: claimDetails?.dealerEmail || claimDetails?.dealer_email || claimDetails?.DealerEmail || '',
phone: claimDetails?.dealerPhone || claimDetails?.dealer_phone || claimDetails?.DealerPhone || '',
address: claimDetails?.dealerAddress || claimDetails?.dealer_address || claimDetails?.DealerAddress || '',
};
// Log warning if dealer info is missing (should always be present for claim management requests)
if (!dealerInfo.dealerCode || !dealerInfo.dealerName) {
console.warn('[claimDataMapper] Dealer information is missing from claimDetails:', {
hasClaimDetails: !!claimDetails,
dealerCode: dealerInfo.dealerCode,
dealerName: dealerInfo.dealerName,
rawClaimDetails: claimDetails,
availableKeys: claimDetails ? Object.keys(claimDetails) : [],
});
}
// Map proposal details
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
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: expectedCompletionDate,
expectedCompletionDays: proposalDetails.expectedCompletionDays || proposalDetails.expected_completion_days,
timelineForClosure: expectedCompletionDate, // Map expectedCompletionDate to timelineForClosure for ProposalDetailsCard
dealerComments: proposalDetails.dealerComments || proposalDetails.dealer_comments,
submittedOn: proposalDetails.submittedAt || proposalDetails.submitted_at || proposalDetails.submittedOn,
} : undefined;
// Map IO details from dedicated internal_orders table
const ioDetails = {
ioNumber: internalOrder.ioNumber || internalOrder.io_number || claimDetails.ioNumber || claimDetails.io_number,
ioRemark: internalOrder.ioRemark || internalOrder.io_remark || '',
availableBalance: internalOrder.ioAvailableBalance || internalOrder.io_available_balance || claimDetails.ioAvailableBalance || claimDetails.io_available_balance,
blockedAmount: internalOrder.ioBlockedAmount || internalOrder.io_blocked_amount || claimDetails.ioBlockedAmount || claimDetails.io_blocked_amount,
remainingBalance: internalOrder.ioRemainingBalance || internalOrder.io_remaining_balance || claimDetails.ioRemainingBalance || claimDetails.io_remaining_balance,
organizedBy: internalOrder.organizer?.displayName || internalOrder.organizer?.name || internalOrder.organizedBy || '',
organizedAt: internalOrder.organizedAt || internalOrder.organized_at || '',
};
// Map DMS details from new invoice and credit note tables
const dmsDetails = {
eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number ||
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
eInvoiceDate: invoice.invoiceDate || invoice.invoice_date ||
claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
dmsNumber: invoice.dmsNumber || invoice.dms_number ||
claimDetails.dmsNumber || claimDetails.dms_number,
creditNoteNumber: creditNote.creditNoteNumber || creditNote.credit_note_number ||
claimDetails.creditNoteNumber || claimDetails.credit_note_number,
creditNoteDate: creditNote.creditNoteDate || creditNote.credit_note_date ||
claimDetails.creditNoteDate || claimDetails.credit_note_date,
creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
(claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))),
};
// Map claim amounts
const claimAmount = {
estimated: activityInfo.estimatedBudget || 0,
closed: activityInfo.closedExpenses || 0,
};
return {
activityInfo,
dealerInfo,
proposalDetails: proposal,
ioDetails: Object.keys(ioDetails).some(k => ioDetails[k as keyof typeof ioDetails]) ? ioDetails : undefined,
dmsDetails: Object.keys(dmsDetails).some(k => dmsDetails[k as keyof typeof dmsDetails]) ? dmsDetails : undefined,
claimAmount,
};
} catch (error) {
console.error('[claimDataMapper] Error mapping claim data:', error);
return null;
}
}
/**
* Determine user's role in the request
*/
export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole {
try {
// Check if user is the initiator
if (apiRequest.initiatorId === currentUserId ||
apiRequest.initiator?.userId === currentUserId ||
apiRequest.requestedBy?.userId === currentUserId) {
return 'INITIATOR';
}
// Check if user is a dealer (participant with DEALER type)
const participants = apiRequest.participants || [];
const dealerParticipant = participants.find((p: any) =>
(p.userId === currentUserId || p.user?.userId === currentUserId) &&
(p.participantType === 'DEALER' || p.type === 'DEALER')
);
if (dealerParticipant) {
return 'DEALER';
}
// Check if user is a department lead (approver at level 3)
const approvalLevels = apiRequest.approvalLevels || [];
const deptLeadLevel = approvalLevels.find((level: any) =>
level.levelNumber === 3 &&
(level.approverId === currentUserId || level.approver?.userId === currentUserId)
);
if (deptLeadLevel) {
return 'DEPARTMENT_LEAD';
}
// Check if user is an approver
const approverLevel = approvalLevels.find((level: any) =>
(level.approverId === currentUserId || level.approver?.userId === currentUserId) &&
level.status === 'PENDING'
);
if (approverLevel) {
return 'APPROVER';
}
// Default to spectator
return 'SPECTATOR';
} catch (error) {
console.error('[claimDataMapper] Error determining user role:', error);
return 'SPECTATOR';
}
}
/**
* Get role-based visibility settings
*/
export function getRoleBasedVisibility(role: RequestRole): RoleVisibility {
switch (role) {
case 'INITIATOR':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false, // Can only edit in specific scenarios
};
case 'DEALER':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: false,
showDMSDetails: false,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'DEPARTMENT_LEAD':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'APPROVER':
return {
showDealerInfo: true,
showProposalDetails: true,
showIODetails: true,
showDMSDetails: true,
showClaimAmount: true,
canEditClaimAmount: false,
};
case 'SPECTATOR':
default:
return {
showDealerInfo: false,
showProposalDetails: false,
showIODetails: false,
showDMSDetails: false,
showClaimAmount: false,
canEditClaimAmount: false,
};
}
}

View File

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