multi level iteration partially implemented

This commit is contained in:
laxmanhalaki 2026-01-13 19:19:26 +05:30
parent fc46f32282
commit a3a142d603
7 changed files with 946 additions and 47 deletions

View File

@ -47,6 +47,10 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
const organizer = internalOrder?.organizer || null;
// Get estimated budget from proposal details
const proposalDetails = apiRequest?.proposalDetails || {};
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
const [ioNumber, setIoNumber] = useState(existingIONumber);
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
@ -139,8 +143,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with available balance
setAmountToBlock(String(ioData.availableBalance));
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
if (estimatedBudget > 0) {
setAmountToBlock(String(estimatedBudget));
} else {
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');
@ -190,6 +198,15 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
toast.error('Amount to block exceeds available IO budget');
return;
}
// Validate that amount to block must exactly match estimated budget
if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`);
return;
}
}
// Blocking budget
@ -362,12 +379,25 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
className="pl-8"
/>
</div>
{estimatedBudget > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
<strong>Required:</strong> Amount must be exactly equal to the estimated budget: <strong>{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
</p>
</div>
)}
</div>
{/* Block Button */}
<Button
onClick={handleBlockBudget}
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
disabled={
blockingBudget ||
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Target className="w-4 h-4 mr-2" />

View File

@ -10,20 +10,23 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle } from 'lucide-react';
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw } from 'lucide-react';
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { AdditionalApproverReviewModal } from './modals';
import { DealerProposalSubmissionModal } from './modals';
import { InitiatorProposalApprovalModal } from './modals';
import { DeptLeadIOApprovalModal } from './modals';
import { DealerCompletionDocumentsModal } from './modals';
import { CreditNoteSAPModal } from './modals';
import { EmailNotificationTemplateModal } from './modals';
import { DMSPushModal } from './modals';
import {
AdditionalApproverReviewModal,
DealerProposalSubmissionModal,
InitiatorProposalApprovalModal,
DeptLeadIOApprovalModal,
DealerCompletionDocumentsModal,
CreditNoteSAPModal,
EmailNotificationTemplateModal,
DMSPushModal
// InitiatorActionModal - Removed, using direct buttons instead
} from './modals';
import { toast } from 'sonner';
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer } from '@/services/dealerClaimApi';
import { getWorkflowDetails, approveLevel, rejectLevel } from '@/services/workflowApi';
import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi';
import { uploadDocument } from '@/services/documentApi';
interface DealerClaimWorkflowTabProps {
@ -61,6 +64,10 @@ interface WorkflowStep {
};
einvoiceUrl?: string;
emailTemplateUrl?: string;
versionHistory?: {
current: any;
previous: any;
};
}
/**
@ -168,6 +175,10 @@ export function DealerClaimWorkflowTab({
const [selectedStepForEmail, setSelectedStepForEmail] = useState<{ stepNumber: number; stepName: string } | null>(null);
const [showAdditionalApproverReviewModal, setShowAdditionalApproverReviewModal] = useState(false);
const [selectedLevelForReview, setSelectedLevelForReview] = useState<{ levelId: string; levelName: string; approverName: string } | null>(null);
// Removed - Initiator actions are now direct buttons in step card, no modal needed
const [versionHistory, setVersionHistory] = useState<any[]>([]);
const [showHistory, setShowHistory] = useState(false);
const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set());
// Load approval flows from real API
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
@ -285,7 +296,23 @@ export function DealerClaimWorkflowTab({
// Small delay to ensure backend has fully processed the approval
await new Promise(resolve => setTimeout(resolve, 500));
onRefresh?.();
loadVersionHistory();
};
const loadVersionHistory = async () => {
if (request?.id || request?.requestId) {
try {
const history = await getWorkflowHistory(request.id || request.requestId);
setVersionHistory(history);
} catch (error) {
console.warn('Failed to load version history:', error);
}
}
};
useEffect(() => {
loadVersionHistory();
}, [request?.id, request?.requestId, refreshTrigger]);
// Step title and description mapping based on actual step number (not array index)
// This handles cases where approvers are added between steps
@ -394,6 +421,38 @@ export function DealerClaimWorkflowTab({
return `Step ${stepNumber} approval required.`;
};
// Helper function to get version history for a specific approval level
// Uses levelName to match history (consistent and doesn't change with step shifts or additional approvers)
const getStepVersionHistory = (levelName: string) => {
if (!versionHistory || versionHistory.length === 0 || !levelName) return { current: null, previous: null };
// Filter history by levelName (most reliable and consistent)
// Level names are consistent: "Dealer Proposal Submission", "Department Lead Approval", etc.
// Additional approvers have unique names like "Additional Approver - John Doe"
const stepVersions = versionHistory.filter((history: any) => {
// Primary match: by levelName (consistent identifier)
if (history.levelName && history.levelName.trim() === levelName.trim()) {
return true; // Include all snapshot types for this level
}
// Fallback: match by levelName from snapshotData (for approval snapshots)
if (history.snapshotData?.levelName && history.snapshotData.levelName.trim() === levelName.trim()) {
return true;
}
return false;
});
// Sort by version descending to get most recent first
const sortedVersions = [...stepVersions].sort((a, b) => b.version - a.version);
const current = sortedVersions.length > 0 ? sortedVersions[0] : null;
const previous = sortedVersions.length > 1 ? sortedVersions[1] : null;
return { current, previous };
};
// Get backend currentLevel to determine which steps are active vs waiting
// This needs to be calculated before mapping steps so we can use it in status normalization
// Convert to number to ensure proper comparison
@ -402,6 +461,23 @@ export function DealerClaimWorkflowTab({
? Number(backendCurrentLevel)
: null;
// Check request status - if rejected or closed, no steps should be active
const requestStatus = (request?.status || '').toUpperCase();
const isRequestRejected = requestStatus === 'REJECTED';
const isRequestClosed = requestStatus === 'CLOSED';
const isRequestActive = !isRequestRejected && !isRequestClosed &&
(requestStatus === 'PENDING' || requestStatus === 'IN_PROGRESS' || requestStatus === 'IN-PROGRESS');
// Find the rejected step level (if any) - all steps after rejection should be inactive
const rejectedStepLevel = approvalFlow.find((level: any) => {
const levelId = level.levelId || level.level_id;
const approval = request?.approvals?.find((a: any) => a.levelId === levelId);
return approval?.status?.toLowerCase() === 'rejected';
});
const rejectedStepNumber = rejectedStepLevel
? Number(rejectedStepLevel.levelNumber || rejectedStepLevel.level_number || rejectedStepLevel.step || 0)
: null;
// Transform approval flow to dealer claim workflow steps
const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => {
// Get actual step number from levelNumber or step field - ensure it's a number
@ -463,13 +539,44 @@ export function DealerClaimWorkflowTab({
}
}
// Normalize status - ALWAYS check step position first, then use approval/step status
// This ensures future steps are always 'waiting' regardless of approval/step status
// Normalize status - CRITICAL: Check request status first, then step position
// If request is rejected/closed, no steps should be active after rejection point
let normalizedStatus: string;
// First, check step position relative to currentLevel (this is the source of truth)
// Use currentLevelNumber which is guaranteed to be a number or null
if (currentLevelNumber !== null && currentLevelNumber > 0) {
// FIRST: Check if request is rejected/closed - if so, handle accordingly
if (isRequestRejected || isRequestClosed) {
// If this step was rejected, mark it as rejected
if (approval?.status?.toLowerCase() === 'rejected') {
normalizedStatus = 'rejected';
}
// If there's a rejected step and this step comes after it, mark as waiting (inactive)
else if (rejectedStepNumber !== null && actualStepNumber > rejectedStepNumber) {
normalizedStatus = 'waiting';
}
// If this step was approved before rejection, keep it as approved
else if (approval?.status?.toLowerCase() === 'approved') {
normalizedStatus = 'approved';
}
// For steps before rejection that weren't explicitly approved/rejected
else if (rejectedStepNumber !== null && actualStepNumber < rejectedStepNumber) {
// Check if step was approved
if (approval?.status?.toLowerCase() === 'approved') {
normalizedStatus = 'approved';
} else {
normalizedStatus = 'waiting';
}
}
// If no rejected step found but request is rejected, check current level
else if (currentLevelNumber !== null && actualStepNumber > currentLevelNumber) {
normalizedStatus = 'waiting';
}
// Default: mark as waiting (inactive) for rejected/closed requests
else {
normalizedStatus = 'waiting';
}
}
// SECOND: Request is active - check step position relative to currentLevel
else if (isRequestActive && currentLevelNumber !== null && currentLevelNumber > 0) {
if (actualStepNumber > currentLevelNumber) {
// Future step - MUST be waiting (ignore approval status and step.status)
normalizedStatus = 'waiting';
@ -511,8 +618,9 @@ export function DealerClaimWorkflowTab({
}
}
}
} else {
// No backend currentLevel - use approval status if available, otherwise step.status
}
// THIRD: No backend currentLevel or request status unclear - use approval status
else {
if (approval?.status) {
const approvalStatus = approval.status.toLowerCase();
if (approvalStatus === 'approved') {
@ -546,6 +654,13 @@ export function DealerClaimWorkflowTab({
const approverName = step.approver || step.approverName || 'Unknown';
// Get levelName for this step (consistent identifier, doesn't change with step shifts)
// Level names are consistent: "Dealer Proposal Submission", "Department Lead Approval", etc.
// Additional approvers have unique names like "Additional Approver - John Doe"
// Get version history for this step using levelName (consistent and reliable)
const stepVersionHistory = levelName ? getStepVersionHistory(levelName) : { current: null, previous: null };
return {
step: actualStepNumber,
title: getStepTitle(actualStepNumber, levelName, approverName),
@ -560,6 +675,7 @@ export function DealerClaimWorkflowTab({
dmsDetails,
einvoiceUrl: actualStepNumber === 7 ? (approval as any)?.einvoiceUrl : undefined,
emailTemplateUrl: (approval as any)?.emailTemplateUrl || undefined,
versionHistory: stepVersionHistory, // Add version history to step
};
});
@ -569,22 +685,42 @@ export function DealerClaimWorkflowTab({
// Note: Status normalization already handled in workflowSteps mapping above
// backendCurrentLevel is already calculated above before the map function
// Find the step that matches backend's currentLevel
const activeStepFromBackend = currentLevelNumber !== null
? workflowSteps.find(s => s.step === currentLevelNumber)
: null;
// CRITICAL: If request is rejected or closed, no step should be active
let activeStep = null;
let currentStep = 1;
// If backend currentLevel exists and step is pending/in_progress, use it
// Otherwise, find first pending/in_progress step
const activeStep = activeStepFromBackend &&
(activeStepFromBackend.status === 'pending' || activeStepFromBackend.status === 'in_progress')
? activeStepFromBackend
: workflowSteps.find(s => {
const status = s.status?.toLowerCase() || '';
return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review';
});
const currentStep = activeStep ? activeStep.step : (currentLevelNumber || request?.currentStep || 1);
if (isRequestRejected || isRequestClosed) {
// Request is rejected/closed - no active step
// Find the rejected step to show as the "current" step for display purposes
if (rejectedStepNumber !== null) {
currentStep = rejectedStepNumber;
} else if (currentLevelNumber !== null) {
currentStep = currentLevelNumber;
} else {
currentStep = request?.currentStep || 1;
}
} else if (isRequestActive) {
// Request is active - find the active step
// Find the step that matches backend's currentLevel
const activeStepFromBackend = currentLevelNumber !== null
? workflowSteps.find(s => s.step === currentLevelNumber)
: null;
// If backend currentLevel exists and step is pending/in_progress, use it
// Otherwise, find first pending/in_progress step
activeStep = activeStepFromBackend &&
(activeStepFromBackend.status === 'pending' || activeStepFromBackend.status === 'in_progress')
? activeStepFromBackend
: workflowSteps.find(s => {
const status = s.status?.toLowerCase() || '';
return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review';
});
currentStep = activeStep ? activeStep.step : (currentLevelNumber || request?.currentStep || 1);
} else {
// Request status unclear - use backend currentLevel or default
currentStep = currentLevelNumber || request?.currentStep || 1;
}
// Check if current user is the dealer (for steps 1 and 5)
const userEmail = (user as any)?.email?.toLowerCase() || '';
@ -888,6 +1024,7 @@ export function DealerClaimWorkflowTab({
totalClosedExpenses: data.totalClosedExpenses,
completionDocuments: data.completionDocuments,
activityPhotos: data.activityPhotos,
completionDescription: data.completionDescription,
});
// Upload supporting documents if provided
@ -1174,18 +1311,48 @@ export function DealerClaimWorkflowTab({
</div>
</CardHeader>
<CardContent>
{/* Returned to Initiator Banner */}
{(() => {
const isReturnedToInitiator = request?.status === 'rejected' &&
!(request?.closureDate || request?.closure_date) && isInitiator;
if (!isReturnedToInitiator) return null;
return (
<div className="mb-6 p-4 bg-amber-50 border-l-4 border-amber-500 rounded-lg flex items-start gap-4 shadow-sm">
<div className="p-2 bg-amber-100 rounded-full">
<AlertTriangle className="w-5 h-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="text-sm font-bold text-amber-900 mb-1">Action Required: Request Returned</h3>
<p className="text-xs text-amber-800 leading-relaxed mb-3">
This request has been returned to you by the department head.
{isInitiator ?
'You can choose to resubmit, discuss with the dealer, request a revision, or cancel the request.' :
'The initiator needs to take action to proceed.'}
</p>
{/* Actions are now in the step card itself - no need for separate button */}
</div>
</div>
);
})()}
<div className="space-y-4">
{workflowSteps.map((step, index) => {
// Step is active if:
// 1. It's pending or in_progress
// 2. AND it matches currentStep (from backend or calculated)
// 3. AND it's the actual current step (not a future step that happens to be pending)
// 1. Request is active (not rejected/closed)
// 2. It's pending or in_progress
// 3. AND it matches currentStep (from backend or calculated)
// 4. AND it's the actual current step (not a future step that happens to be pending)
const stepStatus = step.status?.toLowerCase() || '';
const isPendingOrInProgress = stepStatus === 'pending' || stepStatus === 'in_progress';
const isPendingOrInProgress = stepStatus === 'in_progress';
const matchesCurrentStep = step.step === currentStep;
// Step is active only if it matches the current step AND is pending/in_progress
const isActive = isPendingOrInProgress && matchesCurrentStep;
// Step is active only if:
// - Request is active (not rejected/closed)
// - AND it matches the current step
// - AND is pending/in_progress
const isActive = isRequestActive && isPendingOrInProgress && matchesCurrentStep;
const isCompleted = step.status === 'approved';
// Find approval data for this step to get SLA information
@ -1272,6 +1439,275 @@ export function DealerClaimWorkflowTab({
</div>
)}
{/* Version History Section */}
{step.versionHistory && (step.versionHistory.current || step.versionHistory.previous) && (
<div className="mt-3">
<Button
variant="ghost"
size="sm"
className="w-full justify-between text-xs text-amber-700 hover:text-amber-800 hover:bg-amber-50"
onClick={() => {
const newExpanded = new Set(expandedVersionSteps);
if (newExpanded.has(step.step)) {
newExpanded.delete(step.step);
} else {
newExpanded.add(step.step);
}
setExpandedVersionSteps(newExpanded);
}}
>
<div className="flex items-center gap-2">
<History className="w-3.5 h-3.5" />
<span className="font-medium">Version History</span>
{step.versionHistory.current && (
<Badge className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0">
v{step.versionHistory.current.version}
</Badge>
)}
{step.versionHistory.previous && (
<Badge className="bg-gray-100 text-gray-600 text-[10px] px-1.5 py-0">
v{step.versionHistory.previous.version}
</Badge>
)}
</div>
{expandedVersionSteps.has(step.step) ? (
<ChevronUp className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</Button>
{expandedVersionSteps.has(step.step) && (
<div className="mt-2 space-y-3 p-3 bg-amber-50/50 rounded-lg border border-amber-200">
{/* Current Version */}
{step.versionHistory.current && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge className="bg-amber-500 text-white text-[10px] px-2 py-0.5">
Current: v{step.versionHistory.current.version}
</Badge>
<span className="text-[10px] text-amber-700 font-medium">
{formatDateSafe(step.versionHistory.current.createdAt)}
</span>
</div>
</div>
<p className="text-xs text-gray-700 font-medium">
{step.versionHistory.current.changeReason || 'Version Update'}
</p>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-[8px] font-bold text-blue-600">
{step.versionHistory.current.changer?.displayName?.charAt(0) || 'U'}
</span>
</div>
<span className="text-[10px] text-gray-600">
By {step.versionHistory.current.changer?.displayName || step.versionHistory.current.changer?.email || 'Unknown User'}
</span>
</div>
{/* Show snapshot data if available - JSONB structure */}
{step.versionHistory.current.snapshotType === 'PROPOSAL' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Proposal Snapshot:</p>
{step.versionHistory.current.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.current.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600">
Budget: {Number(step.versionHistory.current.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.current.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)}
{step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.current.snapshotData.costItems && step.versionHistory.current.snapshotData.costItems.length > 0 && (
<p className="text-[10px] text-gray-500 mt-1">
{step.versionHistory.current.snapshotData.costItems.length} cost item(s)
</p>
)}
</div>
)}
{step.versionHistory.current.snapshotType === 'INTERNAL_ORDER' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">IO Block Snapshot:</p>
<p className="text-[10px] text-gray-600">
IO Number: {step.versionHistory.current.snapshotData.ioNumber || 'N/A'}
</p>
<p className="text-[10px] text-gray-600">
Blocked Amount: {Number(step.versionHistory.current.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.current.snapshotData.sapDocumentNumber && (
<p className="text-[10px] text-gray-600">
SAP Doc: {step.versionHistory.current.snapshotData.sapDocumentNumber}
</p>
)}
</div>
)}
{step.versionHistory.current.snapshotType === 'COMPLETION' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Completion Snapshot:</p>
{step.versionHistory.current.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.current.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600">
Total Expenses: {Number(step.versionHistory.current.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.current.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)}
{step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.current.snapshotData.expenses && step.versionHistory.current.snapshotData.expenses.length > 0 && (
<p className="text-[10px] text-gray-500 mt-1">
{step.versionHistory.current.snapshotData.expenses.length} expense item(s)
</p>
)}
</div>
)}
{step.versionHistory.current.snapshotType === 'APPROVE' && step.versionHistory.current.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">
{step.versionHistory.current.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot:
</p>
<p className="text-[10px] text-gray-600">
By: {step.versionHistory.current.snapshotData.approverName || step.versionHistory.current.snapshotData.approverEmail || 'Unknown'}
</p>
{step.versionHistory.current.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)}
{step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.current.snapshotData.rejectionReason && (
<p className="text-[10px] text-red-600 mt-1">
Rejection Reason: {step.versionHistory.current.snapshotData.rejectionReason.substring(0, 100)}
{step.versionHistory.current.snapshotData.rejectionReason.length > 100 ? '...' : ''}
</p>
)}
</div>
)}
</div>
)}
{/* Previous Version */}
{step.versionHistory.previous && (
<div className="space-y-2 pt-2 border-t border-amber-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge className="bg-gray-400 text-white text-[10px] px-2 py-0.5">
Previous: v{step.versionHistory.previous.version}
</Badge>
<span className="text-[10px] text-gray-600 font-medium">
{formatDateSafe(step.versionHistory.previous.createdAt)}
</span>
</div>
</div>
<p className="text-xs text-gray-700 font-medium">
{step.versionHistory.previous.changeReason || 'Version Update'}
</p>
<div className="flex items-center gap-1.5">
<div className="w-3 h-3 rounded-full bg-gray-100 flex items-center justify-center">
<span className="text-[8px] font-bold text-gray-600">
{step.versionHistory.previous.changer?.displayName?.charAt(0) || 'U'}
</span>
</div>
<span className="text-[10px] text-gray-600">
By {step.versionHistory.previous.changer?.displayName || step.versionHistory.previous.changer?.email || 'Unknown User'}
</span>
</div>
{/* Show snapshot data if available - JSONB structure */}
{step.versionHistory.previous.snapshotType === 'PROPOSAL' && step.versionHistory.previous.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Proposal Snapshot:</p>
{step.versionHistory.previous.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.previous.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600">
Budget: {Number(step.versionHistory.previous.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.previous.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)}
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
</div>
)}
{step.versionHistory.previous.snapshotType === 'INTERNAL_ORDER' && step.versionHistory.previous.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">IO Block Snapshot:</p>
<p className="text-[10px] text-gray-600">
IO Number: {step.versionHistory.previous.snapshotData.ioNumber || 'N/A'}
</p>
<p className="text-[10px] text-gray-600">
Blocked Amount: {Number(step.versionHistory.previous.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
)}
{step.versionHistory.previous.snapshotType === 'COMPLETION' && step.versionHistory.previous.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">Completion Snapshot:</p>
{step.versionHistory.previous.snapshotData.documentUrl && (
<p className="text-[10px] text-blue-600 mb-1">
<a href={step.versionHistory.previous.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-[10px] text-gray-600">
Total Expenses: {Number(step.versionHistory.previous.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
{step.versionHistory.previous.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)}
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
</div>
)}
{step.versionHistory.previous.snapshotType === 'APPROVE' && step.versionHistory.previous.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-gray-200">
<p className="text-[10px] font-semibold text-gray-700 mb-1">
{step.versionHistory.previous.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot:
</p>
<p className="text-[10px] text-gray-600">
By: {step.versionHistory.previous.snapshotData.approverName || step.versionHistory.previous.snapshotData.approverEmail || 'Unknown'}
</p>
{step.versionHistory.previous.snapshotData.comments && (
<p className="text-[10px] text-gray-600 mt-1">
Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)}
{step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''}
</p>
)}
{step.versionHistory.previous.snapshotData.rejectionReason && (
<p className="text-[10px] text-red-600 mt-1">
Rejection Reason: {step.versionHistory.previous.snapshotData.rejectionReason.substring(0, 100)}
{step.versionHistory.previous.snapshotData.rejectionReason.length > 100 ? '...' : ''}
</p>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{/* Active Approver - SLA Time Tracking (Only show for current active step) */}
{isActive && approval?.sla && (
<div className="mt-3 space-y-3">
@ -1494,6 +1930,56 @@ export function DealerClaimWorkflowTab({
</Button>
)}
{/* Initiator Action Step: Show action buttons (REVISE, REOPEN) - Direct actions, no modal */}
{(() => {
// Find the step level from approvalFlow
const stepLevelForInitiator = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
const stepLevelName = (stepLevelForInitiator?.levelName || step.title || '').toLowerCase();
const isInitiatorActionStep = stepLevelName.includes('initiator action');
const isUserInitiator = isInitiator || (userEmail === initiatorEmail);
if (!isInitiatorActionStep || !isUserInitiator) return null;
const handleDirectAction = async (action: 'REVISE' | 'REOPEN') => {
try {
if (!request?.id && !request?.requestId) {
throw new Error('Request ID not found');
}
const requestId = request.requestId || request.id;
// Call action directly without modal - comments are optional
await handleInitiatorAction(requestId, action, { reason: '' });
toast.success(`Action "${action === 'REVISE' ? 'Revision Requested' : 'Request Reopened'}" performed successfully`);
handleRefresh();
} catch (error: any) {
console.error('Failed to perform initiator action:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to perform action';
toast.error(errorMessage);
}
};
return (
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="border-orange-500 text-orange-600 hover:bg-orange-50"
onClick={() => handleDirectAction('REVISE')}
>
<RefreshCw className="w-4 h-4 mr-2" />
Request Re-quotation
</Button>
<Button
variant="outline"
className="border-blue-500 text-blue-600 hover:bg-blue-50"
onClick={() => handleDirectAction('REOPEN')}
>
<RotateCw className="w-4 h-4 mr-2" />
Reopen
</Button>
</div>
);
})()}
{/* Department Lead Step: Approve and Organise IO - Find dynamically by levelName */}
{(() => {
// Find Department Lead step dynamically (handles step shifts)
@ -1529,7 +2015,7 @@ export function DealerClaimWorkflowTab({
disabled={!hasIONumber}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve and Organise IO
Review and Approve
</Button>
{!hasIONumber && (
<p className="text-xs text-amber-600">
@ -1969,6 +2455,148 @@ export function DealerClaimWorkflowTab({
approverName={selectedLevelForReview.approverName}
/>
)}
{/* Initiator Action Modal - Removed, actions are now direct buttons in step card */}
{/* Version History Section */}
{versionHistory && versionHistory.length > 0 && (
<Card className="mt-6 border-amber-100 bg-amber-50/30">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2 text-amber-900">
<Activity className="w-4 h-4" />
Revision History & Audit Trail
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={() => setShowHistory(!showHistory)}
className="text-amber-700 hover:text-amber-800 hover:bg-amber-100"
>
{showHistory ? 'Hide History' : 'View History'}
</Button>
</div>
<CardDescription className="text-xs text-amber-700">
Records of all revisions and actions taken on this request
</CardDescription>
</CardHeader>
{showHistory && (
<CardContent>
<div className="space-y-4">
{versionHistory.map((item, idx) => (
<div key={item.historyId || idx} className="relative pl-6 pb-4 border-l-2 border-amber-200 last:border-0 last:pb-0">
<div className="absolute left-[-9px] top-0 w-4 h-4 rounded-full bg-amber-500 border-2 border-white shadow-sm" />
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-amber-900">
Version {item.version}
</span>
{item.snapshotType && (
<Badge className="bg-blue-100 text-blue-700 text-[9px] px-1.5 py-0">
{item.snapshotType}
</Badge>
)}
{item.levelNumber && (
<Badge className="bg-gray-100 text-gray-600 text-[9px] px-1.5 py-0">
Step {item.levelNumber}
</Badge>
)}
</div>
<span className="text-[10px] text-amber-600 font-medium bg-amber-100 px-2 py-0.5 rounded-full">
{formatDateSafe(item.createdAt)}
</span>
</div>
<p className="text-xs font-medium text-gray-800">
{item.changeReason || 'Version Update'}
</p>
<div className="flex items-center gap-1.5 mt-1">
<div className="w-4 h-4 rounded-full bg-blue-100 flex items-center justify-center">
<span className="text-[8px] font-bold text-blue-600">
{item.changer?.displayName?.charAt(0) || 'U'}
</span>
</div>
<span className="text-xs text-gray-500">
By {item.changer?.displayName || item.changer?.email || 'Unknown User'}
</span>
</div>
{/* Show snapshot details based on type - JSONB structure */}
{item.snapshotType === 'PROPOSAL' && item.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">Proposal:</p>
{item.snapshotData.documentUrl && (
<p className="text-blue-600 mb-1">
<a href={item.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-gray-600">Budget: {Number(item.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
{item.snapshotData.comments && (
<p className="text-gray-600 mt-1">Comments: {item.snapshotData.comments.substring(0, 80)}{item.snapshotData.comments.length > 80 ? '...' : ''}</p>
)}
</div>
)}
{item.snapshotType === 'COMPLETION' && item.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">Completion:</p>
{item.snapshotData.documentUrl && (
<p className="text-blue-600 mb-1">
<a href={item.snapshotData.documentUrl} target="_blank" rel="noopener noreferrer" className="underline">
View Document
</a>
</p>
)}
<p className="text-gray-600">Total Expenses: {Number(item.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
{item.snapshotData.comments && (
<p className="text-gray-600 mt-1">Comments: {item.snapshotData.comments.substring(0, 80)}{item.snapshotData.comments.length > 80 ? '...' : ''}</p>
)}
</div>
)}
{item.snapshotType === 'INTERNAL_ORDER' && item.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">IO Block:</p>
<p className="text-gray-600">IO Number: {item.snapshotData.ioNumber || 'N/A'}</p>
<p className="text-gray-600">Blocked: {Number(item.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
{item.snapshotData.sapDocumentNumber && (
<p className="text-gray-600">SAP Doc: {item.snapshotData.sapDocumentNumber}</p>
)}
</div>
)}
{item.snapshotType === 'APPROVE' && item.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">
{item.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'}:
</p>
<p className="text-gray-600">By: {item.snapshotData.approverName || item.snapshotData.approverEmail || 'Unknown'}</p>
{item.snapshotData.levelName && (
<p className="text-gray-600">Level: {item.snapshotData.levelName}</p>
)}
{item.snapshotData.comments && (
<p className="text-gray-600 mt-1">Comments: {item.snapshotData.comments.substring(0, 80)}{item.snapshotData.comments.length > 80 ? '...' : ''}</p>
)}
{item.snapshotData.rejectionReason && (
<p className="text-red-600 mt-1">Rejection Reason: {item.snapshotData.rejectionReason.substring(0, 80)}{item.snapshotData.rejectionReason.length > 80 ? '...' : ''}</p>
)}
</div>
)}
{item.snapshotType === 'WORKFLOW' && item.snapshotData && (
<div className="mt-2 p-2 bg-white rounded border border-amber-200 text-[10px]">
<p className="font-semibold text-gray-700 mb-1">Workflow:</p>
<p className="text-gray-600">Status: {item.snapshotData.status || 'N/A'}</p>
{item.snapshotData.currentLevel && (
<p className="text-gray-600">Current Level: {item.snapshotData.currentLevel}</p>
)}
</div>
)}
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
)}
</>
);
}

View File

@ -139,7 +139,7 @@ export function DeptLeadIOApprovalModal({
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-lg lg:text-xl">
Approve and Organise IO
Review and Approve
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm mt-1">
Review IO details and provide your approval comments

View File

@ -0,0 +1,215 @@
/**
* InitiatorActionModal Component
* Modal for Initiator to take action on a returned/rejected request
* Actions: Reopen, Request Revised Quotation, Cancel
*/
import { useState, useEffect } 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 {
RefreshCw,
MessageSquare,
FileEdit,
XOctagon,
AlertTriangle,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
interface InitiatorActionModalProps {
isOpen: boolean;
onClose: () => void;
onAction: (action: 'REOPEN' | 'REVISE' | 'CANCEL', comments: string) => Promise<void>;
requestTitle?: string;
requestId?: string;
defaultAction?: 'REOPEN' | 'REVISE' | 'CANCEL';
}
export function InitiatorActionModal({
isOpen,
onClose,
onAction,
requestTitle = 'Request',
requestId: _requestId,
defaultAction,
}: InitiatorActionModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [selectedAction, setSelectedAction] = useState<'REOPEN' | 'REVISE' | 'CANCEL' | null>(defaultAction || null);
// Update selectedAction when defaultAction changes
useEffect(() => {
if (defaultAction) {
setSelectedAction(defaultAction);
}
}, [defaultAction]);
const actions = [
{
id: 'REOPEN',
label: 'Reopen & Resubmit',
description: 'Resubmit the request to the department head for approval.',
icon: <RefreshCw className="w-5 h-5 text-blue-600" />,
color: 'blue',
variant: 'default' as const
},
{
id: 'REVISE',
label: 'Request Revised Quotation',
description: 'Ask dealer to submit a new proposal/quotation.',
icon: <FileEdit className="w-5 h-5 text-amber-600" />,
color: 'amber',
variant: 'default' as const
},
{
id: 'CANCEL',
label: 'Cancel Request',
description: 'Permanently close and cancel this request.',
icon: <XOctagon className="w-5 h-5 text-red-600" />,
color: 'red',
variant: 'destructive' as const
}
];
const handleActionClick = (actionId: any) => {
setSelectedAction(actionId);
};
const handleSubmit = async () => {
if (!selectedAction) {
toast.error('Please select an action');
return;
}
if (!comments.trim()) {
toast.error('Please provide a reason or comments for this action');
return;
}
try {
setSubmitting(true);
await onAction(selectedAction, comments);
handleReset();
onClose();
} catch (error: any) {
console.error('Failed to perform initiator action:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Action failed. Please try again.';
toast.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setComments('');
setSelectedAction(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
if (!isOpen) return null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-xl">Action Required: {requestTitle}</DialogTitle>
<DialogDescription>
This request has been returned to you. Please select how you would like to proceed.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{actions.map((action) => (
<div
key={action.id}
onClick={() => handleActionClick(action.id)}
className={`
cursor-pointer p-4 border-2 rounded-xl transition-all duration-200
${selectedAction === action.id
? `border-${action.color}-600 bg-${action.color}-50 shadow-sm`
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'}
`}
>
<div className="flex items-center gap-3 mb-2">
<div className={`p-2 rounded-lg bg-white border border-gray-100`}>
{action.icon}
</div>
<h4 className="font-bold text-sm text-gray-900">{action.label}</h4>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
{action.description}
</p>
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-gray-500" />
Comments / Reason
</h3>
<Textarea
placeholder="Provide a detailed reason for your decision..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[120px] text-sm resize-none"
/>
</div>
{selectedAction === 'CANCEL' && (
<div className="p-3 bg-red-50 border border-red-100 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="text-xs text-red-800">
<p className="font-bold mb-1">Warning: Irreversible Action</p>
<p>Cancelling this request will permanently close it. This action cannot be undone.</p>
</div>
</div>
)}
</div>
<DialogFooter className="border-t pt-4">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedAction || !comments.trim() || submitting}
className={`
min-w-[120px]
${selectedAction === 'CANCEL' ? 'bg-red-600 hover:bg-red-700' : 'bg-purple-600 hover:bg-purple-700'}
`}
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
'Confirm Action'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -14,3 +14,4 @@ export { DMSPushModal } from './DMSPushModal';
export { EditClaimAmountModal } from './EditClaimAmountModal';
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
export { InitiatorActionModal } from './InitiatorActionModal';

View File

@ -143,6 +143,7 @@ export async function submitCompletion(
totalClosedExpenses?: number;
completionDocuments?: File[];
activityPhotos?: File[];
completionDescription?: string;
}
): Promise<any> {
try {
@ -162,6 +163,10 @@ export async function submitCompletion(
formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString());
}
if (completionData.completionDescription) {
formData.append('completionDescription', completionData.completionDescription);
}
if (completionData.completionDocuments) {
completionData.completionDocuments.forEach((file) => {
formData.append('completionDocuments', file);

View File

@ -504,6 +504,7 @@ export default {
getWorkflowDetails,
getWorkNotes,
createWorkNoteMultipart,
handleInitiatorAction,
};
export async function submitWorkflow(requestId: string) {
@ -582,7 +583,26 @@ export async function updateBreachReason(levelId: string, breachReason: string):
}
}
// Also export in default for convenience
// Note: keeping separate named export above for tree-shaking
/**
* Handle initiator action on returned (rejected without closure) requests
* POST /api/v1/workflows/:id/initiator-action
*/
export async function handleInitiatorAction(
requestId: string,
action: 'REOPEN' | 'DISCUSS' | 'REVISE' | 'CANCEL',
data?: any
): Promise<any> {
const response = await apiClient.post(`/workflows/${requestId}/initiator-action`, {
action,
...data
});
return response.data?.data || response.data;
}
/**
* Get version history for a workflow request
* GET /api/v1/workflows/:id/history
*/
export async function getWorkflowHistory(requestId: string): Promise<any[]> {
const response = await apiClient.get(`/workflows/${requestId}/history`);
return response.data?.data || [];
}