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 { toast } from 'sonner';
|
||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||
|
||||
// Combined Request Database for backward compatibility
|
||||
// This combines both custom and claim management requests
|
||||
export const REQUEST_DATABASE: any = {
|
||||
...CUSTOM_REQUEST_DATABASE,
|
||||
...CLAIM_MANAGEMENT_DATABASE
|
||||
};
|
||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
||||
|
||||
interface AppProps {
|
||||
onLogout?: () => void;
|
||||
@ -61,6 +55,20 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
const [dynamicRequests, setDynamicRequests] = useState<any[]>([]);
|
||||
const [selectedRequestId, setSelectedRequestId] = 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
|
||||
useEffect(() => {
|
||||
@ -265,7 +273,85 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
setApprovalAction(null);
|
||||
};
|
||||
|
||||
const handleClaimManagementSubmit = (claimData: any) => {
|
||||
const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
|
||||
try {
|
||||
// Prepare payload for API
|
||||
const payload = {
|
||||
activityName: claimData.activityName,
|
||||
activityType: claimData.activityType,
|
||||
dealerCode: claimData.dealerCode,
|
||||
dealerName: claimData.dealerName,
|
||||
dealerEmail: claimData.dealerEmail || undefined,
|
||||
dealerPhone: claimData.dealerPhone || undefined,
|
||||
dealerAddress: claimData.dealerAddress || undefined,
|
||||
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toISOString() : undefined,
|
||||
location: claimData.location,
|
||||
requestDescription: claimData.requestDescription,
|
||||
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
||||
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
||||
estimatedBudget: claimData.estimatedBudget || undefined,
|
||||
selectedManagerEmail: selectedManagerEmail || undefined,
|
||||
};
|
||||
|
||||
// Call API to create claim request
|
||||
const response = await createClaimRequest(payload);
|
||||
const createdRequest = response.request;
|
||||
|
||||
// Close manager modal if open
|
||||
setManagerModalOpen(false);
|
||||
setManagerModalData(null);
|
||||
|
||||
toast.success('Claim Request Submitted', {
|
||||
description: 'Your claim management request has been created successfully.',
|
||||
});
|
||||
|
||||
// Navigate to the created request detail page
|
||||
if (createdRequest?.requestId) {
|
||||
navigate(`/request/${createdRequest.requestId}`);
|
||||
} else {
|
||||
navigate('/my-requests');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[App] Error creating claim request:', error);
|
||||
|
||||
// Check for manager-related errors
|
||||
const errorData = error?.response?.data;
|
||||
const errorCode = errorData?.code || errorData?.error?.code;
|
||||
|
||||
if (errorCode === 'NO_MANAGER_FOUND') {
|
||||
// Show modal for no manager found
|
||||
setManagerModalData({
|
||||
errorType: 'NO_MANAGER_FOUND',
|
||||
message: errorData?.message || errorData?.error?.message || 'No reporting manager found. Please ensure your manager is correctly configured in the system.',
|
||||
pendingClaimData: claimData,
|
||||
});
|
||||
setManagerModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
|
||||
// Show modal with manager list for selection
|
||||
const managers = errorData?.managers || errorData?.error?.managers || [];
|
||||
setManagerModalData({
|
||||
errorType: 'MULTIPLE_MANAGERS_FOUND',
|
||||
managers: managers,
|
||||
message: errorData?.message || errorData?.error?.message || 'Multiple managers found. Please select one.',
|
||||
pendingClaimData: claimData,
|
||||
});
|
||||
setManagerModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Other errors - show toast
|
||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
|
||||
toast.error('Failed to Submit Claim Request', {
|
||||
description: errorMessage,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep the old code below for backward compatibility (local storage fallback)
|
||||
// This can be removed once API integration is fully tested
|
||||
/*
|
||||
// Generate unique ID for the new claim request
|
||||
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
||||
|
||||
@ -457,6 +543,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
description: 'Your claim management request has been created successfully.',
|
||||
});
|
||||
navigate('/my-requests');
|
||||
*/
|
||||
};
|
||||
|
||||
return (
|
||||
@ -674,6 +761,27 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Manager Selection Modal */}
|
||||
<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 */}
|
||||
{approvalAction && (
|
||||
<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 { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -25,7 +25,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { getAllDealers, getDealerInfo, formatDealerAddress } from '@/utils/dealerDatabase';
|
||||
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
||||
|
||||
interface ClaimManagementWizardProps {
|
||||
onBack?: () => void;
|
||||
@ -41,9 +41,6 @@ const CLAIM_TYPES = [
|
||||
'Service Campaign'
|
||||
];
|
||||
|
||||
// Fetch dealers from database
|
||||
const DEALERS = getAllDealers();
|
||||
|
||||
const STEP_NAMES = [
|
||||
'Claim Details',
|
||||
'Review & Submit'
|
||||
@ -51,6 +48,8 @@ const STEP_NAMES = [
|
||||
|
||||
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
||||
const [loadingDealers, setLoadingDealers] = useState(true);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
activityName: '',
|
||||
@ -70,6 +69,23 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
|
||||
const totalSteps = STEP_NAMES.length;
|
||||
|
||||
// Fetch dealers from API on component mount
|
||||
useEffect(() => {
|
||||
const fetchDealers = async () => {
|
||||
setLoadingDealers(true);
|
||||
try {
|
||||
const fetchedDealers = await fetchDealersFromAPI();
|
||||
setDealers(fetchedDealers);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load dealer list.');
|
||||
console.error('Error fetching dealers:', error);
|
||||
} finally {
|
||||
setLoadingDealers(false);
|
||||
}
|
||||
};
|
||||
fetchDealers();
|
||||
}, []);
|
||||
|
||||
const updateFormData = (field: string, value: any) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
@ -103,14 +119,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
}
|
||||
};
|
||||
|
||||
const handleDealerChange = (dealerCode: string) => {
|
||||
const dealer = getDealerInfo(dealerCode);
|
||||
if (dealer) {
|
||||
updateFormData('dealerCode', dealer.code);
|
||||
updateFormData('dealerName', dealer.name);
|
||||
updateFormData('dealerEmail', dealer.email);
|
||||
updateFormData('dealerPhone', dealer.phone);
|
||||
updateFormData('dealerAddress', formatDealerAddress(dealer));
|
||||
const handleDealerChange = async (dealerCode: string) => {
|
||||
const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
|
||||
if (selectedDealer) {
|
||||
updateFormData('dealerCode', dealerCode);
|
||||
updateFormData('dealerName', selectedDealer.dealerName);
|
||||
updateFormData('dealerEmail', selectedDealer.email || '');
|
||||
updateFormData('dealerPhone', selectedDealer.phone || '');
|
||||
updateFormData('dealerAddress', ''); // Address not available in API response
|
||||
|
||||
// Try to fetch full dealer info from API if available
|
||||
try {
|
||||
const fullDealerInfo = await getDealerByCode(dealerCode);
|
||||
if (fullDealerInfo) {
|
||||
updateFormData('dealerEmail', fullDealerInfo.email || selectedDealer.email || '');
|
||||
updateFormData('dealerPhone', fullDealerInfo.phone || selectedDealer.phone || '');
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore error, use basic info from list
|
||||
console.debug('Could not fetch full dealer info:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -235,9 +263,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
{/* Dealer Selection */}
|
||||
<div>
|
||||
<Label htmlFor="dealer" className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
|
||||
<Select value={formData.dealerCode} onValueChange={handleDealerChange}>
|
||||
<Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
|
||||
<SelectTrigger className="mt-2 h-12">
|
||||
<SelectValue placeholder="Select dealer">
|
||||
<SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
|
||||
{formData.dealerCode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{formData.dealerCode}</span>
|
||||
@ -248,15 +276,21 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEALERS.map((dealer) => (
|
||||
<SelectItem key={dealer.code} value={dealer.code}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{dealer.code}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{dealer.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
{dealers.length === 0 && !loadingDealers ? (
|
||||
<div className="p-2 text-sm text-gray-500">No dealers available</div>
|
||||
) : (
|
||||
dealers
|
||||
.filter((dealer) => dealer.dealerCode && dealer.dealerCode.trim() !== '')
|
||||
.map((dealer) => (
|
||||
<SelectItem key={dealer.dealerCode} value={dealer.dealerCode}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span>{dealer.dealerName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.dealerCode && (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
||||
import apiClient from '@/services/authApi';
|
||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||
import { getSocket } from '@/utils/socket';
|
||||
@ -229,6 +230,87 @@ export function useRequestDetails(
|
||||
console.debug('Pause details not available:', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch: Get claim details if this is a claim management request
|
||||
*/
|
||||
let claimDetails = null;
|
||||
let proposalDetails = null;
|
||||
let completionDetails = null;
|
||||
let internalOrder = null;
|
||||
|
||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
||||
try {
|
||||
console.debug('[useRequestDetails] Fetching claim details for requestId:', wf.requestId);
|
||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
||||
console.debug('[useRequestDetails] Claim API response:', {
|
||||
status: claimResponse.status,
|
||||
hasData: !!claimResponse.data,
|
||||
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
|
||||
fullResponse: claimResponse.data,
|
||||
});
|
||||
|
||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
||||
console.debug('[useRequestDetails] Extracted claimData:', {
|
||||
hasClaimData: !!claimData,
|
||||
claimDataKeys: claimData ? Object.keys(claimData) : [],
|
||||
hasClaimDetails: !!(claimData?.claimDetails || claimData?.claim_details),
|
||||
hasProposalDetails: !!(claimData?.proposalDetails || claimData?.proposal_details),
|
||||
hasCompletionDetails: !!(claimData?.completionDetails || claimData?.completion_details),
|
||||
hasInternalOrder: !!(claimData?.internalOrder || claimData?.internal_order),
|
||||
});
|
||||
|
||||
if (claimData) {
|
||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
||||
// New normalized tables
|
||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
||||
const invoice = claimData.invoice || null;
|
||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
||||
|
||||
// Store new fields in claimDetails for backward compatibility and easy access
|
||||
if (claimDetails) {
|
||||
(claimDetails as any).budgetTracking = budgetTracking;
|
||||
(claimDetails as any).invoice = invoice;
|
||||
(claimDetails as any).creditNote = creditNote;
|
||||
(claimDetails as any).completionExpenses = completionExpenses;
|
||||
}
|
||||
|
||||
console.debug('[useRequestDetails] Extracted details:', {
|
||||
claimDetails: claimDetails ? {
|
||||
hasActivityName: !!(claimDetails.activityName || claimDetails.activity_name),
|
||||
hasActivityType: !!(claimDetails.activityType || claimDetails.activity_type),
|
||||
hasLocation: !!(claimDetails.location),
|
||||
activityName: claimDetails.activityName || claimDetails.activity_name,
|
||||
activityType: claimDetails.activityType || claimDetails.activity_type,
|
||||
location: claimDetails.location,
|
||||
allKeys: Object.keys(claimDetails),
|
||||
} : null,
|
||||
hasProposalDetails: !!proposalDetails,
|
||||
hasCompletionDetails: !!completionDetails,
|
||||
hasInternalOrder: !!internalOrder,
|
||||
hasBudgetTracking: !!budgetTracking,
|
||||
hasInvoice: !!invoice,
|
||||
hasCreditNote: !!creditNote,
|
||||
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
|
||||
});
|
||||
} else {
|
||||
console.warn('[useRequestDetails] No claimData found in response');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Claim details not available - request might not be fully initialized yet
|
||||
console.error('[useRequestDetails] Error fetching claim details:', {
|
||||
error: error?.message || error,
|
||||
status: error?.response?.status,
|
||||
statusText: error?.response?.statusText,
|
||||
responseData: error?.response?.data,
|
||||
requestId: wf.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build: Complete request object with all transformed data
|
||||
* This object is used throughout the UI
|
||||
@ -242,6 +324,7 @@ export function useRequestDetails(
|
||||
description: wf.description,
|
||||
status: statusMap(wf.status),
|
||||
priority: (wf.priority || '').toString().toLowerCase(),
|
||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||
approvalFlow,
|
||||
approvals, // Raw approvals for SLA calculations
|
||||
participants,
|
||||
@ -266,6 +349,16 @@ export function useRequestDetails(
|
||||
conclusionRemark: wf.conclusionRemark || null,
|
||||
closureDate: wf.closureDate || null,
|
||||
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
|
||||
// Claim management specific data
|
||||
claimDetails: claimDetails || null,
|
||||
proposalDetails: proposalDetails || null,
|
||||
completionDetails: completionDetails || null,
|
||||
internalOrder: internalOrder || null,
|
||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||
invoice: (claimDetails as any)?.invoice || null,
|
||||
creditNote: (claimDetails as any)?.creditNote || null,
|
||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
||||
};
|
||||
|
||||
setApiRequest(updatedRequest);
|
||||
@ -441,6 +534,49 @@ export function useRequestDetails(
|
||||
console.debug('Pause details not available:', error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch: Get claim details if this is a claim management request
|
||||
*/
|
||||
let claimDetails = null;
|
||||
let proposalDetails = null;
|
||||
let completionDetails = null;
|
||||
let internalOrder = null;
|
||||
|
||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
||||
try {
|
||||
console.debug('[useRequestDetails] Initial load - Fetching claim details for requestId:', wf.requestId);
|
||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
||||
console.debug('[useRequestDetails] Initial load - Claim API response:', {
|
||||
status: claimResponse.status,
|
||||
hasData: !!claimResponse.data,
|
||||
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
|
||||
});
|
||||
|
||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
||||
if (claimData) {
|
||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
||||
|
||||
console.debug('[useRequestDetails] Initial load - Extracted details:', {
|
||||
hasClaimDetails: !!claimDetails,
|
||||
claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [],
|
||||
hasProposalDetails: !!proposalDetails,
|
||||
hasCompletionDetails: !!completionDetails,
|
||||
hasInternalOrder: !!internalOrder,
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Claim details not available - request might not be fully initialized yet
|
||||
console.error('[useRequestDetails] Initial load - Error fetching claim details:', {
|
||||
error: error?.message || error,
|
||||
status: error?.response?.status,
|
||||
requestId: wf.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Build complete request object
|
||||
const mapped = {
|
||||
id: wf.requestNumber || wf.requestId,
|
||||
@ -449,6 +585,7 @@ export function useRequestDetails(
|
||||
description: wf.description,
|
||||
priority,
|
||||
status: statusMap(wf.status),
|
||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||
summary,
|
||||
initiator: {
|
||||
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||
@ -472,6 +609,11 @@ export function useRequestDetails(
|
||||
conclusionRemark: wf.conclusionRemark || null,
|
||||
closureDate: wf.closureDate || null,
|
||||
pauseInfo: pauseInfo || null,
|
||||
// Claim management specific data
|
||||
claimDetails: claimDetails || null,
|
||||
proposalDetails: proposalDetails || null,
|
||||
completionDetails: completionDetails || null,
|
||||
internalOrder: internalOrder || null,
|
||||
};
|
||||
|
||||
setApiRequest(mapped);
|
||||
|
||||
@ -26,6 +26,7 @@ import {
|
||||
ShieldX,
|
||||
RefreshCw,
|
||||
ArrowLeft,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@ -44,11 +45,15 @@ import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
||||
import { getSummaryDetails, getSummaryByRequestId, type SummaryDetails } from '@/services/summaryApi';
|
||||
import { toast } from 'sonner';
|
||||
import { OverviewTab } from './components/tabs/OverviewTab';
|
||||
import { ClaimManagementOverviewTab } from './components/tabs/ClaimManagementOverviewTab';
|
||||
import { WorkflowTab } from './components/tabs/WorkflowTab';
|
||||
import { DealerClaimWorkflowTab } from './components/tabs/DealerClaimWorkflowTab';
|
||||
import { DocumentsTab } from './components/tabs/DocumentsTab';
|
||||
import { ActivityTab } from './components/tabs/ActivityTab';
|
||||
import { WorkNotesTab } from './components/tabs/WorkNotesTab';
|
||||
import { SummaryTab } from './components/tabs/SummaryTab';
|
||||
import { IOTab } from './components/tabs/IOTab';
|
||||
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
||||
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
||||
import { RequestDetailModals } from './components/RequestDetailModals';
|
||||
import { RequestDetailProps } from './types/requestDetail.types';
|
||||
@ -130,6 +135,74 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
accessDenied,
|
||||
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
||||
|
||||
// Determine if user is initiator (from overview tab initiator info)
|
||||
const currentUserId = (user as any)?.userId || '';
|
||||
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
|
||||
const initiatorUserId = apiRequest?.initiator?.userId;
|
||||
const initiatorEmail = apiRequest?.initiator?.email?.toLowerCase();
|
||||
const isUserInitiator = apiRequest?.initiator && (
|
||||
(initiatorUserId && initiatorUserId === currentUserId) ||
|
||||
(initiatorEmail && initiatorEmail === currentUserEmail)
|
||||
);
|
||||
|
||||
// Determine if user is department lead (whoever is in step 3 / approval level 3)
|
||||
const approvalLevels = apiRequest?.approvalLevels || [];
|
||||
const step3Level = approvalLevels.find((level: any) =>
|
||||
(level.levelNumber || level.level_number) === 3
|
||||
);
|
||||
const deptLeadUserId = step3Level?.approverId || step3Level?.approver?.userId;
|
||||
const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver?.email || step3Level?.approverEmail || '').toLowerCase().trim();
|
||||
|
||||
// Check if user is department lead by userId or email (case-insensitive)
|
||||
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
|
||||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
|
||||
|
||||
// Get step 3 status (case-insensitive check)
|
||||
const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : '';
|
||||
const isStep3PendingOrInProgress = step3Status === 'PENDING' ||
|
||||
step3Status === 'IN_PROGRESS';
|
||||
|
||||
// Check if user is current approver for step 3 (can access IO tab when step is pending/in-progress)
|
||||
// Also check if currentLevel is 3 (workflow is at step 3)
|
||||
const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || 0;
|
||||
const isStep3CurrentLevel = currentLevel === 3;
|
||||
|
||||
const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && (
|
||||
(deptLeadUserId && deptLeadUserId === currentUserId) ||
|
||||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail)
|
||||
);
|
||||
|
||||
// Check if IO tab should be visible (for initiator and department lead in claim management requests)
|
||||
// Department lead can access IO tab when they are the current approver for step 3 (to fetch and block IO)
|
||||
const showIOTab = isClaimManagementRequest(apiRequest) &&
|
||||
(isUserInitiator || isDeptLead || isStep3CurrentApprover);
|
||||
|
||||
// Debug logging for troubleshooting
|
||||
console.debug('[RequestDetail] IO Tab visibility:', {
|
||||
isClaimManagement: isClaimManagementRequest(apiRequest),
|
||||
isUserInitiator,
|
||||
isDeptLead,
|
||||
isStep3CurrentApprover,
|
||||
currentUserId,
|
||||
currentUserEmail,
|
||||
initiatorUserId,
|
||||
initiatorEmail,
|
||||
currentLevel,
|
||||
isStep3CurrentLevel,
|
||||
step3Level: step3Level ? {
|
||||
levelNumber: step3Level.levelNumber || step3Level.level_number,
|
||||
approverId: step3Level.approverId || step3Level.approver?.userId,
|
||||
approverEmail: step3Level.approverEmail || step3Level.approver?.email,
|
||||
status: step3Level.status,
|
||||
statusUpper: step3Status,
|
||||
isPendingOrInProgress: isStep3PendingOrInProgress
|
||||
} : null,
|
||||
deptLeadUserId,
|
||||
deptLeadEmail,
|
||||
emailMatch: deptLeadEmail && currentUserEmail ? deptLeadEmail === currentUserEmail : false,
|
||||
showIOTab,
|
||||
});
|
||||
|
||||
const {
|
||||
mergedMessages,
|
||||
unreadWorkNotes,
|
||||
@ -430,6 +503,16 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Workflow</span>
|
||||
</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
|
||||
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"
|
||||
@ -470,24 +553,33 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
{/* Left Column: Tab content */}
|
||||
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
|
||||
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
|
||||
<OverviewTab
|
||||
request={request}
|
||||
isInitiator={isInitiator}
|
||||
needsClosure={needsClosure}
|
||||
conclusionRemark={conclusionRemark}
|
||||
setConclusionRemark={setConclusionRemark}
|
||||
conclusionLoading={conclusionLoading}
|
||||
conclusionSubmitting={conclusionSubmitting}
|
||||
aiGenerated={aiGenerated}
|
||||
handleGenerateConclusion={handleGenerateConclusion}
|
||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRetrigger={handleRetrigger}
|
||||
currentUserIsApprover={!!currentApprovalLevel}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
/>
|
||||
{isClaimManagementRequest(apiRequest) ? (
|
||||
<ClaimManagementOverviewTab
|
||||
request={request}
|
||||
apiRequest={apiRequest}
|
||||
currentUserId={(user as any)?.userId || ''}
|
||||
isInitiator={isInitiator}
|
||||
/>
|
||||
) : (
|
||||
<OverviewTab
|
||||
request={request}
|
||||
isInitiator={isInitiator}
|
||||
needsClosure={needsClosure}
|
||||
conclusionRemark={conclusionRemark}
|
||||
setConclusionRemark={setConclusionRemark}
|
||||
conclusionLoading={conclusionLoading}
|
||||
conclusionSubmitting={conclusionSubmitting}
|
||||
aiGenerated={aiGenerated}
|
||||
handleGenerateConclusion={handleGenerateConclusion}
|
||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRetrigger={handleRetrigger}
|
||||
currentUserIsApprover={!!currentApprovalLevel}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{isClosed && (
|
||||
@ -502,22 +594,49 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
)}
|
||||
|
||||
<TabsContent value="workflow" className="mt-0">
|
||||
<WorkflowTab
|
||||
request={request}
|
||||
user={user}
|
||||
isInitiator={isInitiator}
|
||||
onSkipApprover={(data) => {
|
||||
if (!data.levelId) {
|
||||
alert('Level ID not available');
|
||||
return;
|
||||
}
|
||||
setSkipApproverData(data);
|
||||
setShowSkipApproverModal(true);
|
||||
}}
|
||||
onRefresh={refreshDetails}
|
||||
/>
|
||||
{isClaimManagementRequest(apiRequest) ? (
|
||||
<DealerClaimWorkflowTab
|
||||
request={request}
|
||||
user={user}
|
||||
isInitiator={isInitiator}
|
||||
onSkipApprover={(data) => {
|
||||
if (!data.levelId) {
|
||||
alert('Level ID not available');
|
||||
return;
|
||||
}
|
||||
setSkipApproverData(data);
|
||||
setShowSkipApproverModal(true);
|
||||
}}
|
||||
onRefresh={refreshDetails}
|
||||
/>
|
||||
) : (
|
||||
<WorkflowTab
|
||||
request={request}
|
||||
user={user}
|
||||
isInitiator={isInitiator}
|
||||
onSkipApprover={(data) => {
|
||||
if (!data.levelId) {
|
||||
alert('Level ID not available');
|
||||
return;
|
||||
}
|
||||
setSkipApproverData(data);
|
||||
setShowSkipApproverModal(true);
|
||||
}}
|
||||
onRefresh={refreshDetails}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{showIOTab && (
|
||||
<TabsContent value="io" className="mt-0">
|
||||
<IOTab
|
||||
request={request}
|
||||
apiRequest={apiRequest}
|
||||
onRefresh={refreshDetails}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="documents" className="mt-0">
|
||||
<DocumentsTab
|
||||
request={request}
|
||||
@ -566,6 +685,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
apiRequest={apiRequest}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* Quick Actions Sidebar Component
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
@ -10,6 +10,9 @@ import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle }
|
||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import notificationApi, { type Notification } from '@/services/notificationApi';
|
||||
import { ProcessDetailsCard } from './claim-cards';
|
||||
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
||||
import { determineUserRole, getRoleBasedVisibility, mapToClaimManagementRequest } from '@/utils/claimDataMapper';
|
||||
|
||||
interface QuickActionsSidebarProps {
|
||||
request: any;
|
||||
@ -27,6 +30,8 @@ interface QuickActionsSidebarProps {
|
||||
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
||||
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
|
||||
currentUserId?: string; // Current user's ID (kept for backwards compatibility)
|
||||
apiRequest?: any;
|
||||
onEditClaimAmount?: () => void;
|
||||
}
|
||||
|
||||
export function QuickActionsSidebar({
|
||||
@ -43,6 +48,10 @@ export function QuickActionsSidebar({
|
||||
onRetrigger,
|
||||
summaryId,
|
||||
refreshTrigger,
|
||||
pausedByUserId: pausedByUserIdProp,
|
||||
currentUserId: currentUserIdProp,
|
||||
apiRequest,
|
||||
onEditClaimAmount,
|
||||
}: QuickActionsSidebarProps) {
|
||||
const { user } = useAuth();
|
||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||
@ -50,8 +59,8 @@ export function QuickActionsSidebar({
|
||||
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
||||
const isClosed = request?.status === 'closed';
|
||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||
const pausedByUserId = request?.pauseInfo?.pausedBy?.userId;
|
||||
const currentUserId = (user as any)?.userId || '';
|
||||
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
|
||||
const currentUserId = currentUserIdProp || (user as any)?.userId || '';
|
||||
|
||||
// Both approver AND initiator can pause (when not already paused and not closed)
|
||||
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
||||
@ -117,6 +126,16 @@ export function QuickActionsSidebar({
|
||||
fetchSharedRecipients();
|
||||
}, [isClosed, summaryId, isInitiator, refreshTrigger]);
|
||||
|
||||
// Claim details for sidebar (only for claim management requests)
|
||||
const claimSidebarData = useMemo(() => {
|
||||
if (!apiRequest || !isClaimManagementRequest(apiRequest)) return null;
|
||||
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
|
||||
if (!claimRequest) return null;
|
||||
const userRole = determineUserRole(apiRequest, currentUserId);
|
||||
const visibility = getRoleBasedVisibility(userRole);
|
||||
return { claimRequest, visibility };
|
||||
}, [apiRequest, currentUserId]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Quick Actions Card - Hide entire card for spectators and closed requests */}
|
||||
@ -339,6 +358,21 @@ export function QuickActionsSidebar({
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
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