after altering the tables rechecked the all steps working as expected

This commit is contained in:
laxmanhalaki 2025-12-11 20:22:46 +05:30
parent 69c7e99d18
commit a4f9962c38
10 changed files with 453 additions and 235 deletions

View File

@ -264,6 +264,19 @@ export function useRequestDetails(
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 ? {
@ -278,6 +291,10 @@ export function useRequestDetails(
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');
@ -337,6 +354,11 @@ export function useRequestDetails(
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);

View File

@ -26,6 +26,7 @@ import {
ShieldX,
RefreshCw,
ArrowLeft,
DollarSign,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@ -51,7 +52,9 @@ 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 { determineUserRole } from '@/utils/claimDataMapper';
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
import { RequestDetailModals } from './components/RequestDetailModals';
import { RequestDetailProps } from './types/requestDetail.types';
@ -133,6 +136,42 @@ 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 isDeptLead = deptLeadUserId && deptLeadUserId === currentUserId;
// Check if IO tab should be visible (for initiator and department lead in claim management requests)
const showIOTab = isClaimManagementRequest(apiRequest) &&
(isUserInitiator || isDeptLead);
// Debug logging for troubleshooting
console.debug('[RequestDetail] IO Tab visibility:', {
isClaimManagement: isClaimManagementRequest(apiRequest),
isUserInitiator,
isDeptLead,
currentUserId,
currentUserEmail,
initiatorUserId,
initiatorEmail,
step3Level: step3Level ? { levelNumber: step3Level.levelNumber || step3Level.level_number, approverId: step3Level.approverId || step3Level.approver?.userId } : null,
deptLeadUserId,
showIOTab,
});
const {
mergedMessages,
unreadWorkNotes,
@ -433,6 +472,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"
@ -547,6 +596,16 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
)}
</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}
@ -595,6 +654,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
apiRequest={apiRequest}
/>
)}
</div>

View File

@ -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,19 @@ 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={claimSidebarData.claimRequest.claimAmount}
estimatedBudgetBreakdown={claimSidebarData.claimRequest.proposalDetails?.costBreakup}
closedExpensesBreakdown={claimSidebarData.claimRequest.activityInfo?.closedExpensesBreakdown}
visibility={claimSidebarData.visibility}
onEditClaimAmount={onEditClaimAmount}
/>
)}
</div>
);
}

View File

