Merge branch 'dealer_claim' of https://git.tech4biz.wiki/laxmanhalaki/Re_Figma_Code into dealer_claim
This commit is contained in:
commit
ac9d4aefe4
231
docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md
Normal file
231
docs/FRONTEND_CLAIM_MANAGEMENT_UPDATES.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# Frontend Updates for Dealer Claim Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document outlines the frontend changes needed to support the new backend structure with `workflowType` field for dealer claim management.
|
||||||
|
|
||||||
|
## ✅ Completed
|
||||||
|
|
||||||
|
1. **Created utility function** (`src/utils/claimRequestUtils.ts`)
|
||||||
|
- `isClaimManagementRequest()` - Checks if request is claim management (supports both old and new formats)
|
||||||
|
- `getWorkflowType()` - Gets workflow type from request
|
||||||
|
- `shouldUseClaimManagementUI()` - Determines if claim-specific UI should be used
|
||||||
|
|
||||||
|
## 🔧 Required Updates
|
||||||
|
|
||||||
|
### 1. Update RequestDetail Component
|
||||||
|
|
||||||
|
**File**: `src/pages/RequestDetail/RequestDetail.tsx`
|
||||||
|
|
||||||
|
**Changes Needed**:
|
||||||
|
- Import `isClaimManagementRequest` from `@/utils/claimRequestUtils`
|
||||||
|
- Conditionally render `ClaimManagementOverviewTab` instead of `OverviewTab` when it's a claim management request
|
||||||
|
- Conditionally render `DealerClaimWorkflowTab` instead of `WorkflowTab` when it's a claim management request
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
||||||
|
import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab';
|
||||||
|
import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab';
|
||||||
|
|
||||||
|
// In the component:
|
||||||
|
const isClaimManagement = isClaimManagementRequest(apiRequest);
|
||||||
|
|
||||||
|
<TabsContent value="overview">
|
||||||
|
{isClaimManagement ? (
|
||||||
|
<ClaimManagementOverviewTab
|
||||||
|
request={request}
|
||||||
|
apiRequest={apiRequest}
|
||||||
|
currentUserId={(user as any)?.userId}
|
||||||
|
isInitiator={isInitiator}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OverviewTab {...overviewProps} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="workflow">
|
||||||
|
{isClaimManagement ? (
|
||||||
|
<DealerClaimWorkflowTab
|
||||||
|
request={request}
|
||||||
|
user={user}
|
||||||
|
isInitiator={isInitiator}
|
||||||
|
onRefresh={refreshDetails}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<WorkflowTab {...workflowProps} />
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Missing Utility Functions
|
||||||
|
|
||||||
|
**File**: `src/pages/RequestDetail/utils/claimDataMapper.ts` (NEW FILE)
|
||||||
|
|
||||||
|
**Functions Needed**:
|
||||||
|
- `mapToClaimManagementRequest(apiRequest, userId)` - Maps backend API response to claim management structure
|
||||||
|
- `determineUserRole(apiRequest, userId)` - Determines user's role (INITIATOR, DEALER, APPROVER, SPECTATOR)
|
||||||
|
- Helper functions to extract claim-specific data from API response
|
||||||
|
|
||||||
|
**Structure**:
|
||||||
|
```typescript
|
||||||
|
export interface ClaimManagementRequest {
|
||||||
|
activityInfo: {
|
||||||
|
activityName: string;
|
||||||
|
activityType: string;
|
||||||
|
activityDate: string;
|
||||||
|
location: string;
|
||||||
|
periodStart?: string;
|
||||||
|
periodEnd?: string;
|
||||||
|
estimatedBudget?: number;
|
||||||
|
closedExpensesBreakdown?: any[];
|
||||||
|
};
|
||||||
|
dealerInfo: {
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
dealerEmail?: string;
|
||||||
|
dealerPhone?: string;
|
||||||
|
dealerAddress?: string;
|
||||||
|
};
|
||||||
|
proposalDetails?: {
|
||||||
|
costBreakup: any[];
|
||||||
|
totalEstimatedBudget: number;
|
||||||
|
timeline: string;
|
||||||
|
dealerComments?: string;
|
||||||
|
};
|
||||||
|
ioDetails?: {
|
||||||
|
ioNumber?: string;
|
||||||
|
availableBalance?: number;
|
||||||
|
blockedAmount?: number;
|
||||||
|
remainingBalance?: number;
|
||||||
|
};
|
||||||
|
dmsDetails?: {
|
||||||
|
dmsNumber?: string;
|
||||||
|
eInvoiceNumber?: string;
|
||||||
|
creditNoteNumber?: string;
|
||||||
|
};
|
||||||
|
claimAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapToClaimManagementRequest(
|
||||||
|
apiRequest: any,
|
||||||
|
userId: string
|
||||||
|
): ClaimManagementRequest | null {
|
||||||
|
// Extract data from apiRequest.claimDetails (from backend)
|
||||||
|
// Map to ClaimManagementRequest structure
|
||||||
|
}
|
||||||
|
|
||||||
|
export function determineUserRole(
|
||||||
|
apiRequest: any,
|
||||||
|
userId: string
|
||||||
|
): 'INITIATOR' | 'DEALER' | 'APPROVER' | 'SPECTATOR' {
|
||||||
|
// Check if user is initiator
|
||||||
|
// Check if user is dealer (from participants or claimDetails)
|
||||||
|
// Check if user is approver (from approval levels)
|
||||||
|
// Check if user is spectator (from participants)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update ClaimManagementOverviewTab
|
||||||
|
|
||||||
|
**File**: `src/pages/RequestDetail/components/tabs/ClaimManagementOverviewTab.tsx`
|
||||||
|
|
||||||
|
**Changes Needed**:
|
||||||
|
- Import the new utility functions from `claimDataMapper.ts`
|
||||||
|
- Remove TODO comments
|
||||||
|
- Ensure it properly handles both old and new API response formats
|
||||||
|
|
||||||
|
### 4. Update API Service
|
||||||
|
|
||||||
|
**File**: `src/services/workflowApi.ts`
|
||||||
|
|
||||||
|
**Changes Needed**:
|
||||||
|
- Add `workflowType` field to `CreateWorkflowFromFormPayload` interface
|
||||||
|
- Include `workflowType: 'CLAIM_MANAGEMENT'` when creating claim management requests
|
||||||
|
- Update response types to include `workflowType` field
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
export interface CreateWorkflowFromFormPayload {
|
||||||
|
templateId?: string | null;
|
||||||
|
templateType: 'CUSTOM' | 'TEMPLATE';
|
||||||
|
workflowType?: string; // NEW: 'CLAIM_MANAGEMENT' | 'NON_TEMPLATIZED' | etc.
|
||||||
|
title: string;
|
||||||
|
// ... rest of fields
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Update ClaimManagementWizard
|
||||||
|
|
||||||
|
**File**: `src/components/workflow/ClaimManagementWizard/ClaimManagementWizard.tsx`
|
||||||
|
|
||||||
|
**Changes Needed**:
|
||||||
|
- When submitting, include `workflowType: 'CLAIM_MANAGEMENT'` in the payload
|
||||||
|
- Update to call the new backend API endpoint for creating claim requests
|
||||||
|
|
||||||
|
### 6. Update Request List Components
|
||||||
|
|
||||||
|
**Files**:
|
||||||
|
- `src/pages/MyRequests/components/RequestCard.tsx`
|
||||||
|
- `src/pages/Requests/components/RequestCard.tsx`
|
||||||
|
- `src/pages/OpenRequests/components/RequestCard.tsx`
|
||||||
|
|
||||||
|
**Changes Needed**:
|
||||||
|
- Display template/workflow type badge based on `workflowType` field
|
||||||
|
- Support both old (`templateType`) and new (`workflowType`) formats
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
```typescript
|
||||||
|
const workflowType = request.workflowType ||
|
||||||
|
(request.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED');
|
||||||
|
|
||||||
|
{workflowType === 'CLAIM_MANAGEMENT' && (
|
||||||
|
<Badge variant="secondary">Claim Management</Badge>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Create API Service for Claim Management
|
||||||
|
|
||||||
|
**File**: `src/services/dealerClaimApi.ts` (NEW FILE)
|
||||||
|
|
||||||
|
**Endpoints Needed**:
|
||||||
|
- `createClaimRequest(data)` - POST `/api/v1/dealer-claims`
|
||||||
|
- `getClaimDetails(requestId)` - GET `/api/v1/dealer-claims/:requestId`
|
||||||
|
- `submitProposal(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/proposal`
|
||||||
|
- `submitCompletion(requestId, data)` - POST `/api/v1/dealer-claims/:requestId/completion`
|
||||||
|
- `updateIODetails(requestId, data)` - PUT `/api/v1/dealer-claims/:requestId/io`
|
||||||
|
|
||||||
|
### 8. Update Type Definitions
|
||||||
|
|
||||||
|
**File**: `src/types/index.ts`
|
||||||
|
|
||||||
|
**Changes Needed**:
|
||||||
|
- Add `workflowType?: string` to request interfaces
|
||||||
|
- Update `ClaimFormData` interface to match backend structure
|
||||||
|
|
||||||
|
## 📋 Implementation Order
|
||||||
|
|
||||||
|
1. ✅ Create `claimRequestUtils.ts` (DONE)
|
||||||
|
2. ⏳ Create `claimDataMapper.ts` with mapping functions
|
||||||
|
3. ⏳ Update `RequestDetail.tsx` to conditionally render claim components
|
||||||
|
4. ⏳ Update `workflowApi.ts` to include `workflowType`
|
||||||
|
5. ⏳ Update `ClaimManagementWizard.tsx` to send `workflowType`
|
||||||
|
6. ⏳ Create `dealerClaimApi.ts` for claim-specific endpoints
|
||||||
|
7. ⏳ Update request card components to show workflow type
|
||||||
|
8. ⏳ Test end-to-end flow
|
||||||
|
|
||||||
|
## 🔄 Backward Compatibility
|
||||||
|
|
||||||
|
The frontend should support both:
|
||||||
|
- **Old Format**: `templateType: 'claim-management'`
|
||||||
|
- **New Format**: `workflowType: 'CLAIM_MANAGEMENT'`
|
||||||
|
|
||||||
|
The `isClaimManagementRequest()` utility function handles both formats automatically.
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- All existing claim management UI components are already created
|
||||||
|
- The main work is connecting them to the new backend API structure
|
||||||
|
- The `workflowType` field allows the system to support multiple template types in the future
|
||||||
|
- All requests (claim management, non-templatized, future templates) will appear in "My Requests" and "Open Requests" automatically
|
||||||
|
|
||||||
126
src/App.tsx
126
src/App.tsx
@ -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
|
||||||
|
|||||||
164
src/components/modals/ManagerSelectionModal.tsx
Normal file
164
src/components/modals/ManagerSelectionModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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 && (
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* DealerInformationCard Component
|
||||||
|
* Displays dealer details for Claim Management requests
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Building, Mail, Phone, MapPin } from 'lucide-react';
|
||||||
|
import { DealerInfo } from '../../types/claimManagement.types';
|
||||||
|
|
||||||
|
interface DealerInformationCardProps {
|
||||||
|
dealerInfo: DealerInfo;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DealerInformationCard({ dealerInfo, className }: DealerInformationCardProps) {
|
||||||
|
// Defensive check: Ensure dealerInfo exists
|
||||||
|
if (!dealerInfo) {
|
||||||
|
console.warn('[DealerInformationCard] dealerInfo is missing');
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardContent className="py-8 text-center text-gray-500">
|
||||||
|
<p>Dealer information not available</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if essential fields are present
|
||||||
|
if (!dealerInfo.dealerCode && !dealerInfo.dealerName) {
|
||||||
|
console.warn('[DealerInformationCard] Dealer info missing essential fields:', dealerInfo);
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardContent className="py-8 text-center text-gray-500">
|
||||||
|
<p>Dealer information incomplete</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
<Building className="w-5 h-5 text-purple-600" />
|
||||||
|
Dealer Information
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Dealer Code and Name */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Dealer Code
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-900 font-medium mt-1">
|
||||||
|
{dealerInfo.dealerCode}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Dealer Name
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-900 font-medium mt-1">
|
||||||
|
{dealerInfo.dealerName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Contact Information */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
Contact Information
|
||||||
|
</label>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{/* Email */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<Mail className="w-4 h-4 text-gray-400" />
|
||||||
|
<span>{dealerInfo.email}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-700">
|
||||||
|
<Phone className="w-4 h-4 text-gray-400" />
|
||||||
|
<span>{dealerInfo.phone}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address */}
|
||||||
|
<div className="flex items-start gap-2 text-sm text-gray-700">
|
||||||
|
<MapPin className="w-4 h-4 text-gray-400 mt-0.5" />
|
||||||
|
<span>{dealerInfo.address}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* RequestInitiatorCard Component
|
||||||
|
* Displays initiator/requester details - can be used for both claim management and regular workflows
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Mail, Phone } from 'lucide-react';
|
||||||
|
|
||||||
|
interface InitiatorInfo {
|
||||||
|
name: string;
|
||||||
|
role?: string;
|
||||||
|
department?: string;
|
||||||
|
email: string;
|
||||||
|
phone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestInitiatorCardProps {
|
||||||
|
initiatorInfo: InitiatorInfo;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RequestInitiatorCard({ initiatorInfo, className }: RequestInitiatorCardProps) {
|
||||||
|
// Generate initials from name
|
||||||
|
const getInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((n) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">Request Initiator</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<Avatar className="h-14 w-14 ring-2 ring-white shadow-md">
|
||||||
|
<AvatarFallback className="bg-gray-700 text-white font-semibold text-lg">
|
||||||
|
{getInitials(initiatorInfo.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-gray-900">{initiatorInfo.name}</h3>
|
||||||
|
{initiatorInfo.role && (
|
||||||
|
<p className="text-sm text-gray-600">{initiatorInfo.role}</p>
|
||||||
|
)}
|
||||||
|
{initiatorInfo.department && (
|
||||||
|
<p className="text-sm text-gray-500">{initiatorInfo.department}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{/* Email */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
<span>{initiatorInfo.email}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
{initiatorInfo.phone && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<Phone className="w-4 h-4" />
|
||||||
|
<span>{initiatorInfo.phone}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
12
src/pages/RequestDetail/components/claim-cards/index.ts
Normal file
12
src/pages/RequestDetail/components/claim-cards/index.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Claim Management Card Components
|
||||||
|
* Re-export all claim-specific card components for easy imports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { ActivityInformationCard } from './ActivityInformationCard';
|
||||||
|
export { DealerInformationCard } from './DealerInformationCard';
|
||||||
|
export { ProposalDetailsCard } from './ProposalDetailsCard';
|
||||||
|
export { ProcessDetailsCard } from './ProcessDetailsCard';
|
||||||
|
export { RequestInitiatorCard } from './RequestInitiatorCard';
|
||||||
|
|
||||||
|
|
||||||
270
src/pages/RequestDetail/components/modals/CreditNoteSAPModal.tsx
Normal file
270
src/pages/RequestDetail/components/modals/CreditNoteSAPModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* EditClaimAmountModal Component
|
||||||
|
* Modal for editing claim amount (restricted by role)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { DollarSign, AlertCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface EditClaimAmountModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentAmount: number;
|
||||||
|
onSubmit: (newAmount: number) => Promise<void>;
|
||||||
|
currency?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditClaimAmountModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
currentAmount,
|
||||||
|
onSubmit,
|
||||||
|
currency = '₹',
|
||||||
|
}: EditClaimAmountModalProps) {
|
||||||
|
const [amount, setAmount] = useState<string>(currentAmount.toString());
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `${currency}${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAmountChange = (value: string) => {
|
||||||
|
// Remove non-numeric characters except decimal point
|
||||||
|
const cleaned = value.replace(/[^\d.]/g, '');
|
||||||
|
|
||||||
|
// Ensure only one decimal point
|
||||||
|
const parts = cleaned.split('.');
|
||||||
|
if (parts.length > 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmount(cleaned);
|
||||||
|
setError('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validate amount
|
||||||
|
const numAmount = parseFloat(amount);
|
||||||
|
|
||||||
|
if (isNaN(numAmount) || numAmount <= 0) {
|
||||||
|
setError('Please enter a valid amount greater than 0');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (numAmount === currentAmount) {
|
||||||
|
toast.info('Amount is unchanged');
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await onSubmit(numAmount);
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update claim amount:', error);
|
||||||
|
setError('Failed to update claim amount. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!submitting) {
|
||||||
|
setAmount(currentAmount.toString());
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<DollarSign className="w-5 h-5 text-green-600" />
|
||||||
|
Edit Claim Amount
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update the claim amount. This will be recorded in the activity log.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Current Amount Display */}
|
||||||
|
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
|
||||||
|
<Label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1 block">
|
||||||
|
Current Claim Amount
|
||||||
|
</Label>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{formatCurrency(currentAmount)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Amount Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-amount" className="text-sm font-medium">
|
||||||
|
New Claim Amount <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 font-semibold">
|
||||||
|
{currency}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
id="new-amount"
|
||||||
|
type="text"
|
||||||
|
value={amount}
|
||||||
|
onChange={(e) => handleAmountChange(e.target.value)}
|
||||||
|
placeholder="Enter amount"
|
||||||
|
className="pl-8 text-lg font-semibold"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-red-600">
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount Difference */}
|
||||||
|
{amount && !isNaN(parseFloat(amount)) && parseFloat(amount) !== currentAmount && (
|
||||||
|
<div className="bg-blue-50 rounded-lg p-3 border border-blue-200">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-700">Difference:</span>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
parseFloat(amount) > currentAmount ? 'text-green-700' : 'text-red-700'
|
||||||
|
}`}>
|
||||||
|
{parseFloat(amount) > currentAmount ? '+' : ''}
|
||||||
|
{formatCurrency(parseFloat(amount) - currentAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Warning Message */}
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-xs text-amber-800">
|
||||||
|
<p className="font-semibold mb-1">Important:</p>
|
||||||
|
<ul className="space-y-1 list-disc list-inside">
|
||||||
|
<li>Ensure the new amount is verified and approved</li>
|
||||||
|
<li>This change will be logged in the activity trail</li>
|
||||||
|
<li>Budget blocking (IO) may need to be adjusted</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting || !amount || parseFloat(amount) <= 0}
|
||||||
|
className="bg-green-600 hover:bg-green-700"
|
||||||
|
>
|
||||||
|
{submitting ? 'Updating...' : 'Update Amount'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
1260
src/pages/RequestDetail/components/tabs/DealerClaimWorkflowTab.tsx
Normal file
1260
src/pages/RequestDetail/components/tabs/DealerClaimWorkflowTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
439
src/pages/RequestDetail/components/tabs/IOTab.tsx
Normal file
439
src/pages/RequestDetail/components/tabs/IOTab.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
31
src/pages/RequestDetail/types/claimManagement.types.ts
Normal file
31
src/pages/RequestDetail/types/claimManagement.types.ts
Normal 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
72
src/services/dealerApi.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Dealer API Service
|
||||||
|
* Handles API calls for dealer-related operations
|
||||||
|
*/
|
||||||
|
|
||||||
|
import apiClient from './authApi';
|
||||||
|
|
||||||
|
export interface DealerInfo {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
dealerCode: string;
|
||||||
|
dealerName: string;
|
||||||
|
displayName: string;
|
||||||
|
phone?: string;
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all dealers
|
||||||
|
*/
|
||||||
|
export async function getAllDealers(): Promise<DealerInfo[]> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/dealers');
|
||||||
|
return res.data?.data || res.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DealerAPI] Error fetching dealers:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer by code
|
||||||
|
*/
|
||||||
|
export async function getDealerByCode(dealerCode: string): Promise<DealerInfo | null> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/dealers/code/${dealerCode}`);
|
||||||
|
return res.data?.data || res.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DealerAPI] Error fetching dealer by code:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get dealer by email
|
||||||
|
*/
|
||||||
|
export async function getDealerByEmail(email: string): Promise<DealerInfo | null> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/dealers/email/${encodeURIComponent(email)}`);
|
||||||
|
return res.data?.data || res.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DealerAPI] Error fetching dealer by email:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search dealers
|
||||||
|
*/
|
||||||
|
export async function searchDealers(searchTerm: string): Promise<DealerInfo[]> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/dealers/search', {
|
||||||
|
params: { q: searchTerm },
|
||||||
|
});
|
||||||
|
return res.data?.data || res.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DealerAPI] Error searching dealers:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
282
src/services/dealerClaimApi.ts
Normal file
282
src/services/dealerClaimApi.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
387
src/utils/claimDataMapper.ts
Normal file
387
src/utils/claimDataMapper.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
57
src/utils/claimRequestUtils.ts
Normal file
57
src/utils/claimRequestUtils.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for identifying and handling Claim Management requests
|
||||||
|
* Works with both old format (templateType: 'claim-management') and new format (workflowType: 'CLAIM_MANAGEMENT')
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request is a Claim Management request
|
||||||
|
* Supports both old and new backend formats
|
||||||
|
*/
|
||||||
|
export function isClaimManagementRequest(request: any): boolean {
|
||||||
|
if (!request) return false;
|
||||||
|
|
||||||
|
// New format: Check workflowType
|
||||||
|
if (request.workflowType === 'CLAIM_MANAGEMENT') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old format: Check templateType (for backward compatibility)
|
||||||
|
if (request.templateType === 'claim-management' || request.template === 'claim-management') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check template name/code
|
||||||
|
if (request.templateName === 'Claim Management' || request.templateCode === 'CLAIM_MANAGEMENT') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workflow type from request
|
||||||
|
* Returns 'CLAIM_MANAGEMENT' for claim requests, 'NON_TEMPLATIZED' for others
|
||||||
|
*/
|
||||||
|
export function getWorkflowType(request: any): string {
|
||||||
|
if (!request) return 'NON_TEMPLATIZED';
|
||||||
|
|
||||||
|
// New format
|
||||||
|
if (request.workflowType) {
|
||||||
|
return request.workflowType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old format: Map templateType to workflowType
|
||||||
|
if (request.templateType === 'claim-management' || request.template === 'claim-management') {
|
||||||
|
return 'CLAIM_MANAGEMENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'NON_TEMPLATIZED';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if request needs claim-specific UI components
|
||||||
|
*/
|
||||||
|
export function shouldUseClaimManagementUI(request: any): boolean {
|
||||||
|
return isClaimManagementRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user