Compare commits

..

No commits in common. "08374f9b049621a9f81c101afc750323c7ba7662" and "bbae59e271c508d49078718d84cdfe5610a5828a" have entirely different histories.

16 changed files with 356 additions and 2127 deletions

View File

@ -275,7 +275,7 @@ function AppRoutes({ onLogout }: AppProps) {
setApprovalAction(null); setApprovalAction(null);
}; };
const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => { const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
try { try {
// Prepare payload for API // Prepare payload for API
const payload = { const payload = {
@ -292,7 +292,7 @@ function AppRoutes({ onLogout }: AppProps) {
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined, periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined, periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
estimatedBudget: claimData.estimatedBudget || undefined, estimatedBudget: claimData.estimatedBudget || undefined,
approvers: claimData.approvers || [], // Pass approvers array selectedManagerEmail: selectedManagerEmail || undefined,
}; };
// Call API to create claim request // Call API to create claim request

View File

@ -6,8 +6,7 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
export interface SLAData { export interface SLAData {
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached'; status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
percentageUsed?: number; percentageUsed: number;
percent?: number; // Simplified format (alternative to percentageUsed)
elapsedText: string; elapsedText: string;
elapsedHours: number; elapsedHours: number;
remainingText: string; remainingText: string;
@ -31,7 +30,7 @@ export function SLAProgressBar({
// Pure presentational component - no business logic // Pure presentational component - no business logic
// If request is closed/approved/rejected or no SLA data, show status message // If request is closed/approved/rejected or no SLA data, show status message
// Check if SLA has required fields (percentageUsed or at least some data) // Check if SLA has required fields (percentageUsed or at least some data)
const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.elapsedHours !== undefined); const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.percent !== undefined || sla.elapsedHours !== undefined);
if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') { if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
return ( return (
@ -52,7 +51,8 @@ export function SLAProgressBar({
// Use percentage-based colors to match approver SLA tracker // Use percentage-based colors to match approver SLA tracker
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached) // Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
// Grey: When paused (frozen state) // Grey: When paused (frozen state)
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0; // Handle both full format (percentageUsed) and simplified format (percent)
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : (sla.percent || 0);
const rawStatus = sla.status || 'on_track'; const rawStatus = sla.status || 'on_track';
// Determine colors based on percentage (matching ApprovalStepCard logic) // Determine colors based on percentage (matching ApprovalStepCard logic)

View File

@ -1,573 +0,0 @@
/**
* ClaimApproverSelectionStep Component
* Step 2: Manual approver selection for all 8 steps in dealer claim workflow
* Similar to ApprovalWorkflowStep but fixed to 8 steps with predefined step names
*/
import { motion } from 'framer-motion';
import { useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Users, Shield, CheckCircle, Info, Clock, User } from 'lucide-react';
import { useMultiUserSearch } from '@/hooks/useUserSearch';
import { ensureUserExists } from '@/services/userApi';
import { toast } from 'sonner';
// Fixed 8-step workflow for dealer claims
const CLAIM_STEPS = [
{ level: 1, name: 'Dealer Proposal Submission', description: 'Dealer submits proposal documents', defaultTat: 72, isAuto: false, approverType: 'dealer' },
{ level: 2, name: 'Requestor Evaluation', description: 'Initiator evaluates dealer proposal', defaultTat: 48, isAuto: false, approverType: 'initiator' },
{ level: 3, name: 'Department Lead Approval', description: 'Department lead approves and blocks IO budget', defaultTat: 72, isAuto: false, approverType: 'manual' },
{ level: 4, name: 'Activity Creation', description: 'System auto-processes activity creation', defaultTat: 1, isAuto: true, approverType: 'system' },
{ level: 5, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' },
{ level: 6, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' },
{ level: 7, name: 'E-Invoice Generation', description: 'System generates e-invoice via DMS', defaultTat: 1, isAuto: true, approverType: 'system' },
{ level: 8, name: 'Credit Note Confirmation', description: 'System/Finance processes credit note confirmation', defaultTat: 48, isAuto: true, approverType: 'system' },
];
interface ClaimApprover {
email: string;
name?: string;
userId?: string;
level: number;
tat?: number | string;
tatType?: 'hours' | 'days';
}
interface ClaimApproverSelectionStepProps {
formData: {
dealerEmail?: string;
dealerName?: string;
approvers?: ClaimApprover[];
};
updateFormData: (field: string, value: any) => void;
onValidationError?: (error: { type: string; email: string; message: string }) => void;
currentUserEmail?: string;
currentUserId?: string;
currentUserName?: string;
onValidate?: (isValid: boolean) => void;
}
export function ClaimApproverSelectionStep({
formData,
updateFormData,
onValidationError,
currentUserEmail = '',
currentUserId = '',
currentUserName = '',
onValidate,
}: ClaimApproverSelectionStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
// Validation function to check for missing approvers
const validateApprovers = (): { isValid: boolean; missingSteps: string[] } => {
const approvers = formData.approvers || [];
const missingSteps: string[] = [];
CLAIM_STEPS.forEach((step) => {
// Skip auto steps (system steps) and pre-filled steps (dealer, initiator)
// Step 8 is now a system step, so it should be skipped from validation
if (step.isAuto || step.approverType === 'dealer' || step.approverType === 'initiator') {
return;
}
// For manual steps (3 and 8), check if approver is assigned, verified, and has TAT
const approver = approvers.find((a: ClaimApprover) => a.level === step.level);
if (!approver || !approver.email || !approver.userId || !approver.tat) {
missingSteps.push(`Step ${step.level}: ${step.name}`);
}
});
return {
isValid: missingSteps.length === 0,
missingSteps,
};
};
// Expose validation to parent component
useEffect(() => {
if (onValidate) {
const validation = validateApprovers();
onValidate(validation.isValid);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData.approvers]);
// Initialize approvers array for all 8 steps
useEffect(() => {
const currentApprovers = formData.approvers || [];
const newApprovers: ClaimApprover[] = [];
CLAIM_STEPS.forEach((step) => {
const existingApprover = currentApprovers.find((a: ClaimApprover) => a.level === step.level);
if (step.isAuto) {
// System steps - no approver needed
// Step 8 is System/Finance, use finance email
const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com';
const systemName = step.level === 8 ? 'System/Finance' : 'System';
newApprovers.push({
email: systemEmail,
name: systemName,
level: step.level,
tat: step.defaultTat,
tatType: 'hours',
});
} else if (step.approverType === 'dealer') {
// Dealer steps - use dealer email
newApprovers.push({
email: formData.dealerEmail || '',
name: formData.dealerName || '',
level: step.level,
tat: step.defaultTat,
tatType: 'hours',
});
} else if (step.approverType === 'initiator') {
// Initiator steps - use current user
newApprovers.push({
email: currentUserEmail || '',
name: currentUserName || currentUserEmail || 'User',
userId: currentUserId,
level: step.level,
tat: step.defaultTat,
tatType: 'hours',
});
} else {
// Manual steps - use existing or create empty
newApprovers.push(existingApprover || {
email: '',
name: '',
level: step.level,
tat: step.defaultTat,
tatType: 'hours',
});
}
});
// Only update if approvers array is empty or structure changed
if (currentApprovers.length === 0 || currentApprovers.length !== newApprovers.length) {
updateFormData('approvers', newApprovers);
}
}, [formData.dealerEmail, formData.dealerName, currentUserEmail, currentUserId, currentUserName]);
const handleApproverEmailChange = (level: number, value: string) => {
const approvers = [...(formData.approvers || [])];
const index = approvers.findIndex((a: ClaimApprover) => a.level === level);
if (index === -1) {
// Create new approver entry
const step = CLAIM_STEPS.find(s => s.level === level);
approvers.push({
email: value,
name: '',
level: level,
tat: step?.defaultTat || 48,
tatType: 'hours',
});
} else {
// Update existing approver
const existingApprover = approvers[index];
if (existingApprover) {
const previousEmail = existingApprover.email;
approvers[index] = {
...existingApprover,
email: value,
// Clear name and userId if email changed
name: value !== previousEmail ? '' : existingApprover.name,
userId: value !== previousEmail ? undefined : existingApprover.userId,
};
}
}
updateFormData('approvers', approvers);
if (!value || !value.startsWith('@') || value.length < 2) {
clearSearchForIndex(level - 1);
return;
}
searchUsersForIndex(level - 1, value, 10);
};
const handleUserSelect = async (level: number, selectedUser: any) => {
try {
// Check if user is trying to select themselves for non-initiator steps
const step = CLAIM_STEPS.find(s => s.level === level);
if (step && !step.isAuto && step.approverType !== 'initiator' && selectedUser.email?.toLowerCase() === currentUserEmail?.toLowerCase()) {
toast.error(`You cannot assign yourself as ${step.name} approver.`);
if (onValidationError) {
onValidationError({
type: 'self-assign',
email: selectedUser.email,
message: `You cannot assign yourself as ${step.name} approver.`
});
}
return;
}
// Check for duplicates across other steps
const approvers = formData.approvers || [];
const isDuplicate = approvers.some(
(a: ClaimApprover) =>
a.level !== level &&
(a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase())
);
if (isDuplicate) {
toast.error('This user is already assigned to another step.');
if (onValidationError) {
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'This user is already assigned to another step.'
});
}
return;
}
// Ensure user exists in database (create from Okta if needed)
const dbUser = await ensureUserExists({
userId: selectedUser.userId,
email: selectedUser.email,
displayName: selectedUser.displayName,
firstName: selectedUser.firstName,
lastName: selectedUser.lastName,
department: selectedUser.department,
phone: selectedUser.phone,
mobilePhone: selectedUser.mobilePhone,
designation: selectedUser.designation,
jobTitle: selectedUser.jobTitle,
manager: selectedUser.manager,
employeeId: selectedUser.employeeId,
employeeNumber: selectedUser.employeeNumber,
secondEmail: selectedUser.secondEmail,
location: selectedUser.location
});
// Update approver in array
const updatedApprovers = [...(formData.approvers || [])];
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) => a.level === level);
if (approverIndex === -1) {
const step = CLAIM_STEPS.find(s => s.level === level);
updatedApprovers.push({
email: selectedUser.email,
name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '),
userId: dbUser.userId,
level: level,
tat: step?.defaultTat || 48,
tatType: 'hours' as const,
});
} else {
const existingApprover = updatedApprovers[approverIndex];
if (existingApprover) {
updatedApprovers[approverIndex] = {
...existingApprover,
email: selectedUser.email,
name: selectedUser.displayName || [selectedUser.firstName, selectedUser.lastName].filter(Boolean).join(' '),
userId: dbUser.userId,
};
}
}
updateFormData('approvers', updatedApprovers);
clearSearchForIndex(level - 1);
toast.success(`Approver for ${CLAIM_STEPS.find(s => s.level === level)?.name} selected successfully.`);
} catch (err) {
console.error('Failed to ensure user exists:', err);
toast.error('Failed to validate user. Please try again.');
if (onValidationError) {
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'Failed to validate user. Please try again.'
});
}
}
};
const handleTatChange = (level: number, tat: number | string) => {
const approvers = [...(formData.approvers || [])];
const index = approvers.findIndex((a: ClaimApprover) => a.level === level);
if (index !== -1) {
const existingApprover = approvers[index];
if (existingApprover) {
approvers[index] = {
...existingApprover,
tat: tat,
};
updateFormData('approvers', approvers);
}
}
};
const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => {
const approvers = [...(formData.approvers || [])];
const index = approvers.findIndex((a: ClaimApprover) => a.level === level);
if (index !== -1) {
const existingApprover = approvers[index];
if (existingApprover) {
approvers[index] = {
...existingApprover,
tatType: tatType,
tat: '', // Clear TAT when changing type
};
updateFormData('approvers', approvers);
}
}
};
const approvers = formData.approvers || [];
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
className="space-y-6"
>
<div className="text-center mb-8">
<div className="w-16 h-16 bg-gradient-to-br from-purple-500 to-indigo-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
<Users className="w-8 h-8 text-white" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Approver Selection</h2>
<p className="text-gray-600">
Assign approvers for all 8 workflow steps with TAT (Turn Around Time)
</p>
</div>
<div className="max-w-4xl mx-auto space-y-6">
{/* Info Card */}
<Card className="border-2 border-blue-200 bg-blue-50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-900">
<Info className="w-5 h-5" />
Workflow Steps Information
</CardTitle>
<CardDescription className="text-blue-700">
Some steps are pre-filled (Dealer, Initiator, System). You need to assign approvers for Step 3 only. Step 8 is handled by System/Finance.
</CardDescription>
</CardHeader>
</Card>
{/* Approval Hierarchy */}
<Card className="border-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
Approval Hierarchy (8 Steps)
</CardTitle>
<CardDescription>
Define approvers and TAT for each step. Steps 1, 2, 4, 5, 6, 7, 8 are pre-filled. Only Step 3 requires manual assignment.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pt-4">
{/* Initiator Card */}
<div className="p-3 rounded-lg border-2 border-blue-200 bg-blue-50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-white" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold text-blue-900 text-sm">Request Initiator</span>
<Badge variant="secondary" className="text-xs">YOU</Badge>
</div>
<p className="text-xs text-blue-700">Creates and submits the request</p>
</div>
</div>
</div>
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */}
{CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => {
const approver = approvers.find((a: ClaimApprover) => a.level === step.level) || {
email: '',
name: '',
level: step.level,
tat: step.defaultTat,
tatType: 'hours' as const,
};
const isLast = index === filteredSteps.length - 1;
const isPreFilled = step.isAuto || step.approverType === 'dealer' || step.approverType === 'initiator';
const isEditable = !step.isAuto;
return (
<div key={step.level} className="space-y-1">
<div className="flex justify-center">
<div className="w-px h-3 bg-gray-300"></div>
</div>
<div className={`p-3 rounded-lg border-2 transition-all ${
approver.email && approver.userId
? 'border-green-200 bg-green-50'
: isPreFilled
? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
approver.email && approver.userId
? 'bg-green-600'
: isPreFilled
? 'bg-blue-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold text-sm">{step.level}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-semibold text-gray-900 text-sm">
{step.name}
</span>
{isLast && (
<Badge variant="destructive" className="text-xs">FINAL</Badge>
)}
{isPreFilled && (
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
)}
</div>
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
{isEditable && (
<div className="space-y-2">
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${step.level}`} className="text-xs font-medium">
Email Address {!isPreFilled && '*'}
</Label>
{approver.email && approver.userId && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
</Badge>
)}
</div>
<div className="relative">
<Input
id={`approver-${step.level}`}
type="text"
placeholder={isPreFilled ? approver.email : "@approver@royalenfield.com"}
value={approver.email || ''}
onChange={(e) => {
const newValue = e.target.value;
if (!isPreFilled) {
handleApproverEmailChange(step.level, newValue);
}
}}
disabled={isPreFilled || step.isAuto}
className="h-9 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full text-sm"
/>
{/* Search suggestions dropdown */}
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{userSearchLoading[step.level - 1] ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{userSearchResults[step.level - 1]?.map((u) => (
<li
key={u.userId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleUserSelect(step.level, u)}
>
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
<div className="text-xs text-gray-600">{u.email}</div>
{u.department && (
<div className="text-xs text-gray-500">{u.department}</div>
)}
</li>
))}
</ul>
)}
</div>
)}
</div>
{approver.name && (
<p className="text-xs text-green-600 mt-1">
Selected: <span className="font-semibold">{approver.name}</span>
</p>
)}
</div>
<div>
<Label htmlFor={`tat-${step.level}`} className="text-xs font-medium">
TAT (Turn Around Time) *
</Label>
<div className="flex items-center gap-2 mt-1">
<Input
id={`tat-${step.level}`}
type="number"
placeholder={approver.tatType === 'days' ? '7' : '24'}
min="1"
max={approver.tatType === 'days' ? '30' : '720'}
value={approver.tat || ''}
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
disabled={step.isAuto}
className="h-9 border-2 border-gray-300 focus:border-blue-500 flex-1 text-sm"
/>
<Select
value={approver.tatType || 'hours'}
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
disabled={step.isAuto}
>
<SelectTrigger className="w-20 h-9 border-2 border-gray-300 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</CardContent>
</Card>
{/* TAT Summary */}
<Card className="border-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5" />
TAT Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{approvers.map((approver: ClaimApprover) => {
const step = CLAIM_STEPS.find(s => s.level === approver.level);
if (!step || step.isAuto) return null;
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat;
if (!tat) return null;
return (
<div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">Step {approver.level}: {step.name}</span>
<span className="text-sm text-gray-600">{hours} hours</span>
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
</motion.div>
);
}

View File

@ -22,13 +22,10 @@ import {
CheckCircle, CheckCircle,
Info, Info,
FileText, FileText,
Users,
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi'; import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
import { useAuth } from '@/contexts/AuthContext';
interface ClaimManagementWizardProps { interface ClaimManagementWizardProps {
onBack?: () => void; onBack?: () => void;
@ -36,29 +33,20 @@ interface ClaimManagementWizardProps {
} }
const CLAIM_TYPES = [ const CLAIM_TYPES = [
'Riders Mania Claims', 'Marketing Activity',
'Marketing Cost Bike to Vendor', 'Promotional Event',
'Media Bike Service', 'Dealer Training',
'ARAI Motorcycle Liquidation', 'Infrastructure Development',
'ARAI Certification STA Approval CNR', 'Customer Experience Initiative',
'Procurement of Spares/Apparel/GMA for Events', 'Service Campaign'
'Fuel for Media Bike Used for Event',
'Motorcycle Buyback and Goodwill Support',
'Liquidation of Used Motorcycle',
'Motorcycle Registration CNR (Owned or Gifted by RE)',
'Legal Claims Reimbursement',
'Service Camp Claims',
'Corporate Claims Institutional Sales PDI'
]; ];
const STEP_NAMES = [ const STEP_NAMES = [
'Claim Details', 'Claim Details',
'Approver Selection',
'Review & Submit' 'Review & Submit'
]; ];
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) { export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [dealers, setDealers] = useState<DealerInfo[]>([]); const [dealers, setDealers] = useState<DealerInfo[]>([]);
const [loadingDealers, setLoadingDealers] = useState(true); const [loadingDealers, setLoadingDealers] = useState(true);
@ -76,16 +64,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
requestDescription: '', requestDescription: '',
periodStartDate: undefined as Date | undefined, periodStartDate: undefined as Date | undefined,
periodEndDate: undefined as Date | undefined, periodEndDate: undefined as Date | undefined,
estimatedBudget: '', estimatedBudget: ''
// Approvers array for all 8 steps
approvers: [] as Array<{
email: string;
name?: string;
userId?: string;
level: number;
tat?: number | string;
tatType?: 'hours' | 'days';
}>
}); });
const totalSteps = STEP_NAMES.length; const totalSteps = STEP_NAMES.length;
@ -108,28 +87,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}, []); }, []);
const updateFormData = (field: string, value: any) => { const updateFormData = (field: string, value: any) => {
setFormData(prev => { setFormData(prev => ({ ...prev, [field]: value }));
const updated = { ...prev, [field]: value };
// Validate period dates
if (field === 'periodStartDate') {
// If start date is selected and end date exists, validate end date
if (value && updated.periodEndDate && value > updated.periodEndDate) {
// Clear end date if it's before the new start date
updated.periodEndDate = undefined;
toast.error('End date must be on or after the start date. End date has been cleared.');
}
} else if (field === 'periodEndDate') {
// If end date is selected and start date exists, validate end date
if (value && updated.periodStartDate && value < updated.periodStartDate) {
toast.error('End date must be on or after the start date.');
// Don't update the end date if it's invalid
return prev;
}
}
return updated;
});
}; };
const isStepValid = () => { const isStepValid = () => {
@ -143,12 +101,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
formData.location && formData.location &&
formData.requestDescription; formData.requestDescription;
case 2: case 2:
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
const approvers = formData.approvers || [];
const step3Approver = approvers.find((a: any) => a.level === 3);
// Step 8 is now a system step, no validation needed
return step3Approver?.email && step3Approver?.userId && step3Approver?.tat;
case 3:
return true; return true;
default: default:
return false; return false;
@ -156,28 +108,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}; };
const nextStep = () => { const nextStep = () => {
if (currentStep < totalSteps) { if (currentStep < totalSteps && isStepValid()) {
if (!isStepValid()) {
// Show specific error messages for step 2 (approver selection)
if (currentStep === 2) {
const approvers = formData.approvers || [];
const step3Approver = approvers.find((a: any) => a.level === 3);
const missingSteps: string[] = [];
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
missingSteps.push('Step 3: Department Lead Approval');
}
if (missingSteps.length > 0) {
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
} else {
toast.error('Please complete all required approver selections (email, user verification, and TAT) before proceeding.');
}
} else {
toast.error('Please complete all required fields before proceeding.');
}
return;
}
setCurrentStep(currentStep + 1); setCurrentStep(currentStep + 1);
} }
}; };
@ -218,8 +149,57 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
status: 'pending', status: 'pending',
currentStep: 'initiator-review', currentStep: 'initiator-review',
// Pass approvers array to backend workflowSteps: [
approvers: formData.approvers || [] {
step: 1,
name: 'Initiator Evaluation',
status: 'pending',
approver: 'Current User (Initiator)',
description: 'Review and confirm all claim details and documents'
},
{
step: 2,
name: 'IO Confirmation',
status: 'waiting',
approver: 'System',
description: 'Automatic IO generation upon initiator approval'
},
{
step: 3,
name: 'Department Lead Approval',
status: 'waiting',
approver: 'Department Lead',
description: 'Budget blocking and final approval'
},
{
step: 4,
name: 'Document Submission',
status: 'waiting',
approver: 'Dealer',
description: 'Dealer submits completion documents'
},
{
step: 5,
name: 'Document Verification',
status: 'waiting',
approver: 'Initiator',
description: 'Verify completion documents'
},
{
step: 6,
name: 'E-Invoice Generation',
status: 'waiting',
approver: 'System',
description: 'Auto-generate e-invoice based on approved amount'
},
{
step: 7,
name: 'Credit Note Issuance',
status: 'waiting',
approver: 'Finance',
description: 'Issue credit note to dealer'
}
]
}; };
// Don't show toast here - let the parent component handle success/error after API call // Don't show toast here - let the parent component handle success/error after API call
@ -392,8 +372,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
selected={formData.periodStartDate} selected={formData.periodStartDate}
onSelect={(date) => updateFormData('periodStartDate', date)} onSelect={(date) => updateFormData('periodStartDate', date)}
initialFocus initialFocus
// Maximum date is the end date (if selected)
toDate={formData.periodEndDate || undefined}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
@ -406,7 +384,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start text-left mt-2 h-12" className="w-full justify-start text-left mt-2 h-12"
disabled={!formData.periodStartDate}
> >
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'} {formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
@ -418,30 +395,17 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
selected={formData.periodEndDate} selected={formData.periodEndDate}
onSelect={(date) => updateFormData('periodEndDate', date)} onSelect={(date) => updateFormData('periodEndDate', date)}
initialFocus initialFocus
// Minimum date is the start date (if selected)
fromDate={formData.periodStartDate || undefined}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{!formData.periodStartDate && (
<p className="text-xs text-gray-500 mt-1">Please select start date first</p>
)}
</div> </div>
</div> </div>
{(formData.periodStartDate || formData.periodEndDate) && ( {(formData.periodStartDate || formData.periodEndDate) && (
<div className="mt-2"> <p className="text-xs text-gray-600 mt-2">
{formData.periodStartDate && formData.periodEndDate ? ( {formData.periodStartDate && formData.periodEndDate
<p className="text-xs text-gray-600"> ? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}`
Period: {format(formData.periodStartDate, 'MMM dd, yyyy')} - {format(formData.periodEndDate, 'MMM dd, yyyy')} : 'Please select both start and end dates for the period'}
</p> </p>
) : (
<p className="text-xs text-gray-500">
{formData.periodStartDate
? 'Please select end date for the period'
: 'Please select start date first'}
</p>
)}
</div>
)} )}
</div> </div>
</div> </div>
@ -449,23 +413,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
); );
case 2: case 2:
return (
<ClaimApproverSelectionStep
formData={formData}
updateFormData={updateFormData}
currentUserEmail={(user as any)?.email || ''}
currentUserId={(user as any)?.userId || ''}
currentUserName={
(user as any)?.displayName ||
(user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (user as any)?.email || 'User')
}
/>
);
case 3:
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -542,55 +489,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</CardContent> </CardContent>
</Card> </Card>
{/* Approver Information */}
<Card className="border-2">
<CardHeader className="bg-gradient-to-br from-purple-50 to-indigo-50">
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-purple-600" />
Selected Approvers
</CardTitle>
</CardHeader>
<CardContent className="pt-6 space-y-4">
<div className="space-y-3">
{(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => {
const stepNames: Record<number, string> = {
1: 'Dealer Proposal Submission',
2: 'Requestor Evaluation',
3: 'Department Lead Approval',
4: 'Activity Creation',
5: 'Dealer Completion Documents',
6: 'Requestor Claim Approval',
7: 'E-Invoice Generation',
8: 'Credit Note Confirmation',
};
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat;
return (
<div key={approver.level} className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">
Step {approver.level}: {stepNames[approver.level]}
</Label>
<p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
{approver.email && (
<p className="text-xs text-gray-500 mt-1">{approver.email}</p>
)}
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">{hours} hours</p>
<p className="text-xs text-gray-500">TAT</p>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
{/* Date & Location */} {/* Date & Location */}
<Card className="border-2"> <Card className="border-2">
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50"> <CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
@ -758,11 +656,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? ( {currentStep < totalSteps ? (
<Button <Button
onClick={nextStep} onClick={nextStep}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${ disabled={!isStepValid()}
!isStepValid() className="gap-2 w-full sm:w-auto order-1 sm:order-2"
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
> >
Next Next
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />

View File

@ -10,7 +10,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react'; import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -31,7 +30,6 @@ interface IOBlockedDetails {
blockedDate: string; blockedDate: string;
blockedBy: string; // User who blocked blockedBy: string; // User who blocked
sapDocumentNumber: string; sapDocumentNumber: string;
ioRemark?: string; // IO remark
status: 'blocked' | 'released' | 'failed'; status: 'blocked' | 'released' | 'failed';
} }
@ -42,7 +40,6 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
// Load existing IO data from apiRequest or request // Load existing IO data from apiRequest or request
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null; const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || ''; const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
const existingIORemark = internalOrder?.ioRemark || internalOrder?.io_remark || '';
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0; const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0; const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0; const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
@ -51,19 +48,15 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const organizer = internalOrder?.organizer || null; const organizer = internalOrder?.organizer || null;
const [ioNumber, setIoNumber] = useState(existingIONumber); const [ioNumber, setIoNumber] = useState(existingIONumber);
const [ioRemark, setIoRemark] = useState(existingIORemark);
const [fetchingAmount, setFetchingAmount] = useState(false); const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null); const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>(''); const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null); const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false); const [blockingBudget, setBlockingBudget] = useState(false);
const maxIoRemarkChars = 300;
const ioRemarkChars = ioRemark.length;
// Load existing IO block details from apiRequest // Load existing IO block details from apiRequest
useEffect(() => { useEffect(() => {
if (internalOrder && existingIONumber) { if (internalOrder && existingIONumber && existingBlockedAmount > 0) {
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance); const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
// Get blocked by user name from organizer association (who blocked the amount) // Get blocked by user name from organizer association (who blocked the amount)
// When amount is blocked, organizedBy stores the user who blocked it // When amount is blocked, organizedBy stores the user who blocked it
@ -74,32 +67,25 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
organizer?.email || organizer?.email ||
'Unknown User'; 'Unknown User';
// Set IO number and remark from existing data setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
});
setIoNumber(existingIONumber); setIoNumber(existingIONumber);
setIoRemark(existingIORemark);
// Only set blocked details if amount is blocked // Set fetched amount if available balance exists
if (existingBlockedAmount > 0) { if (availableBeforeBlock > 0) {
setBlockedDetails({ setFetchedAmount(availableBeforeBlock);
ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber,
ioRemark: existingIORemark,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
});
// Set fetched amount if available balance exists
if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock);
}
} }
} }
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]); }, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
@ -142,43 +128,6 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
} }
}; };
/**
* Save IO details (IO number and remark) without blocking budget
*/
const handleSaveIODetails = async () => {
if (!ioNumber.trim()) {
toast.error('Please enter an IO number');
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
setBlockingBudget(true);
try {
// Save only IO number and remark (no balance fields)
const payload = {
ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
};
await updateIODetails(requestId, payload);
toast.success('IO details saved successfully');
// Refresh request details
onRefresh?.();
} catch (error: any) {
console.error('Failed to save IO details:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to save IO details';
toast.error(errorMessage);
} finally {
setBlockingBudget(false);
}
};
/** /**
* Block budget in SAP system * Block budget in SAP system
*/ */
@ -193,72 +142,34 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
return; return;
} }
const blockAmountRaw = parseFloat(amountToBlock); const blockAmount = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) { if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) {
toast.error('Please enter a valid amount to block'); toast.error('Please enter a valid amount to block');
return; return;
} }
// Round to 2 decimal places to avoid floating point precision issues
// This ensures we send clean values like 240.00 instead of 239.9999999
const blockAmount = Math.round(blockAmountRaw * 100) / 100;
if (blockAmount > fetchedAmount) { if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget'); toast.error('Amount to block exceeds available IO budget');
return; return;
} }
// Log the amount being sent to backend for debugging
console.log('[IOTab] Blocking budget:', {
ioNumber: ioNumber.trim(),
originalInput: amountToBlock,
parsedAmount: blockAmountRaw,
roundedAmount: blockAmount,
fetchedAmount,
calculatedRemaining: fetchedAmount - blockAmount,
});
setBlockingBudget(true); setBlockingBudget(true);
try { try {
// Call updateIODetails with blockedAmount to block budget in SAP and store in database // Call updateIODetails with blockedAmount to block budget in SAP and store in database
// This will store in internal_orders and claim_budget_tracking tables // This will store in internal_orders and claim_budget_tracking tables
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only await updateIODetails(requestId, {
const payload = {
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
ioAvailableBalance: fetchedAmount, ioAvailableBalance: fetchedAmount,
ioBlockedAmount: blockAmount, ioBlockedAmount: blockAmount,
ioRemainingBalance: fetchedAmount - blockAmount, // Calculated value (backend will use SAP's actual value) ioRemainingBalance: fetchedAmount - blockAmount,
}; });
console.log('[IOTab] Sending to backend:', payload);
await updateIODetails(requestId, payload);
// Fetch updated claim details to get the blocked IO data // Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId); const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) { if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount));
// Log what was saved vs what we sent
console.log('[IOTab] Blocking result:', {
sentAmount: blockAmount,
savedBlockedAmount,
sentRemaining: fetchedAmount - blockAmount,
savedRemainingBalance,
availableBalance: fetchedAmount,
difference: savedBlockedAmount - blockAmount,
});
// Warn if the saved amount differs from what we sent
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
}
const currentUser = user as any; const currentUser = user as any;
// When blocking, always use the current user who is performing the block action // When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount // The organizer association may be from initial IO organization, but we want who blocked the amount
@ -269,17 +180,14 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
currentUser?.email || currentUser?.email ||
'Current User'; 'Current User';
const savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
const blocked: IOBlockedDetails = { const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber, ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: savedBlockedAmount, blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
availableBalance: fetchedAmount, // Available amount before block availableBalance: fetchedAmount, // Available amount before block
remainingBalance: savedRemainingBalance, remainingBalance: Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount)),
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(), blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
ioRemark: savedIoRemark,
status: 'blocked', status: 'blocked',
}; };
@ -339,42 +247,6 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div> </div>
</div> </div>
{/* IO Remark Input */}
<div className="space-y-2">
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900">
IO Remark
</Label>
<Textarea
id="ioRemark"
placeholder="Enter remarks about IO organization"
value={ioRemark}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxIoRemarkChars) {
setIoRemark(value);
}
}}
rows={3}
disabled={!!blockedDetails}
className="bg-white text-sm min-h-[80px] resize-none"
/>
<div className="flex justify-end text-xs text-gray-600">
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
</div>
</div>
{/* Save IO Details Button (shown when IO number is entered but amount not fetched) */}
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
<Button
onClick={handleSaveIODetails}
disabled={blockingBudget || !ioNumber.trim()}
variant="outline"
className="w-full border-[#2d4a3e] text-[#2d4a3e] hover:bg-[#2d4a3e] hover:text-white"
>
{blockingBudget ? 'Saving...' : 'Save IO Details'}
</Button>
)}
{/* Fetched Amount Display */} {/* Fetched Amount Display */}
{fetchedAmount !== null && !blockedDetails && ( {fetchedAmount !== null && !blockedDetails && (
<> <>
@ -461,12 +333,6 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p> <p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
</div> </div>
{blockedDetails.ioRemark && (
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Remark</p>
<p className="text-sm font-medium text-gray-900 whitespace-pre-wrap">{blockedDetails.ioRemark}</p>
</div>
)}
<div className="p-4 bg-green-50"> <div className="p-4 bg-green-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<p className="text-xl font-bold text-green-700"> <p className="text-xl font-bold text-green-700">

View File

@ -172,11 +172,15 @@ export function DealerClaimWorkflowTab({
const [refreshTrigger, setRefreshTrigger] = useState(0); const [refreshTrigger, setRefreshTrigger] = useState(0);
// Reload approval flows whenever request changes or after refresh // Reload approval flows whenever request changes or after refresh
// Always fetch from API to ensure fresh data (don't rely on cached request.approvalFlow)
// Also watch for changes in totalLevels to detect when approvers are added
useEffect(() => { useEffect(() => {
const loadApprovalFlows = async () => { const loadApprovalFlows = async () => {
// Always load from real API to get the latest data // First check if request has approvalFlow
if (request?.approvalFlow && request.approvalFlow.length > 0) {
setApprovalFlow(request.approvalFlow);
return;
}
// Load from real API
if (request?.id || request?.requestId) { if (request?.id || request?.requestId) {
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
try { try {
@ -184,61 +188,29 @@ export function DealerClaimWorkflowTab({
const approvals = details?.approvalLevels || details?.approvals || []; const approvals = details?.approvalLevels || details?.approvals || [];
if (approvals && approvals.length > 0) { if (approvals && approvals.length > 0) {
// Transform approval levels to match expected format // Transform approval levels to match expected format
// Include levelName and levelNumber for proper mapping const flows = approvals.map((level: any) => ({
const flows = approvals step: level.levelNumber || level.level_number || 0,
.map((level: any) => ({ approver: level.approverName || level.approver_name || '',
step: level.levelNumber || level.level_number || 0, approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
levelNumber: level.levelNumber || level.level_number || 0, status: level.status?.toLowerCase() || 'waiting',
levelName: level.levelName || level.level_name, tatHours: level.tatHours || level.tat_hours || 24,
approver: level.approverName || level.approver_name || '', elapsedHours: level.elapsedHours || level.elapsed_hours,
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(), approvedAt: level.actionDate || level.action_date,
status: level.status?.toLowerCase() || 'waiting', comment: level.comments || level.comment,
tatHours: level.tatHours || level.tat_hours || 24, levelId: level.levelId || level.level_id,
elapsedHours: level.elapsedHours || level.elapsed_hours, }));
approvedAt: level.actionDate || level.action_date, setApprovalFlow(flows);
comment: level.comments || level.comment,
levelId: level.levelId || level.level_id,
}))
// Sort by levelNumber to ensure correct order (critical for proper display)
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0));
// Only update if the data actually changed (avoid unnecessary re-renders)
setApprovalFlow(prevFlows => {
// Check if flows are different
if (prevFlows.length !== flows.length) {
return flows;
}
// Check if any levelNumber or levelName changed
const hasChanges = prevFlows.some((prev: any, idx: number) => {
const curr = flows[idx];
return !curr ||
prev.levelNumber !== curr.levelNumber ||
prev.levelName !== curr.levelName ||
prev.approverEmail !== curr.approverEmail;
});
return hasChanges ? flows : prevFlows;
});
} else {
// If no approvals found, clear the flow
setApprovalFlow([]);
} }
} catch (error) { } catch (error) {
console.warn('Failed to load approval flows from API:', error); console.warn('Failed to load approval flows from API:', error);
// On error, try to use request.approvalFlow as fallback
if (request?.approvalFlow && request.approvalFlow.length > 0) {
setApprovalFlow(request.approvalFlow);
}
} }
} else if (request?.approvalFlow && request.approvalFlow.length > 0) {
// Fallback: use request.approvalFlow only if no requestId available
setApprovalFlow(request.approvalFlow);
} }
}; };
loadApprovalFlows(); loadApprovalFlows();
}, [request?.id, request?.requestId, request?.totalLevels, refreshTrigger]); }, [request, refreshTrigger]);
// Also reload when request.currentStep or totalLevels changes (to catch step transitions and new approvers) // Also reload when request.currentStep changes
useEffect(() => { useEffect(() => {
if (request?.id || request?.requestId) { if (request?.id || request?.requestId) {
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
@ -247,24 +219,17 @@ export function DealerClaimWorkflowTab({
const details = await getWorkflowDetails(requestId); const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || []; const approvals = details?.approvalLevels || details?.approvals || [];
if (approvals && approvals.length > 0) { if (approvals && approvals.length > 0) {
const flows = approvals const flows = approvals.map((level: any) => ({
.map((level: any) => ({ step: level.levelNumber || level.level_number || 0,
step: level.levelNumber || level.level_number || 0, approver: level.approverName || level.approver_name || '',
levelNumber: level.levelNumber || level.level_number || 0, approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
levelName: level.levelName || level.level_name, status: level.status?.toLowerCase() || 'waiting',
approver: level.approverName || level.approver_name || '', tatHours: level.tatHours || level.tat_hours || 24,
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(), elapsedHours: level.elapsedHours || level.elapsed_hours,
status: level.status?.toLowerCase() || 'waiting', approvedAt: level.actionDate || level.action_date,
tatHours: level.tatHours || level.tat_hours || 24, comment: level.comments || level.comment,
elapsedHours: level.elapsedHours || level.elapsed_hours, levelId: level.levelId || level.level_id,
approvedAt: level.actionDate || level.action_date, }));
comment: level.comments || level.comment,
levelId: level.levelId || level.level_id,
}))
// Sort by levelNumber to ensure correct order
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0));
// Update state with new flows
setApprovalFlow(flows); setApprovalFlow(flows);
} }
} catch (error) { } catch (error) {
@ -273,7 +238,7 @@ export function DealerClaimWorkflowTab({
}; };
loadApprovalFlows(); loadApprovalFlows();
} }
}, [request?.currentStep, request?.totalLevels]); }, [request?.currentStep]);
// Enhanced refresh handler that also reloads approval flows // Enhanced refresh handler that also reloads approval flows
const handleRefresh = () => { const handleRefresh = () => {
@ -281,128 +246,36 @@ export function DealerClaimWorkflowTab({
onRefresh?.(); onRefresh?.();
}; };
// Step title and description mapping based on actual step number (not array index)
// This handles cases where approvers are added between steps
const getStepTitle = (stepNumber: number, levelName?: string, approverName?: string): string => {
// Use levelName from backend if available (most accurate)
// Check if it's an "Additional Approver" - this indicates a dynamically added approver
if (levelName && levelName.trim()) {
// If it starts with "Additional Approver", use it as-is (it's already formatted)
if (levelName.toLowerCase().includes('additional approver')) {
return levelName;
}
// Otherwise use the levelName from backend (preserved from original step)
return levelName;
}
// Fallback to mapping based on step number
const stepTitleMap: Record<number, string> = {
1: 'Dealer - Proposal Submission',
2: 'Requestor Evaluation & Confirmation',
3: 'Department Lead Approval',
4: 'Activity Creation',
5: 'Dealer - Completion Documents',
6: 'Requestor - Claim Approval',
7: 'E-Invoice Generation',
8: 'Credit Note from SAP',
};
// If step number exists in map, use it
if (stepTitleMap[stepNumber]) {
return stepTitleMap[stepNumber];
}
// For dynamically added steps, create a title from approver name or generic
if (approverName && approverName !== 'Unknown' && approverName !== 'System') {
return `Additional Approver - ${approverName}`;
}
return `Additional Approver - Step ${stepNumber}`;
};
const getStepDescription = (stepNumber: number, levelName?: string, approverName?: string): string => {
// Check if this is an "Additional Approver" (dynamically added)
const isAdditionalApprover = levelName && levelName.toLowerCase().includes('additional approver');
// If this is an additional approver, use generic description
if (isAdditionalApprover) {
if (approverName && approverName !== 'Unknown' && approverName !== 'System') {
return `${approverName} will review and approve this request as an additional approver.`;
}
return `Additional approver will review and approve this request.`;
}
// Use levelName to determine description (handles shifted steps correctly)
// This ensures descriptions shift with their steps when approvers are added
if (levelName && levelName.trim()) {
const levelNameLower = levelName.toLowerCase();
// Map level names to descriptions (works even after shifting)
if (levelNameLower.includes('dealer') && levelNameLower.includes('proposal')) {
return 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests';
}
if (levelNameLower.includes('requestor') && (levelNameLower.includes('evaluation') || levelNameLower.includes('confirmation'))) {
return 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)';
}
if (levelNameLower.includes('department lead')) {
return 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)';
}
if (levelNameLower.includes('activity creation')) {
return 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.';
}
if (levelNameLower.includes('dealer') && (levelNameLower.includes('completion') || levelNameLower.includes('documents'))) {
return 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description';
}
if (levelNameLower.includes('requestor') && (levelNameLower.includes('claim') || levelNameLower.includes('approval'))) {
return 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.';
}
if (levelNameLower.includes('e-invoice') || levelNameLower.includes('invoice generation')) {
return 'E-invoice will be generated through DMS.';
}
if (levelNameLower.includes('credit note') || levelNameLower.includes('sap')) {
return 'Got credit note from SAP. Review and send to dealer to complete the claim management process.';
}
}
// Fallback to step number mapping (for backwards compatibility)
const stepDescriptionMap: Record<number, string> = {
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
4: 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
5: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
6: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
7: 'E-invoice will be generated through DMS.',
8: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
};
if (stepDescriptionMap[stepNumber]) {
return stepDescriptionMap[stepNumber];
}
// Final fallback
if (approverName && approverName !== 'Unknown' && approverName !== 'System') {
return `${approverName} will review and approve this request.`;
}
return `Step ${stepNumber} approval required.`;
};
// Transform approval flow to dealer claim workflow steps // Transform approval flow to dealer claim workflow steps
const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => { const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => {
// Get actual step number from levelNumber or step field const stepTitles = [
const actualStepNumber = step.levelNumber || step.level_number || step.step || index + 1; 'Dealer - Proposal Submission',
'Requestor Evaluation & Confirmation',
// Get levelName from the approval level if available 'Dept Lead Approval',
const levelName = step.levelName || step.level_name; 'Activity Creation',
'Dealer - Completion Documents',
'Requestor - Claim Approval',
'E-Invoice Generation',
'Credit Note from SAP',
];
const stepDescriptions = [
'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
'E-invoice will be generated through DMS.',
'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
];
// Find approval data for this step // Find approval data for this step
const approval = request?.approvals?.find((a: any) => a.levelId === step.levelId); const approval = request?.approvals?.find((a: any) => a.levelId === step.levelId);
// Extract IO details from internalOrder table (Department Lead step - check by levelName) // Extract IO details from internalOrder table (Step 3)
let ioDetails = undefined; let ioDetails = undefined;
const isDeptLeadStep = levelName && levelName.toLowerCase().includes('department lead'); if (step.step === 3) {
if (isDeptLeadStep || actualStepNumber === 3) {
// Get IO details from dedicated internalOrder table // Get IO details from dedicated internalOrder table
const internalOrder = request?.internalOrder || request?.internal_order; const internalOrder = request?.internalOrder || request?.internal_order;
@ -441,7 +314,7 @@ export function DealerClaimWorkflowTab({
// Extract DMS details from approval data (Step 6) // Extract DMS details from approval data (Step 6)
let dmsDetails = undefined; let dmsDetails = undefined;
if (actualStepNumber === 6) { if (step.step === 6) {
if (approval?.dmsDetails) { if (approval?.dmsDetails) {
dmsDetails = { dmsDetails = {
dmsNumber: approval.dmsDetails.dmsNumber || '', dmsNumber: approval.dmsDetails.dmsNumber || '',
@ -470,18 +343,18 @@ export function DealerClaimWorkflowTab({
// Waiting steps (future steps) should have elapsedHours = 0 // Waiting steps (future steps) should have elapsedHours = 0
// This ensures that when in step 1, only step 1 shows elapsed time, others show 0 // This ensures that when in step 1, only step 1 shows elapsed time, others show 0
const isWaiting = normalizedStatus === 'waiting'; const isWaiting = normalizedStatus === 'waiting';
const isActive = normalizedStatus === 'pending' || normalizedStatus === 'in_progress';
const isCompleted = normalizedStatus === 'approved' || normalizedStatus === 'rejected';
// Only calculate/show elapsed hours for active or completed steps // Only calculate/show elapsed hours for active or completed steps
// For waiting steps, elapsedHours should be 0 (they haven't started yet) // For waiting steps, elapsedHours should be 0 (they haven't started yet)
const elapsedHours = isWaiting ? 0 : (step.elapsedHours || 0); const elapsedHours = isWaiting ? 0 : (step.elapsedHours || 0);
const approverName = step.approver || step.approverName || 'Unknown';
return { return {
step: actualStepNumber, step: step.step || index + 1,
title: getStepTitle(actualStepNumber, levelName, approverName), title: stepTitles[index] || `Step ${step.step || index + 1}`,
approver: approverName, approver: step.approver || 'Unknown',
description: getStepDescription(actualStepNumber, levelName, approverName) || step.description || '', description: stepDescriptions[index] || step.description || '',
tatHours: step.tatHours || 24, tatHours: step.tatHours || 24,
status: normalizedStatus as any, status: normalizedStatus as any,
comment: step.comment || approval?.comment, comment: step.comment || approval?.comment,
@ -489,33 +362,21 @@ export function DealerClaimWorkflowTab({
elapsedHours, // Only non-zero for active/completed steps elapsedHours, // Only non-zero for active/completed steps
ioDetails, ioDetails,
dmsDetails, dmsDetails,
einvoiceUrl: actualStepNumber === 7 ? (approval as any)?.einvoiceUrl : undefined, einvoiceUrl: step.step === 7 ? (approval as any)?.einvoiceUrl : undefined,
emailTemplateUrl: (approval as any)?.emailTemplateUrl || undefined, emailTemplateUrl: step.step === 4 ? (approval as any)?.emailTemplateUrl : undefined,
}; };
}); });
const totalSteps = request?.totalSteps || 8; const totalSteps = request?.totalSteps || 8;
// Calculate currentStep from approval flow - find the first pending or in_progress step // Calculate currentStep from approval flow - find the first pending or in_progress step
// IMPORTANT: Use the workflow's currentLevel from backend (most accurate) // If no pending/in_progress step, use the request's currentStep
// Fallback to finding first pending step if currentLevel not available
// Note: Status normalization already handled in workflowSteps mapping above // Note: Status normalization already handled in workflowSteps mapping above
const backendCurrentLevel = request?.currentLevel || request?.current_level || request?.currentStep; const activeStep = workflowSteps.find(s => {
const status = s.status?.toLowerCase() || '';
// Find the step that matches backend's currentLevel return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review';
const activeStepFromBackend = workflowSteps.find(s => s.step === backendCurrentLevel); });
const currentStep = activeStep ? activeStep.step : (request?.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 : (backendCurrentLevel || request?.currentStep || 1);
// Check if current user is the dealer (for steps 1 and 5) // Check if current user is the dealer (for steps 1 and 5)
const userEmail = (user as any)?.email?.toLowerCase() || ''; const userEmail = (user as any)?.email?.toLowerCase() || '';
@ -535,30 +396,8 @@ export function DealerClaimWorkflowTab({
const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase(); const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase();
const isCurrentApprover = approverEmail && userEmail === approverEmail; const isCurrentApprover = approverEmail && userEmail === approverEmail;
// Find the initiator's step dynamically (Requestor Evaluation step) // Check if user is approver for step 2 (requestor evaluation) - match by email
// This handles cases where approvers are added between steps, causing step numbers to shift const step2Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 2);
const initiatorEmail = (
(request as any)?.initiator?.email?.toLowerCase() ||
(request as any)?.initiatorEmail?.toLowerCase() ||
''
);
// Find the step where the initiator is the approver
// Check by: 1) approverEmail matches initiatorEmail, OR 2) levelName contains "Requestor Evaluation"
const initiatorStepLevel = approvalFlow.find((l: any) => {
const levelApproverEmail = (l.approverEmail || '').toLowerCase();
const levelName = (l.levelName || '').toLowerCase();
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
levelName.includes('requestor evaluation') ||
levelName.includes('requestor') && levelName.includes('confirmation');
});
const initiatorStepNumber = initiatorStepLevel
? (initiatorStepLevel.step || initiatorStepLevel.levelNumber || initiatorStepLevel.level_number || 2)
: 2; // Fallback to 2 if not found
// Check if user is approver for the initiator's step (requestor evaluation)
const step2Level = initiatorStepLevel || approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 2);
const step2ApproverEmail = (step2Level?.approverEmail || '').toLowerCase(); const step2ApproverEmail = (step2Level?.approverEmail || '').toLowerCase();
const isStep2Approver = step2ApproverEmail && userEmail === step2ApproverEmail; const isStep2Approver = step2ApproverEmail && userEmail === step2ApproverEmail;
@ -567,12 +406,9 @@ export function DealerClaimWorkflowTab({
const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase(); const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase();
const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail; const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail;
// Find Department Lead step dynamically (handles step shifts) // Check if user is approver for step 3 (department lead approval) - match by email
const deptLeadStepLevel = approvalFlow.find((l: any) => { const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
const levelName = (l.levelName || '').toLowerCase(); const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
return levelName.includes('department lead');
});
const step3ApproverEmail = (deptLeadStepLevel?.approverEmail || '').toLowerCase();
const isStep3Approver = step3ApproverEmail && userEmail === step3ApproverEmail; const isStep3Approver = step3ApproverEmail && userEmail === step3ApproverEmail;
// Handle proposal submission // Handle proposal submission
@ -633,39 +469,20 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find the initiator's step levelId dynamically // Get approval levels to find Step 2 levelId
const details = await getWorkflowDetails(requestId); const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || []; const approvals = details?.approvalLevels || details?.approvals || [];
const step2Level = approvals.find((level: any) =>
// Find the initiator's step by checking approverEmail or levelName
const initiatorEmail = (
(request as any)?.initiator?.email?.toLowerCase() ||
(request as any)?.initiatorEmail?.toLowerCase() ||
''
);
const step2Level = approvals.find((level: any) => {
const levelApproverEmail = (level.approverEmail || level.approver_email || '').toLowerCase();
const levelName = (level.levelName || level.level_name || '').toLowerCase();
const levelNumber = level.levelNumber || level.level_number;
// Check if this is the initiator's step
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
levelName.includes('requestor evaluation') ||
(levelName.includes('requestor') && levelName.includes('confirmation')) ||
// Fallback: if initiatorStepNumber was found earlier, use it
(levelNumber === initiatorStepNumber);
}) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 2 (level.levelNumber || level.level_number) === 2
); // Final fallback to level 2 );
if (!step2Level?.levelId && !step2Level?.level_id) { if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Initiator approval level not found'); throw new Error('Step 2 approval level not found');
} }
const levelId = step2Level.levelId || step2Level.level_id; const levelId = step2Level.levelId || step2Level.level_id;
// Approve the initiator's step using real API // Approve Step 2 using real API
await approveLevel(requestId, levelId, comments); await approveLevel(requestId, levelId, comments);
// Activity is logged by backend approval service - no need to create work note // Activity is logged by backend approval service - no need to create work note
@ -688,39 +505,20 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find the initiator's step levelId dynamically // Get approval levels to find Step 2 levelId
const details = await getWorkflowDetails(requestId); const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || []; const approvals = details?.approvalLevels || details?.approvals || [];
const step2Level = approvals.find((level: any) =>
// Find the initiator's step by checking approverEmail or levelName
const initiatorEmail = (
(request as any)?.initiator?.email?.toLowerCase() ||
(request as any)?.initiatorEmail?.toLowerCase() ||
''
);
const step2Level = approvals.find((level: any) => {
const levelApproverEmail = (level.approverEmail || level.approver_email || '').toLowerCase();
const levelName = (level.levelName || level.level_name || '').toLowerCase();
const levelNumber = level.levelNumber || level.level_number;
// Check if this is the initiator's step
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
levelName.includes('requestor evaluation') ||
(levelName.includes('requestor') && levelName.includes('confirmation')) ||
// Fallback: if initiatorStepNumber was found earlier, use it
(levelNumber === initiatorStepNumber);
}) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 2 (level.levelNumber || level.level_number) === 2
); // Final fallback to level 2 );
if (!step2Level?.levelId && !step2Level?.level_id) { if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Initiator approval level not found'); throw new Error('Step 2 approval level not found');
} }
const levelId = step2Level.levelId || step2Level.level_id; const levelId = step2Level.levelId || step2Level.level_id;
// Reject the initiator's step using real API // Reject Step 2 using real API
await rejectLevel(requestId, levelId, 'Proposal rejected by requestor', comments); await rejectLevel(requestId, levelId, 'Proposal rejected by requestor', comments);
// Activity is logged by backend approval service - no need to create work note // Activity is logged by backend approval service - no need to create work note
@ -734,7 +532,7 @@ export function DealerClaimWorkflowTab({
} }
}; };
// Handle IO approval (Department Lead step - found dynamically) // Handle IO approval (Step 3)
const handleIOApproval = async (data: { const handleIOApproval = async (data: {
ioNumber: string; ioNumber: string;
ioRemark: string; ioRemark: string;
@ -747,20 +545,15 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find Department Lead step levelId dynamically // Get approval levels to find Step 3 levelId
const details = await getWorkflowDetails(requestId); const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || []; const approvals = details?.approvalLevels || details?.approvals || [];
const step3Level = approvals.find((level: any) =>
// Find Department Lead step by levelName (handles step shifts)
const step3Level = approvals.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('department lead');
}) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3 (level.levelNumber || level.level_number) === 3
); // Fallback to level 3 );
if (!step3Level?.levelId && !step3Level?.level_id) { if (!step3Level?.levelId && !step3Level?.level_id) {
throw new Error('Department Lead approval level not found'); throw new Error('Step 3 approval level not found');
} }
const levelId = step3Level.levelId || step3Level.level_id; const levelId = step3Level.levelId || step3Level.level_id;
@ -882,25 +675,20 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find Department Lead step levelId dynamically // Get approval levels to find Step 3 levelId
const details = await getWorkflowDetails(requestId); const details = await getWorkflowDetails(requestId);
const approvals = details?.approvalLevels || details?.approvals || []; const approvals = details?.approvalLevels || details?.approvals || [];
const step3Level = approvals.find((level: any) =>
// Find Department Lead step by levelName (handles step shifts)
const step3Level = approvals.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('department lead');
}) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3 (level.levelNumber || level.level_number) === 3
); // Fallback to level 3 );
if (!step3Level?.levelId && !step3Level?.level_id) { if (!step3Level?.levelId && !step3Level?.level_id) {
throw new Error('Department Lead approval level not found'); throw new Error('Step 3 approval level not found');
} }
const levelId = step3Level.levelId || step3Level.level_id; const levelId = step3Level.levelId || step3Level.level_id;
// Reject Department Lead step using real API // Reject Step 3 using real API
await rejectLevel(requestId, levelId, 'Dept Lead rejected - More clarification required', comments); await rejectLevel(requestId, levelId, 'Dept Lead rejected - More clarification required', comments);
// Activity is logged by backend approval service - no need to create work note // Activity is logged by backend approval service - no need to create work note
@ -1038,16 +826,8 @@ export function DealerClaimWorkflowTab({
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{workflowSteps.map((step, index) => { {workflowSteps.map((step, index) => {
// Step is active if: // Step is active if it's pending or in_progress and matches currentStep
// 1. It's pending or in_progress const isActive = (step.status === 'pending' || step.status === 'in_progress') && step.step === currentStep;
// 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)
const stepStatus = step.status?.toLowerCase() || '';
const isPendingOrInProgress = stepStatus === 'pending' || 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;
const isCompleted = step.status === 'approved'; const isCompleted = step.status === 'approved';
// Find approval data for this step to get SLA information // Find approval data for this step to get SLA information
@ -1085,8 +865,8 @@ export function DealerClaimWorkflowTab({
<Badge className={getStepBadgeVariant(step.status)}> <Badge className={getStepBadgeVariant(step.status)}>
{step.status.toLowerCase()} {step.status.toLowerCase()}
</Badge> </Badge>
{/* Email Template Button - Show when step has emailTemplateUrl and is approved */} {/* Email Template Button (Step 4) - Show when approved */}
{step.emailTemplateUrl && step.status === 'approved' && ( {step.step === 4 && step.status === 'approved' && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -1298,23 +1078,7 @@ export function DealerClaimWorkflowTab({
)} )}
{/* Action Buttons */} {/* Action Buttons */}
{/* Only show action buttons if: {isActive && (
1. Step is active (pending/in_progress and matches currentStep)
2. AND current user is the approver for this step (or is dealer for dealer steps) */}
{(() => {
// Find the step level from approvalFlow to verify user is the approver
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
const isUserApproverForThisStep = stepApproverEmail && userEmail === stepApproverEmail;
// For dealer steps (1 and 5), also check if user is dealer
const isDealerStep = step.step === 1 ||
(stepLevel?.levelName && stepLevel.levelName.toLowerCase().includes('dealer'));
const isUserAuthorized = isUserApproverForThisStep || (isDealerStep && isDealer);
// Step must be active AND user must be authorized
return isActive && isUserAuthorized;
})() && (
<div className="mt-4 flex gap-2"> <div className="mt-4 flex gap-2">
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */} {/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
{step.step === 1 && (isDealer || isStep1Approver) && ( {step.step === 1 && (isDealer || isStep1Approver) && (
@ -1329,9 +1093,8 @@ export function DealerClaimWorkflowTab({
</Button> </Button>
)} )}
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */} {/* Step 2: Confirm Request - Only for initiator or step 2 approver */}
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */} {step.step === 2 && (isInitiator || isStep2Approver) && (
{step.step === initiatorStepNumber && (isInitiator || isStep2Approver) && (
<Button <Button
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
onClick={() => { onClick={() => {
@ -1339,77 +1102,31 @@ export function DealerClaimWorkflowTab({
}} }}
> >
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
Review Request Confirm Request
</Button> </Button>
)} )}
{/* Department Lead Step: Approve and Organise IO - Find dynamically by levelName */} {/* Step 3: Approve and Organise IO - Only for department lead (step 3 approver) */}
{(() => { {step.step === 3 && (() => {
// Find Department Lead step dynamically (handles step shifts) // Find step 3 from approvalFlow to get approverEmail
const deptLeadStepLevel = approvalFlow.find((l: any) => { const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
const levelName = (l.levelName || '').toLowerCase(); const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
return levelName.includes('department lead'); const isStep3ApproverByEmail = step3ApproverEmail && userEmail === step3ApproverEmail;
}); return isStep3ApproverByEmail || isStep3Approver || isCurrentApprover;
// Check if this is the Department Lead step
const isDeptLeadStep = deptLeadStepLevel &&
(step.step === (deptLeadStepLevel.step || deptLeadStepLevel.levelNumber || deptLeadStepLevel.level_number));
if (!isDeptLeadStep) return null;
// Check if user is the Department Lead approver
const deptLeadApproverEmail = (deptLeadStepLevel?.approverEmail || '').toLowerCase();
const isDeptLeadApprover = deptLeadApproverEmail && userEmail === deptLeadApproverEmail;
if (!(isDeptLeadApprover || isStep3Approver || isCurrentApprover)) return null;
// Check if IO number is available (same way as IO tab and modal)
const internalOrder = request?.internalOrder || request?.internal_order;
const ioNumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
const hasIONumber = ioNumber && ioNumber.trim() !== '';
return (
<div className="space-y-2">
{!hasIONumber && (
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-800">IO Number Not Available</p>
<p className="text-xs text-amber-700 mt-1">
Please add an IO number in the IO tab before approving this step.
</p>
</div>
</div>
)}
<Button
className="bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => {
setShowIOApprovalModal(true);
}}
disabled={!hasIONumber}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve and Organise IO
</Button>
</div>
);
})()}
{/* Step 5 (or shifted step): Upload Completion Documents - Only for dealer */}
{/* Check if dealer is the approver for this step (handles step shifts) */}
{(() => {
// Find the step level from approvalFlow to verify dealer is the approver
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
// Check if dealer is the approver for this step
const isDealerForThisStep = isDealer && stepApproverEmail === dealerEmail;
// Check if this is the Dealer Completion Documents step
// by checking if the levelName contains "Dealer Completion" or "Completion Documents"
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
const isDealerCompletionStep = levelName.includes('dealer completion') ||
levelName.includes('completion documents');
return isDealerForThisStep && isDealerCompletionStep;
})() && ( })() && (
<Button
className="bg-green-600 hover:bg-green-700"
onClick={() => {
setShowIOApprovalModal(true);
}}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve and Organise IO
</Button>
)}
{/* Step 5: Upload Completion Documents - Only for dealer */}
{step.step === 5 && isDealer && (
<Button <Button
className="bg-purple-600 hover:bg-purple-700" className="bg-purple-600 hover:bg-purple-700"
onClick={() => { onClick={() => {
@ -1509,7 +1226,6 @@ export function DealerClaimWorkflowTab({
requestTitle={request?.title} requestTitle={request?.title}
requestId={request?.id || request?.requestId} requestId={request?.id || request?.requestId}
preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined} preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined}
preFilledIORemark={request?.internalOrder?.ioRemark || request?.internalOrder?.io_remark || request?.internal_order?.ioRemark || request?.internal_order?.io_remark || undefined}
preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined} preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined}
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined} preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
/> />

View File

@ -29,7 +29,7 @@ interface CreditNoteSAPModalProps {
creditNoteNumber?: string; creditNoteNumber?: string;
creditNoteDate?: string; creditNoteDate?: string;
creditNoteAmount?: number; creditNoteAmount?: number;
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED'; status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT';
}; };
dealerInfo?: { dealerInfo?: {
dealerName?: string; dealerName?: string;

View File

@ -4,7 +4,7 @@
* Allows dealers to upload completion documents, photos, expenses, and provide completion details * Allows dealers to upload completion documents, photos, expenses, and provide completion details
*/ */
import { useState, useRef, useMemo, useEffect } from 'react'; import { useState, useRef, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,9 +18,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download } from 'lucide-react'; import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import '@/components/common/FilePreview/FilePreview.css';
interface ExpenseItem { interface ExpenseItem {
id: string; id: string;
@ -64,64 +63,12 @@ export function DealerCompletionDocumentsModal({
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null); const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
const [completionDescription, setCompletionDescription] = useState(''); const [completionDescription, setCompletionDescription] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
const completionDocsInputRef = useRef<HTMLInputElement>(null); const completionDocsInputRef = useRef<HTMLInputElement>(null);
const photosInputRef = useRef<HTMLInputElement>(null); const photosInputRef = useRef<HTMLInputElement>(null);
const invoicesInputRef = useRef<HTMLInputElement>(null); const invoicesInputRef = useRef<HTMLInputElement>(null);
const attendanceInputRef = useRef<HTMLInputElement>(null); const attendanceInputRef = useRef<HTMLInputElement>(null);
// Helper function to check if file can be previewed
const canPreviewFile = (file: File): boolean => {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
return type.includes('image') ||
type.includes('pdf') ||
name.endsWith('.pdf') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
};
// Cleanup object URLs when component unmounts or file changes
useEffect(() => {
return () => {
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
};
}, [previewFile]);
// Handle file preview
const handlePreviewFile = (file: File) => {
if (!canPreviewFile(file)) {
toast.error('Preview is only available for images and PDF files');
return;
}
// Cleanup previous preview URL
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
const url = URL.createObjectURL(file);
setPreviewFile({ file, url });
};
// Handle download file (for non-previewable files)
const handleDownloadFile = (file: File) => {
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Calculate total closed expenses // Calculate total closed expenses
const totalClosedExpenses = useMemo(() => { const totalClosedExpenses = useMemo(() => {
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0); return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0);
@ -267,11 +214,6 @@ export function DealerCompletionDocumentsModal({
}; };
const handleReset = () => { const handleReset = () => {
// Cleanup preview URL if exists
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
setPreviewFile(null);
setActivityCompletionDate(''); setActivityCompletionDate('');
setNumberOfParticipants(''); setNumberOfParticipants('');
setExpenseItems([]); setExpenseItems([]);
@ -475,40 +417,16 @@ export function DealerCompletionDocumentsModal({
<FileText className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" /> <FileText className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span> <span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2"> <Button
{canPreviewFile(file) && ( type="button"
<Button variant="ghost"
type="button" size="sm"
variant="ghost" className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
size="sm" onClick={() => handleRemoveCompletionDoc(index)}
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700" title="Remove document"
onClick={() => handlePreviewFile(file)} >
title="Preview file" <X className="w-4 h-4" />
> </Button>
<Eye className="w-3.5 h-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
onClick={() => handleDownloadFile(file)}
title="Download file"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveCompletionDoc(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
</div> </div>
))} ))}
</div> </div>
@ -580,40 +498,16 @@ export function DealerCompletionDocumentsModal({
<Image className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" /> <Image className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span> <span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2"> <Button
{canPreviewFile(file) && ( type="button"
<Button variant="ghost"
type="button" size="sm"
variant="ghost" className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
size="sm" onClick={() => handleRemovePhoto(index)}
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700" title="Remove photo"
onClick={() => handlePreviewFile(file)} >
title="Preview photo" <X className="w-4 h-4" />
> </Button>
<Eye className="w-3.5 h-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
onClick={() => handleDownloadFile(file)}
title="Download photo"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemovePhoto(index)}
title="Remove photo"
>
<X className="w-4 h-4" />
</Button>
</div>
</div> </div>
))} ))}
</div> </div>
@ -693,40 +587,16 @@ export function DealerCompletionDocumentsModal({
<Receipt className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" /> <Receipt className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span> <span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2"> <Button
{canPreviewFile(file) && ( type="button"
<Button variant="ghost"
type="button" size="sm"
variant="ghost" className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
size="sm" onClick={() => handleRemoveInvoice(index)}
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700" title="Remove document"
onClick={() => handlePreviewFile(file)} >
title="Preview file" <X className="w-4 h-4" />
> </Button>
<Eye className="w-3.5 h-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
onClick={() => handleDownloadFile(file)}
title="Download file"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveInvoice(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
</div> </div>
))} ))}
</div> </div>
@ -782,7 +652,7 @@ export function DealerCompletionDocumentsModal({
)} )}
</label> </label>
</div> </div>
{attendanceSheet && ( {attendanceSheet && (
<div className="mt-3"> <div className="mt-3">
<p className="text-xs font-medium text-gray-600 mb-2"> <p className="text-xs font-medium text-gray-600 mb-2">
Selected Document: Selected Document:
@ -792,43 +662,19 @@ export function DealerCompletionDocumentsModal({
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" /> <FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span> <span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2"> <Button
{canPreviewFile(attendanceSheet) && ( type="button"
<Button variant="ghost"
type="button" size="sm"
variant="ghost" className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
size="sm" onClick={() => {
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700" setAttendanceSheet(null);
onClick={() => handlePreviewFile(attendanceSheet)} if (attendanceInputRef.current) attendanceInputRef.current.value = '';
title="Preview file" }}
> title="Remove document"
<Eye className="w-3.5 h-3.5" /> >
</Button> <X className="w-4 h-4" />
)} </Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
onClick={() => handleDownloadFile(attendanceSheet)}
title="Download file"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => {
setAttendanceSheet(null);
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
}}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
</div> </div>
</div> </div>
)} )}
@ -882,96 +728,6 @@ export function DealerCompletionDocumentsModal({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{/* File Preview Modal - Matching DocumentsTab style */}
{previewFile && (
<Dialog
open={!!previewFile}
onOpenChange={() => {
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
setPreviewFile(null);
}}
>
<DialogContent className="file-preview-dialog p-3 sm:p-6">
<div className="file-preview-content">
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
{previewFile.file.name}
</DialogTitle>
<p className="text-xs sm:text-sm text-gray-500">
{previewFile.file.type || 'Unknown type'} {(previewFile.file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap mr-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(previewFile.file)}
className="gap-2 h-9"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Download</span>
</Button>
</div>
</div>
</DialogHeader>
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
{previewFile.file.type?.includes('image') ? (
<div className="flex items-center justify-center h-full">
<img
src={previewFile.url}
alt={previewFile.file.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
className="rounded-lg shadow-lg"
/>
</div>
) : previewFile.file.type?.includes('pdf') || previewFile.file.name.toLowerCase().endsWith('.pdf') ? (
<div className="w-full h-full flex items-center justify-center">
<iframe
src={previewFile.url}
className="w-full h-full rounded-lg border-0"
title={previewFile.file.name}
style={{
minHeight: '70vh',
height: '100%'
}}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
<Eye className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
<p className="text-sm text-gray-600 mb-6">
This file type cannot be previewed. Please download to view.
</p>
<Button
onClick={() => handleDownloadFile(previewFile.file)}
className="gap-2"
>
<Download className="h-4 w-4" />
Download {previewFile.file.name}
</Button>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
</Dialog> </Dialog>
); );
} }

View File

@ -4,7 +4,7 @@
* Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments * Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments
*/ */
import { useState, useRef, useMemo, useEffect } from 'react'; import { useState, useRef, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,9 +18,8 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react'; import { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import '@/components/common/FilePreview/FilePreview.css';
interface CostItem { interface CostItem {
id: string; id: string;
@ -61,63 +60,10 @@ export function DealerProposalSubmissionModal({
const [otherDocuments, setOtherDocuments] = useState<File[]>([]); const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
const [dealerComments, setDealerComments] = useState(''); const [dealerComments, setDealerComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
const proposalDocInputRef = useRef<HTMLInputElement>(null); const proposalDocInputRef = useRef<HTMLInputElement>(null);
const otherDocsInputRef = useRef<HTMLInputElement>(null); const otherDocsInputRef = useRef<HTMLInputElement>(null);
// Helper function to check if file can be previewed
const canPreviewFile = (file: File): boolean => {
const type = file.type.toLowerCase();
const name = file.name.toLowerCase();
return type.includes('image') ||
type.includes('pdf') ||
name.endsWith('.pdf') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
};
// Cleanup object URLs when component unmounts or file changes
useEffect(() => {
return () => {
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
};
}, [previewFile]);
// Handle file preview - instant preview using object URL
const handlePreviewFile = (file: File) => {
if (!canPreviewFile(file)) {
toast.error('Preview is only available for images and PDF files');
return;
}
// Cleanup previous preview URL
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
// Create object URL immediately for instant preview
const url = URL.createObjectURL(file);
setPreviewFile({ file, url });
};
// Handle download file (for non-previewable files)
const handleDownloadFile = (file: File) => {
const url = URL.createObjectURL(file);
const a = document.createElement('a');
a.href = url;
a.download = file.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
// Calculate total estimated budget // Calculate total estimated budget
const totalBudget = useMemo(() => { const totalBudget = useMemo(() => {
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0); return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
@ -218,11 +164,6 @@ export function DealerProposalSubmissionModal({
}; };
const handleReset = () => { const handleReset = () => {
// Cleanup preview URL if exists
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
setPreviewFile(null);
setProposalDocument(null); setProposalDocument(null);
setCostItems([{ id: '1', description: '', amount: 0 }]); setCostItems([{ id: '1', description: '', amount: 0 }]);
setTimelineMode('date'); setTimelineMode('date');
@ -302,41 +243,17 @@ export function DealerProposalSubmissionModal({
className="cursor-pointer flex flex-col items-center gap-2" className="cursor-pointer flex flex-col items-center gap-2"
> >
{proposalDocument ? ( {proposalDocument ? (
<div className="flex flex-col items-center gap-2 w-full"> <>
<CheckCircle2 className="w-8 h-8 text-green-600" /> <CheckCircle2 className="w-8 h-8 text-green-600" />
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2"> <div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full"> <span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
{proposalDocument.name} {proposalDocument.name}
</span> </span>
<span className="text-xs text-green-600 mb-2"> <span className="text-xs text-green-600">
Document selected Document selected
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> </>
{canPreviewFile(proposalDocument) && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handlePreviewFile(proposalDocument)}
className="h-8 text-xs"
>
<Eye className="w-3.5 h-3.5 mr-1" />
Preview
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleDownloadFile(proposalDocument)}
className="h-8 text-xs"
>
<Download className="w-3.5 h-3.5 mr-1" />
Download
</Button>
</div>
</div>
) : ( ) : (
<> <>
<Upload className="w-8 h-8 text-gray-400" /> <Upload className="w-8 h-8 text-gray-400" />
@ -550,40 +467,16 @@ export function DealerProposalSubmissionModal({
{file.name} {file.name}
</span> </span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2"> <Button
{canPreviewFile(file) && ( type="button"
<Button variant="ghost"
type="button" size="sm"
variant="ghost" className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
size="sm" onClick={() => handleRemoveOtherDoc(index)}
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700" title="Remove document"
onClick={() => handlePreviewFile(file)} >
title="Preview file" <X className="w-4 h-4" />
> </Button>
<Eye className="w-3.5 h-3.5" />
</Button>
)}
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
onClick={() => handleDownloadFile(file)}
title="Download file"
>
<Download className="w-3.5 h-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
onClick={() => handleRemoveOtherDoc(index)}
title="Remove document"
>
<X className="w-4 h-4" />
</Button>
</div>
</div> </div>
))} ))}
</div> </div>
@ -638,96 +531,6 @@ export function DealerProposalSubmissionModal({
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{/* File Preview Modal - Matching DocumentsTab style */}
{previewFile && (
<Dialog
open={!!previewFile}
onOpenChange={() => {
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
setPreviewFile(null);
}}
>
<DialogContent className="file-preview-dialog p-3 sm:p-6">
<div className="file-preview-content">
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
{previewFile.file.name}
</DialogTitle>
<p className="text-xs sm:text-sm text-gray-500">
{previewFile.file.type || 'Unknown type'} {(previewFile.file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-wrap mr-2">
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(previewFile.file)}
className="gap-2 h-9"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Download</span>
</Button>
</div>
</div>
</DialogHeader>
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
{previewFile.file.type?.includes('image') ? (
<div className="flex items-center justify-center h-full">
<img
src={previewFile.url}
alt={previewFile.file.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
className="rounded-lg shadow-lg"
/>
</div>
) : previewFile.file.type?.includes('pdf') || previewFile.file.name.toLowerCase().endsWith('.pdf') ? (
<div className="w-full h-full flex items-center justify-center">
<iframe
src={previewFile.url}
className="w-full h-full rounded-lg border-0"
title={previewFile.file.name}
style={{
minHeight: '70vh',
height: '100%'
}}
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
<Eye className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
<p className="text-sm text-gray-600 mb-6">
This file type cannot be previewed. Please download to view.
</p>
<Button
onClick={() => handleDownloadFile(previewFile.file)}
className="gap-2"
>
<Download className="h-4 w-4" />
Download {previewFile.file.name}
</Button>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
</Dialog> </Dialog>
); );
} }

View File

@ -35,7 +35,6 @@ interface DeptLeadIOApprovalModalProps {
requestId?: string; requestId?: string;
// Pre-filled IO data from IO table // Pre-filled IO data from IO table
preFilledIONumber?: string; preFilledIONumber?: string;
preFilledIORemark?: string;
preFilledBlockedAmount?: number; preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number; preFilledRemainingBalance?: number;
} }
@ -48,7 +47,6 @@ export function DeptLeadIOApprovalModal({
requestTitle, requestTitle,
requestId: _requestId, requestId: _requestId,
preFilledIONumber, preFilledIONumber,
preFilledIORemark,
preFilledBlockedAmount, preFilledBlockedAmount,
preFilledRemainingBalance, preFilledRemainingBalance,
}: DeptLeadIOApprovalModalProps) { }: DeptLeadIOApprovalModalProps) {
@ -63,12 +61,12 @@ export function DeptLeadIOApprovalModal({
// Reset form when modal opens/closes // Reset form when modal opens/closes
React.useEffect(() => { React.useEffect(() => {
if (isOpen) { if (isOpen) {
// Prefill IO remark from props if available // Reset form when modal opens
setIoRemark(preFilledIORemark || ''); setIoRemark('');
setComments(''); setComments('');
setActionType('approve'); setActionType('approve');
} }
}, [isOpen, preFilledIORemark]); }, [isOpen]);
const ioRemarkChars = ioRemark.length; const ioRemarkChars = ioRemark.length;
const commentsChars = comments.length; const commentsChars = comments.length;
@ -271,7 +269,7 @@ export function DeptLeadIOApprovalModal({
)} )}
</div> </div>
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */} {/* IO Remark - Only editable field */}
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2"> <Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Remark <span className="text-red-500">*</span> IO Remark <span className="text-red-500">*</span>
@ -286,18 +284,11 @@ export function DeptLeadIOApprovalModal({
setIoRemark(value); setIoRemark(value);
} }
}} }}
rows={3} rows={2}
className="bg-white text-sm min-h-[80px] resize-none" className="bg-white text-sm min-h-[60px] resize-none"
disabled={false}
readOnly={false}
/> />
<div className="flex items-center justify-between text-xs"> <div className="flex justify-end text-xs text-gray-600">
{preFilledIORemark && ( <span>{ioRemarkChars}/{maxIoRemarkChars}</span>
<span className="text-blue-600">
Prefilled from IO tab (editable)
</span>
)}
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -4,7 +4,7 @@
* Allows initiator to review dealer's proposal and approve/reject * Allows initiator to review dealer's proposal and approve/reject
*/ */
import { useState, useMemo, useEffect } from 'react'; import { useState, useMemo } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -25,11 +25,8 @@ import {
MessageSquare, MessageSquare,
Download, Download,
Eye, Eye,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
import '@/components/common/FilePreview/FilePreview.css';
interface CostItem { interface CostItem {
id: string; id: string;
@ -78,13 +75,6 @@ export function InitiatorProposalApprovalModal({
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null); const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
const [previewDocument, setPreviewDocument] = useState<{
name: string;
url: string;
type?: string;
size?: number;
} | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
// Calculate total budget // Calculate total budget
const totalBudget = useMemo(() => { const totalBudget = useMemo(() => {
@ -120,88 +110,6 @@ export function InitiatorProposalApprovalModal({
} }
}; };
// Check if document can be previewed
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
if (!doc.name) return false;
const name = doc.name.toLowerCase();
return name.endsWith('.pdf') ||
name.endsWith('.jpg') ||
name.endsWith('.jpeg') ||
name.endsWith('.png') ||
name.endsWith('.gif') ||
name.endsWith('.webp');
};
// Handle document preview - fetch as blob to avoid CSP issues
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => {
if (!doc.id) {
toast.error('Document preview not available - document ID missing');
return;
}
setPreviewLoading(true);
try {
const previewUrl = getDocumentPreviewUrl(doc.id);
// Determine file type from name
const fileName = doc.name.toLowerCase();
const isPDF = fileName.endsWith('.pdf');
const isImage = fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i);
// Fetch the document as a blob to create a blob URL (CSP compliant)
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
const token = isProduction ? null : localStorage.getItem('accessToken');
const headers: HeadersInit = {
'Accept': isPDF ? 'application/pdf' : '*/*'
};
if (!isProduction && token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(previewUrl, {
headers,
credentials: 'include',
mode: 'cors'
});
if (!response.ok) {
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
if (blob.size === 0) {
throw new Error('File is empty or could not be loaded');
}
// Create blob URL (CSP compliant - uses 'blob:' protocol)
const blobUrl = window.URL.createObjectURL(blob);
setPreviewDocument({
name: doc.name,
url: blobUrl,
type: blob.type || (isPDF ? 'application/pdf' : isImage ? 'image' : undefined),
size: blob.size,
});
} catch (error) {
console.error('Failed to load document preview:', error);
toast.error('Failed to load document preview');
} finally {
setPreviewLoading(false);
}
};
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) {
window.URL.revokeObjectURL(previewDocument.url);
}
};
}, [previewDocument]);
const handleApprove = async () => { const handleApprove = async () => {
if (!comments.trim()) { if (!comments.trim()) {
toast.error('Please provide approval comments'); toast.error('Please provide approval comments');
@ -308,41 +216,30 @@ export function InitiatorProposalApprovalModal({
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex gap-2">
{proposalData.proposalDocument.id && ( {proposalData.proposalDocument.url && (
<> <>
{canPreviewDocument(proposalData.proposalDocument) && ( <Button
<button variant="outline"
type="button" size="sm"
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)} onClick={() => window.open(proposalData.proposalDocument?.url, '_blank')}
disabled={previewLoading}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document"
>
{previewLoading ? (
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<Eye className="w-5 h-5 text-blue-600" />
)}
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (proposalData.proposalDocument?.id) {
await downloadDocument(proposalData.proposalDocument.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
> >
<Download className="w-5 h-5 text-gray-600" /> <Eye className="w-4 h-4 mr-1" />
</button> View
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = proposalData.proposalDocument?.url || '';
link.download = proposalData.proposalDocument?.name || '';
link.click();
}}
>
<Download className="w-4 h-4 mr-1" />
Download
</Button>
</> </>
)} )}
</div> </div>
@ -445,41 +342,14 @@ export function InitiatorProposalApprovalModal({
<FileText className="w-5 h-5 text-gray-600" /> <FileText className="w-5 h-5 text-gray-600" />
<p className="text-sm font-medium text-gray-900">{doc.name}</p> <p className="text-sm font-medium text-gray-900">{doc.name}</p>
</div> </div>
{doc.id && ( {doc.url && (
<div className="flex items-center gap-1"> <Button
{canPreviewDocument(doc) && ( variant="ghost"
<button size="sm"
type="button" onClick={() => window.open(doc.url, '_blank')}
onClick={() => handlePreviewDocument(doc)} >
disabled={previewLoading} <Download className="w-4 h-4" />
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed" </Button>
title="Preview document"
>
{previewLoading ? (
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
) : (
<Eye className="w-5 h-5 text-blue-600" />
)}
</button>
)}
<button
type="button"
onClick={async () => {
try {
if (doc.id) {
await downloadDocument(doc.id);
}
} catch (error) {
console.error('Failed to download document:', error);
toast.error('Failed to download document');
}
}}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
title="Download document"
>
<Download className="w-5 h-5 text-gray-600" />
</button>
</div>
)} )}
</div> </div>
))} ))}
@ -567,110 +437,6 @@ export function InitiatorProposalApprovalModal({
</div> </div>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
{/* File Preview Modal - Matching DocumentsTab style */}
{previewDocument && (
<Dialog
open={!!previewDocument}
onOpenChange={() => setPreviewDocument(null)}
>
<DialogContent className="file-preview-dialog p-3 sm:p-6">
<div className="file-preview-content">
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
<div className="flex-1 min-w-0">
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
{previewDocument.name}
</DialogTitle>
{previewDocument.type && (
<p className="text-xs sm:text-sm text-gray-500">
{previewDocument.type} {previewDocument.size && `${(previewDocument.size / 1024).toFixed(1)} KB`}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2 flex-wrap mr-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const link = document.createElement('a');
link.href = previewDocument.url;
link.download = previewDocument.name;
link.click();
}}
className="gap-2 h-9"
>
<Download className="h-4 w-4" />
<span className="hidden sm:inline">Download</span>
</Button>
</div>
</div>
</DialogHeader>
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
{previewLoading ? (
<div className="flex items-center justify-center h-full min-h-[70vh]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
<p className="text-sm text-gray-600">Loading preview...</p>
</div>
</div>
) : previewDocument.name.toLowerCase().endsWith('.pdf') || previewDocument.type?.includes('pdf') ? (
<div className="w-full h-full flex items-center justify-center">
<iframe
src={previewDocument.url}
className="w-full h-full rounded-lg border-0"
title={previewDocument.name}
style={{
minHeight: '70vh',
height: '100%'
}}
/>
</div>
) : previewDocument.name.match(/\.(jpg|jpeg|png|gif|webp)$/i) || previewDocument.type?.includes('image') ? (
<div className="flex items-center justify-center h-full">
<img
src={previewDocument.url}
alt={previewDocument.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain'
}}
className="rounded-lg shadow-lg"
/>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
<Eye className="w-10 h-10 text-gray-400" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
<p className="text-sm text-gray-600 mb-6">
This file type cannot be previewed. Please download to view.
</p>
<Button
onClick={() => {
const link = document.createElement('a');
link.href = previewDocument.url;
link.download = previewDocument.name;
link.click();
}}
className="gap-2"
>
<Download className="h-4 w-4" />
Download {previewDocument.name}
</Button>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
</Dialog> </Dialog>
); );
} }

View File

@ -129,36 +129,54 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
accessDenied, accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user); } = useRequestDetails(requestIdentifier, dynamicRequests, user);
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number) // Determine if user is initiator
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
const currentUserEmail = (user as any)?.email?.toLowerCase() || ''; 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)
// Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber // Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber
const approvalFlow = apiRequest?.approvalFlow || []; const approvalFlow = apiRequest?.approvalFlow || [];
const approvals = apiRequest?.approvals || []; const approvals = apiRequest?.approvals || [];
// Find Department Lead step dynamically by levelName (handles step shifts when approvers are added) // Try to find Step 3 from approvalFlow first (has 'step' property), then from approvals (has 'levelNumber')
const deptLeadLevel = approvalFlow.find((level: any) => { const step3Level = approvalFlow.find((level: any) =>
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('department lead');
}) || approvals.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('department lead');
}) || approvalFlow.find((level: any) =>
(level.step || level.levelNumber || level.level_number) === 3 (level.step || level.levelNumber || level.level_number) === 3
) || approvals.find((level: any) => ) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3 (level.levelNumber || level.level_number) === 3
); // Fallback to step 3 for backwards compatibility );
const deptLeadUserId = deptLeadLevel?.approverId || deptLeadLevel?.approver_id || deptLeadLevel?.approver?.userId; const deptLeadUserId = step3Level?.approverId || step3Level?.approver_id || step3Level?.approver?.userId;
const deptLeadEmail = (deptLeadLevel?.approverEmail || deptLeadLevel?.approver_email || deptLeadLevel?.approver?.email || '').toLowerCase().trim(); const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver_email || step3Level?.approver?.email || '').toLowerCase().trim();
// User is department lead if they match the Department Lead approver (regardless of status or step number) // User is department lead if they match the Step 3 approver (regardless of status)
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) || const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail); (deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : '';
const isStep3PendingOrInProgress = step3Status === 'PENDING' ||
step3Status === 'IN_PROGRESS' ||
step3Status === 'PAUSED';
const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || apiRequest?.currentStep || 0;
const isStep3CurrentLevel = currentLevel === 3;
// User is current approver for Step 3 if Step 3 is active and they are the approver
const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && (
(deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail)
);
// IO tab visibility for dealer claims // IO tab visibility for dealer claims
// Show IO tab only for department lead (found dynamically, not hardcoded to step 3) // Show IO tab if user is initiator, department lead (Step 3 approver), or current Step 3 approver
const showIOTab = isDeptLead; // Also show if Step 3 has been approved (to view IO details)
const isStep3Approved = step3Status === 'APPROVED';
const showIOTab = isUserInitiator || isDeptLead || isStep3CurrentApprover || isStep3Approved;
const { const {
mergedMessages, mergedMessages,

View File

@ -63,7 +63,7 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
</Badge> </Badge>
{/* Template Type Badge */} {/* Template Type Badge */}
{(() => { {(() => {
const templateType = request.templateType || ''; const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType

View File

@ -17,7 +17,6 @@ export interface ClosedRequest {
department?: string; department?: string;
totalLevels?: number; totalLevels?: number;
completedLevels?: number; completedLevels?: number;
templateType?: string; // Template type for badge display
} }
export interface ClosedRequestsProps { export interface ClosedRequestsProps {

View File

@ -28,7 +28,6 @@ export function transformClosedRequest(r: any): ClosedRequest {
department: r.department, department: r.department,
totalLevels: r.totalLevels || 0, totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0, completedLevels: r.summary?.approvedLevels || 0,
templateType: r.templateType || r.template_type, // Template type for badge display
}; };
} }

View File

@ -19,14 +19,7 @@ export interface CreateClaimRequestPayload {
periodStartDate?: string; // ISO date string periodStartDate?: string; // ISO date string
periodEndDate?: string; // ISO date string periodEndDate?: string; // ISO date string
estimatedBudget?: string | number; estimatedBudget?: string | number;
approvers?: Array<{ selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
email: string;
name?: string;
userId?: string;
level: number;
tat?: number | string;
tatType?: 'hours' | 'days';
}>;
} }
export interface ClaimRequestResponse { export interface ClaimRequestResponse {