From a3a142d6039eb794838801c2b8f1487b40221756 Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Tue, 13 Jan 2026 19:19:26 +0530 Subject: [PATCH] multi level iteration partially implemented --- .../components/request-detail/IOTab.tsx | 36 +- .../components/request-detail/WorkflowTab.tsx | 706 +++++++++++++++++- .../modals/DeptLeadIOApprovalModal.tsx | 2 +- .../modals/InitiatorActionModal.tsx | 215 ++++++ .../components/request-detail/modals/index.ts | 1 + src/services/dealerClaimApi.ts | 5 + src/services/workflowApi.ts | 28 +- 7 files changed, 946 insertions(+), 47 deletions(-) create mode 100644 src/dealer-claim/components/request-detail/modals/InitiatorActionModal.tsx diff --git a/src/dealer-claim/components/request-detail/IOTab.tsx b/src/dealer-claim/components/request-detail/IOTab.tsx index f6532c3..0789d3a 100644 --- a/src/dealer-claim/components/request-detail/IOTab.tsx +++ b/src/dealer-claim/components/request-detail/IOTab.tsx @@ -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(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" /> + {estimatedBudget > 0 && ( +
+

+ Required: Amount must be exactly equal to the estimated budget: ₹{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+ )} {/* Block Button */} + + {expandedVersionSteps.has(step.step) && ( +
+ {/* Current Version */} + {step.versionHistory.current && ( +
+
+
+ + Current: v{step.versionHistory.current.version} + + + {formatDateSafe(step.versionHistory.current.createdAt)} + +
+
+

+ {step.versionHistory.current.changeReason || 'Version Update'} +

+
+
+ + {step.versionHistory.current.changer?.displayName?.charAt(0) || 'U'} + +
+ + By {step.versionHistory.current.changer?.displayName || step.versionHistory.current.changer?.email || 'Unknown User'} + +
+ {/* Show snapshot data if available - JSONB structure */} + {step.versionHistory.current.snapshotType === 'PROPOSAL' && step.versionHistory.current.snapshotData && ( +
+

Proposal Snapshot:

+ {step.versionHistory.current.snapshotData.documentUrl && ( +

+ + View Document + +

+ )} +

+ Budget: ₹{Number(step.versionHistory.current.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+ {step.versionHistory.current.snapshotData.comments && ( +

+ Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)} + {step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''} +

+ )} + {step.versionHistory.current.snapshotData.costItems && step.versionHistory.current.snapshotData.costItems.length > 0 && ( +

+ {step.versionHistory.current.snapshotData.costItems.length} cost item(s) +

+ )} +
+ )} + {step.versionHistory.current.snapshotType === 'INTERNAL_ORDER' && step.versionHistory.current.snapshotData && ( +
+

IO Block Snapshot:

+

+ IO Number: {step.versionHistory.current.snapshotData.ioNumber || 'N/A'} +

+

+ Blocked Amount: ₹{Number(step.versionHistory.current.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+ {step.versionHistory.current.snapshotData.sapDocumentNumber && ( +

+ SAP Doc: {step.versionHistory.current.snapshotData.sapDocumentNumber} +

+ )} +
+ )} + {step.versionHistory.current.snapshotType === 'COMPLETION' && step.versionHistory.current.snapshotData && ( +
+

Completion Snapshot:

+ {step.versionHistory.current.snapshotData.documentUrl && ( +

+ + View Document + +

+ )} +

+ Total Expenses: ₹{Number(step.versionHistory.current.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+ {step.versionHistory.current.snapshotData.comments && ( +

+ Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)} + {step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''} +

+ )} + {step.versionHistory.current.snapshotData.expenses && step.versionHistory.current.snapshotData.expenses.length > 0 && ( +

+ {step.versionHistory.current.snapshotData.expenses.length} expense item(s) +

+ )} +
+ )} + {step.versionHistory.current.snapshotType === 'APPROVE' && step.versionHistory.current.snapshotData && ( +
+

+ {step.versionHistory.current.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot: +

+

+ By: {step.versionHistory.current.snapshotData.approverName || step.versionHistory.current.snapshotData.approverEmail || 'Unknown'} +

+ {step.versionHistory.current.snapshotData.comments && ( +

+ Comments: {step.versionHistory.current.snapshotData.comments.substring(0, 100)} + {step.versionHistory.current.snapshotData.comments.length > 100 ? '...' : ''} +

+ )} + {step.versionHistory.current.snapshotData.rejectionReason && ( +

+ Rejection Reason: {step.versionHistory.current.snapshotData.rejectionReason.substring(0, 100)} + {step.versionHistory.current.snapshotData.rejectionReason.length > 100 ? '...' : ''} +

+ )} +
+ )} +
+ )} + + {/* Previous Version */} + {step.versionHistory.previous && ( +
+
+
+ + Previous: v{step.versionHistory.previous.version} + + + {formatDateSafe(step.versionHistory.previous.createdAt)} + +
+
+

+ {step.versionHistory.previous.changeReason || 'Version Update'} +

+
+
+ + {step.versionHistory.previous.changer?.displayName?.charAt(0) || 'U'} + +
+ + By {step.versionHistory.previous.changer?.displayName || step.versionHistory.previous.changer?.email || 'Unknown User'} + +
+ {/* Show snapshot data if available - JSONB structure */} + {step.versionHistory.previous.snapshotType === 'PROPOSAL' && step.versionHistory.previous.snapshotData && ( +
+

Proposal Snapshot:

+ {step.versionHistory.previous.snapshotData.documentUrl && ( +

+ + View Document + +

+ )} +

+ Budget: ₹{Number(step.versionHistory.previous.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+ {step.versionHistory.previous.snapshotData.comments && ( +

+ Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)} + {step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''} +

+ )} +
+ )} + {step.versionHistory.previous.snapshotType === 'INTERNAL_ORDER' && step.versionHistory.previous.snapshotData && ( +
+

IO Block Snapshot:

+

+ IO Number: {step.versionHistory.previous.snapshotData.ioNumber || 'N/A'} +

+

+ Blocked Amount: ₹{Number(step.versionHistory.previous.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+
+ )} + {step.versionHistory.previous.snapshotType === 'COMPLETION' && step.versionHistory.previous.snapshotData && ( +
+

Completion Snapshot:

+ {step.versionHistory.previous.snapshotData.documentUrl && ( +

+ + View Document + +

+ )} +

+ Total Expenses: ₹{Number(step.versionHistory.previous.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} +

+ {step.versionHistory.previous.snapshotData.comments && ( +

+ Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)} + {step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''} +

+ )} +
+ )} + {step.versionHistory.previous.snapshotType === 'APPROVE' && step.versionHistory.previous.snapshotData && ( +
+

+ {step.versionHistory.previous.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'} Snapshot: +

+

+ By: {step.versionHistory.previous.snapshotData.approverName || step.versionHistory.previous.snapshotData.approverEmail || 'Unknown'} +

+ {step.versionHistory.previous.snapshotData.comments && ( +

+ Comments: {step.versionHistory.previous.snapshotData.comments.substring(0, 100)} + {step.versionHistory.previous.snapshotData.comments.length > 100 ? '...' : ''} +

+ )} + {step.versionHistory.previous.snapshotData.rejectionReason && ( +

+ Rejection Reason: {step.versionHistory.previous.snapshotData.rejectionReason.substring(0, 100)} + {step.versionHistory.previous.snapshotData.rejectionReason.length > 100 ? '...' : ''} +

+ )} +
+ )} +
+ )} +
+ )} + + )} + {/* Active Approver - SLA Time Tracking (Only show for current active step) */} {isActive && approval?.sla && (
@@ -1494,6 +1930,56 @@ export function DealerClaimWorkflowTab({ )} + {/* 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 ( +
+ + +
+ ); + })()} + {/* 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} > - Approve and Organise IO + Review and Approve {!hasIONumber && (

@@ -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 && ( + + +

+ + + Revision History & Audit Trail + + +
+ + Records of all revisions and actions taken on this request + + + {showHistory && ( + +
+ {versionHistory.map((item, idx) => ( +
+
+
+
+
+ + Version {item.version} + + {item.snapshotType && ( + + {item.snapshotType} + + )} + {item.levelNumber && ( + + Step {item.levelNumber} + + )} +
+ + {formatDateSafe(item.createdAt)} + +
+

+ {item.changeReason || 'Version Update'} +

+
+
+ + {item.changer?.displayName?.charAt(0) || 'U'} + +
+ + By {item.changer?.displayName || item.changer?.email || 'Unknown User'} + +
+ {/* Show snapshot details based on type - JSONB structure */} + {item.snapshotType === 'PROPOSAL' && item.snapshotData && ( +
+

Proposal:

+ {item.snapshotData.documentUrl && ( +

+ + View Document + +

+ )} +

Budget: ₹{Number(item.snapshotData.totalBudget || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

+ {item.snapshotData.comments && ( +

Comments: {item.snapshotData.comments.substring(0, 80)}{item.snapshotData.comments.length > 80 ? '...' : ''}

+ )} +
+ )} + {item.snapshotType === 'COMPLETION' && item.snapshotData && ( +
+

Completion:

+ {item.snapshotData.documentUrl && ( +

+ + View Document + +

+ )} +

Total Expenses: ₹{Number(item.snapshotData.totalExpenses || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

+ {item.snapshotData.comments && ( +

Comments: {item.snapshotData.comments.substring(0, 80)}{item.snapshotData.comments.length > 80 ? '...' : ''}

+ )} +
+ )} + {item.snapshotType === 'INTERNAL_ORDER' && item.snapshotData && ( +
+

IO Block:

+

IO Number: {item.snapshotData.ioNumber || 'N/A'}

+

Blocked: ₹{Number(item.snapshotData.blockedAmount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

+ {item.snapshotData.sapDocumentNumber && ( +

SAP Doc: {item.snapshotData.sapDocumentNumber}

+ )} +
+ )} + {item.snapshotType === 'APPROVE' && item.snapshotData && ( +
+

+ {item.snapshotData.action === 'APPROVE' ? 'Approval' : 'Rejection'}: +

+

By: {item.snapshotData.approverName || item.snapshotData.approverEmail || 'Unknown'}

+ {item.snapshotData.levelName && ( +

Level: {item.snapshotData.levelName}

+ )} + {item.snapshotData.comments && ( +

Comments: {item.snapshotData.comments.substring(0, 80)}{item.snapshotData.comments.length > 80 ? '...' : ''}

+ )} + {item.snapshotData.rejectionReason && ( +

Rejection Reason: {item.snapshotData.rejectionReason.substring(0, 80)}{item.snapshotData.rejectionReason.length > 80 ? '...' : ''}

+ )} +
+ )} + {item.snapshotType === 'WORKFLOW' && item.snapshotData && ( +
+

Workflow:

+

Status: {item.snapshotData.status || 'N/A'}

+ {item.snapshotData.currentLevel && ( +

Current Level: {item.snapshotData.currentLevel}

+ )} +
+ )} +
+
+ ))} +
+ + )} + + )} ); } diff --git a/src/dealer-claim/components/request-detail/modals/DeptLeadIOApprovalModal.tsx b/src/dealer-claim/components/request-detail/modals/DeptLeadIOApprovalModal.tsx index adec9ad..1fd11b5 100644 --- a/src/dealer-claim/components/request-detail/modals/DeptLeadIOApprovalModal.tsx +++ b/src/dealer-claim/components/request-detail/modals/DeptLeadIOApprovalModal.tsx @@ -139,7 +139,7 @@ export function DeptLeadIOApprovalModal({
- Approve and Organise IO + Review and Approve Review IO details and provide your approval comments diff --git a/src/dealer-claim/components/request-detail/modals/InitiatorActionModal.tsx b/src/dealer-claim/components/request-detail/modals/InitiatorActionModal.tsx new file mode 100644 index 0000000..89d23ee --- /dev/null +++ b/src/dealer-claim/components/request-detail/modals/InitiatorActionModal.tsx @@ -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; + 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: , + color: 'blue', + variant: 'default' as const + }, + { + id: 'REVISE', + label: 'Request Revised Quotation', + description: 'Ask dealer to submit a new proposal/quotation.', + icon: , + color: 'amber', + variant: 'default' as const + }, + { + id: 'CANCEL', + label: 'Cancel Request', + description: 'Permanently close and cancel this request.', + icon: , + 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 ( + + + + Action Required: {requestTitle} + + This request has been returned to you. Please select how you would like to proceed. + + + +
+
+ {actions.map((action) => ( +
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'} + `} + > +
+
+ {action.icon} +
+

{action.label}

+
+

+ {action.description} +

+
+ ))} +
+ +
+

+ + Comments / Reason +

+