@ -105,8 +105,8 @@ export function ActivityInformationCard({ activityInfo, className }: ActivityInf
</p>
</div>
{/* Closed Expenses */}
{activityInfo.closedExpenses !== undefined && (
{/* 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

View File

@ -8,15 +8,44 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Activity, Receipt, DollarSign, Pen } from 'lucide-react';
import {
IODetails,
DMSDetails,
ClaimAmountDetails,
CostBreakdownItem,
RoleBasedVisibility,
} from '../../types/claimManagement.types';
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;
@ -38,29 +67,34 @@ export function ProcessDetailsCard({
onEditClaimAmount,
className,
}: ProcessDetailsCardProps) {
const formatCurrency = (amount: number) => {
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) => {
const formatDate = (dateString?: string | null) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString;
return dateString || '';
}
};
const calculateTotal = (items: CostBreakdownItem[]) => {
return items.reduce((sum, item) => sum + item.amount, 0);
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) ||
estimatedBudgetBreakdown ||
closedExpensesBreakdown;
(visibility.showClaimAmount && claimAmount && claimAmount.amount !== undefined && claimAmount.amount !== null) ||
(estimatedBudgetBreakdown && estimatedBudgetBreakdown.length > 0) ||
(closedExpensesBreakdown && closedExpensesBreakdown.length > 0);
if (!hasContent) {
return null;

View File

@ -5,32 +5,50 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Receipt, Calendar } from 'lucide-react';
import { ProposalDetails } from '../../types/claimManagement.types';
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) {
const formatCurrency = (amount: number) => {
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) => {
const formatDate = (dateString?: string | null) => {
if (!dateString) return '';
try {
return format(new Date(dateString), 'MMM d, yyyy, h:mm a');
} catch {
return dateString;
return dateString || '';
}
};
const formatTimelineDate = (dateString: string) => {
const formatTimelineDate = (dateString?: string | null) => {
if (!dateString) return '-';
try {
return format(new Date(dateString), 'MMM d, yyyy');
} catch {
return dateString;
return dateString || '-';
}
};
@ -66,7 +84,7 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{proposalDetails.costBreakup.map((item, index) => (
{(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}

View File

@ -9,7 +9,6 @@ import {
ActivityInformationCard,
DealerInformationCard,
ProposalDetailsCard,
ProcessDetailsCard,
RequestInitiatorCard,
} from '../claim-cards';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
@ -70,6 +69,10 @@ export function ClaimManagementOverviewTab({
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
@ -82,50 +85,35 @@ export function ClaimManagementOverviewTab({
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.requestedBy?.name || apiRequest.createdByName || 'Unknown',
role: 'initiator',
department: apiRequest.requestedBy?.department || apiRequest.department || '',
email: apiRequest.requestedBy?.email || 'N/A',
phone: apiRequest.requestedBy?.phone || apiRequest.requestedBy?.mobile,
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={`grid grid-cols-1 lg:grid-cols-3 gap-6 ${className}`}>
{/* Left Column: Main Information Cards */}
<div className="lg:col-span-2 space-y-6">
{/* Activity Information - Always visible */}
<ActivityInformationCard activityInfo={claimRequest.activityInfo} />
<div className={`space-y-6 ${className}`}>
{/* Activity Information - Always visible */}
<ActivityInformationCard activityInfo={claimRequest.activityInfo} />
{/* Dealer Information - Visible based on role */}
{visibility.showDealerInfo && (
<DealerInformationCard dealerInfo={claimRequest.dealerInfo} />
)}
{/* Dealer Information - Always visible */}
<DealerInformationCard dealerInfo={claimRequest.dealerInfo} />
{/* Proposal Details - Only shown after dealer submits proposal */}
{visibility.showProposalDetails && claimRequest.proposalDetails && (
<ProposalDetailsCard proposalDetails={claimRequest.proposalDetails} />
)}
{/* Proposal Details - Only shown after dealer submits proposal */}
{visibility.showProposalDetails && claimRequest.proposalDetails && (
<ProposalDetailsCard proposalDetails={claimRequest.proposalDetails} />
)}
{/* Request Initiator */}
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
</div>
{/* Right Column: Process Details Sidebar */}
<div className="space-y-6">
<ProcessDetailsCard
ioDetails={claimRequest.ioDetails}
dmsDetails={claimRequest.dmsDetails}
claimAmount={claimRequest.claimAmount}
estimatedBudgetBreakdown={claimRequest.proposalDetails?.costBreakup}
closedExpensesBreakdown={claimRequest.activityInfo.closedExpensesBreakdown}
visibility={visibility}
onEditClaimAmount={onEditClaimAmount}
/>
</div>
{/* Request Initiator */}
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
</div>
);
}

View File

@ -1099,7 +1099,8 @@ export function DealerClaimWorkflowTab({
}
// Call API to push to DMS (this will auto-generate e-invoice)
// eInvoiceDate is required, so we pass current date
const today = new Date().toISOString().split('T')[0];
// Use slice to avoid undefined with strict index checks
const today = new Date().toISOString().slice(0, 10);
await updateEInvoice(requestId as string, {
eInvoiceDate: today,
});
@ -1213,9 +1214,26 @@ export function DealerClaimWorkflowTab({
toast.info('Send to dealer functionality will be implemented');
}}
creditNoteData={{
creditNoteNumber: (request as any)?.claimDetails?.creditNoteNumber || (request as any)?.claimDetails?.credit_note_number,
creditNoteDate: (request as any)?.claimDetails?.creditNoteDate || (request as any)?.claimDetails?.credit_note_date,
creditNoteAmount: (request as any)?.claimDetails?.creditNoteAmount || (request as any)?.claimDetails?.credit_note_amount,
creditNoteNumber: (request as any)?.creditNote?.creditNoteNumber ||
(request as any)?.creditNote?.credit_note_number ||
(request as any)?.claimDetails?.creditNote?.creditNoteNumber ||
(request as any)?.claimDetails?.creditNoteNumber ||
(request as any)?.claimDetails?.credit_note_number,
creditNoteDate: (request as any)?.creditNote?.creditNoteDate ||
(request as any)?.creditNote?.credit_note_date ||
(request as any)?.claimDetails?.creditNote?.creditNoteDate ||
(request as any)?.claimDetails?.creditNoteDate ||
(request as any)?.claimDetails?.credit_note_date,
creditNoteAmount: (request as any)?.creditNote?.creditNoteAmount ?
Number((request as any)?.creditNote?.creditNoteAmount) :
((request as any)?.creditNote?.credit_note_amount ?
Number((request as any)?.creditNote?.credit_note_amount) :
((request as any)?.claimDetails?.creditNote?.creditNoteAmount ?
Number((request as any)?.claimDetails?.creditNote?.creditNoteAmount) :
((request as any)?.claimDetails?.creditNoteAmount ?
Number((request as any)?.claimDetails?.creditNoteAmount) :
((request as any)?.claimDetails?.credit_note_amount ?
Number((request as any)?.claimDetails?.credit_note_amount) : undefined)))),
status: 'APPROVED',
}}
dealerInfo={{

View File

@ -15,16 +15,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { DollarSign, Download, CircleCheckBig, AlertCircle } from 'lucide-react';
import { toast } from 'sonner';
import { mockApi } from '@/services/mockApi';
// Helper to extract data from API response and handle errors
const handleApiResponse = <T>(response: any): T => {
if (!response.success) {
const errorMsg = response.error?.message || 'Operation failed';
throw new Error(errorMsg);
}
return response.data;
};
import { updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
interface IOTabProps {
request: any;
@ -43,41 +34,46 @@ interface IOBlockedDetails {
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const requestId = apiRequest?.requestId || request?.requestId;
const [ioNumber, setIoNumber] = useState(request?.ioNumber || '');
// 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 || '';
const [ioNumber, setIoNumber] = useState(existingIONumber);
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false);
// Load existing IO block from mock API
// Load existing IO block details from apiRequest
useEffect(() => {
if (requestId) {
mockApi.getIOBlock(requestId).then(response => {
try {
const ioBlock = handleApiResponse<any>(response);
if (ioBlock) {
setBlockedDetails({
ioNumber: ioBlock.ioNumber,
blockedAmount: ioBlock.blockedAmount,
availableBalance: ioBlock.availableBalance,
blockedDate: ioBlock.blockedDate,
sapDocumentNumber: ioBlock.sapDocumentNumber,
status: ioBlock.status,
});
setIoNumber(ioBlock.ioNumber);
}
} catch (error) {
// IO block not found is expected for new requests
console.debug('No IO block found for request:', requestId);
}
}).catch(error => {
console.warn('Error loading IO block:', error);
if (internalOrder && existingIONumber) {
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: Number(existingAvailableBalance) || 0,
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
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 (existingAvailableBalance > 0) {
setFetchedAmount(Number(existingAvailableBalance));
}
}
}, [requestId]);
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber]);
/**
* Fetch available budget from SAP
* Validates IO number and gets available balance
* Calls updateIODetails with blockedAmount=0 to validate without blocking
*/
const handleFetchAmount = async () => {
if (!ioNumber.trim()) {
@ -85,23 +81,44 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
setFetchingAmount(true);
try {
// TODO: Replace with actual SAP API integration
// const response = await fetch(`/api/sap/io/${ioNumber}/budget`);
// const data = await response.json();
// Validate IO number by calling updateIODetails with blockedAmount=0
// This validates the IO with SAP and returns available balance without blocking
// The backend will validate the IO number and return the availableBalance from SAP
await updateIODetails(requestId, {
ioNumber: ioNumber.trim(),
ioAvailableBalance: 0, // Will be fetched from SAP validation by backend
ioBlockedAmount: 0, // No blocking, just validation
ioRemainingBalance: 0, // Will be calculated by backend
});
// Mock API call - simulate SAP integration
await new Promise(resolve => setTimeout(resolve, 1500));
// Fetch updated claim details to get the validated IO data
const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
// Mock response
const mockAvailableAmount = 50000; // ₹50,000
setFetchedAmount(mockAvailableAmount);
toast.success('IO budget fetched successfully from SAP');
if (updatedInternalOrder) {
const availableBalance = Number(updatedInternalOrder.ioAvailableBalance || updatedInternalOrder.io_available_balance || 0);
if (availableBalance > 0) {
setFetchedAmount(availableBalance);
toast.success(`IO validated successfully. Available balance: ₹${availableBalance.toLocaleString('en-IN')}`);
} else {
toast.error('No available balance found for this IO number');
setFetchedAmount(null);
}
} else {
toast.error('Failed to fetch IO details after validation');
setFetchedAmount(null);
}
} catch (error: any) {
console.error('Failed to fetch IO budget:', error);
toast.error(error.message || 'Failed to fetch IO budget from SAP');
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);
@ -112,12 +129,23 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
* Block budget in SAP system
*/
const handleBlockBudget = async () => {
if (!ioNumber.trim() || !fetchedAmount) {
if (!ioNumber.trim() || fetchedAmount === null) {
toast.error('Please fetch IO amount first');
return;
}
const claimAmount = request?.claimAmount || request?.amount || 0;
if (!requestId) {
toast.error('Request ID not found');
return;
}
// Get claim amount from budget tracking or proposal details
const claimAmount = apiRequest?.budgetTracking?.proposalEstimatedBudget ||
apiRequest?.proposalDetails?.totalEstimatedBudget ||
apiRequest?.claimDetails?.estimatedBudget ||
request?.claimAmount ||
request?.amount ||
0;
if (claimAmount > fetchedAmount) {
toast.error('Claim amount exceeds available IO budget');
@ -126,76 +154,41 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
setBlockingBudget(true);
try {
// TODO: Replace with actual SAP API integration
// const response = await fetch(`/api/sap/io/block`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({
// ioNumber,
// amount: claimAmount,
// requestId: apiRequest?.requestId,
// }),
// });
// const data = await response.json();
// Call updateIODetails with blockedAmount to block budget in SAP
await updateIODetails(requestId, {
ioNumber: ioNumber.trim(),
ioAvailableBalance: fetchedAmount,
ioBlockedAmount: claimAmount,
ioRemainingBalance: fetchedAmount - claimAmount,
});
// Mock API call
await new Promise(resolve => setTimeout(resolve, 2000));
// Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
// Mock blocked details
const blocked: IOBlockedDetails = {
ioNumber,
blockedAmount: claimAmount,
availableBalance: fetchedAmount - claimAmount,
blockedDate: new Date().toISOString(),
sapDocumentNumber: `SAP-${Date.now()}`,
status: 'blocked',
};
if (updatedInternalOrder) {
const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || claimAmount),
availableBalance: Number(updatedInternalOrder.ioAvailableBalance || updatedInternalOrder.io_available_balance || fetchedAmount),
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
status: 'blocked',
};
// Save to mock API
if (requestId) {
try {
const ioBlockResponse = await mockApi.createIOBlock(requestId, {
id: `io-${Date.now()}`,
ioNumber: blocked.ioNumber,
blockedAmount: blocked.blockedAmount,
availableBalance: blocked.availableBalance,
blockedDate: blocked.blockedDate,
sapDocumentNumber: blocked.sapDocumentNumber,
status: blocked.status,
});
handleApiResponse(ioBlockResponse);
setBlockedDetails(blocked);
toast.success('IO budget blocked successfully in SAP');
// Update request with IO number
const updateResponse = await mockApi.updateRequest(requestId, {
ioNumber: blocked.ioNumber,
ioBlockedAmount: blocked.blockedAmount,
sapDocumentNumber: blocked.sapDocumentNumber,
});
handleApiResponse(updateResponse);
// Create activity log
const activityResponse = await mockApi.createActivity(requestId, {
id: `act-${Date.now()}`,
type: 'io_blocked',
action: 'IO Budget Blocked',
details: `IO ${ioNumber} budget of ₹${claimAmount.toLocaleString('en-IN')} blocked in SAP`,
user: 'System',
message: `IO budget blocked: ${blocked.sapDocumentNumber}`,
});
handleApiResponse(activityResponse);
} catch (error) {
console.error('Failed to save IO block to database:', error);
}
// Refresh request details
onRefresh?.();
} else {
toast.error('IO blocked but failed to fetch updated details');
onRefresh?.();
}
setBlockedDetails(blocked);
toast.success('IO budget blocked successfully in SAP');
// Refresh request details
onRefresh?.();
} catch (error: any) {
console.error('Failed to block IO budget:', error);
toast.error(error.message || 'Failed to block IO budget in SAP');
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to block IO budget in SAP';
toast.error(errorMessage);
} finally {
setBlockingBudget(false);
}
@ -203,60 +196,53 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
/**
* 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) return;
if (!blockedDetails || !requestId) {
toast.error('No blocked budget to release');
return;
}
if (!ioNumber.trim()) {
toast.error('IO number not found');
return;
}
try {
// TODO: Replace with actual SAP API integration
await new Promise(resolve => setTimeout(resolve, 1500));
// Update IO block in mock API
try {
const ioBlockResponse = await mockApi.getIOBlock(requestId);
const ioBlock = handleApiResponse<any>(ioBlockResponse);
if (ioBlock) {
const updateIOResponse = await mockApi.updateIOBlock(requestId, {
status: 'released',
releasedDate: new Date().toISOString(),
});
handleApiResponse(updateIOResponse);
// Update request
const updateRequestResponse = await mockApi.updateRequest(requestId, {
ioBlockedAmount: null,
sapDocumentNumber: null,
});
handleApiResponse(updateRequestResponse);
// Create activity log
const activityResponse = await mockApi.createActivity(requestId, {
id: `act-${Date.now()}`,
type: 'io_released',
action: 'IO Budget Released',
details: `IO ${blockedDetails.ioNumber} budget released`,
user: 'System',
message: 'IO budget released',
});
handleApiResponse(activityResponse);
}
} catch (error) {
console.error('Failed to update IO block in database:', error);
}
// 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);
toast.error(error.message || 'Failed to release IO budget');
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to release IO budget';
toast.error(errorMessage);
}
};
const claimAmount = request?.claimAmount || request?.amount || 0;
// Get claim amount for display purposes (from budget tracking or proposal details)
const claimAmount = apiRequest?.budgetTracking?.proposalEstimatedBudget ||
apiRequest?.proposalDetails?.totalEstimatedBudget ||
apiRequest?.claimDetails?.estimatedBudget ||
request?.claimAmount ||
request?.amount ||
0;
return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">

View File

@ -109,12 +109,22 @@ export function mapToClaimManagementRequest(
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,
});
@ -136,6 +146,37 @@ export function mapToClaimManagementRequest(
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,
@ -145,24 +186,34 @@ export function mapToClaimManagementRequest(
startDate: periodStartDate,
endDate: periodEndDate,
} : undefined,
estimatedBudget: claimDetails.estimatedBudget || claimDetails.estimated_budget,
closedExpenses: claimDetails.closedExpenses || claimDetails.closed_expenses,
closedExpensesBreakdown: completionDetails.closedExpenses ||
completionDetails.closed_expenses ||
completionDetails.closedExpensesBreakdown ||
[],
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 || '',
dealerName: claimDetails.dealerName || claimDetails.dealer_name || '',
email: claimDetails.dealerEmail || claimDetails.dealer_email || '',
phone: claimDetails.dealerPhone || claimDetails.dealer_phone || '',
address: claimDetails.dealerAddress || claimDetails.dealer_address || '',
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 proposal = proposalDetails ? {
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
@ -186,14 +237,23 @@ export function mapToClaimManagementRequest(
organizedAt: internalOrder.organizedAt || internalOrder.organized_at || '',
};
// Map DMS details
// Map DMS details from new invoice and credit note tables
const dmsDetails = {
eInvoiceNumber: claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
eInvoiceDate: claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
dmsNumber: claimDetails.dmsNumber || claimDetails.dms_number,
creditNoteNumber: claimDetails.creditNoteNumber || claimDetails.credit_note_number,
creditNoteDate: claimDetails.creditNoteDate || claimDetails.credit_note_date,
creditNoteAmount: claimDetails.creditNoteAmount || claimDetails.credit_note_amount,
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