Compare commits

...

2 Commits

Author SHA1 Message Date
08374f9b04 build issue resolved 2025-12-19 21:53:24 +05:30
92b5584e22 we have aded the apad approvar in dealer claim 2025-12-19 21:31:35 +05:30
16 changed files with 2126 additions and 355 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,
selectedManagerEmail: selectedManagerEmail || undefined, approvers: claimData.approvers || [], // Pass approvers array
}; };
// Call API to create claim request // Call API to create claim request

View File

@ -6,7 +6,8 @@ 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;
@ -30,7 +31,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.percent !== undefined || sla.elapsedHours !== undefined); const hasValidSLA = sla && (sla.percentageUsed !== undefined || sla.elapsedHours !== undefined);
if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') { if (!hasValidSLA || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
return ( return (
@ -51,8 +52,7 @@ 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)
// Handle both full format (percentageUsed) and simplified format (percent) const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0;
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

@ -0,0 +1,573 @@
/**
* 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,10 +22,13 @@ 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;
@ -33,20 +36,29 @@ interface ClaimManagementWizardProps {
} }
const CLAIM_TYPES = [ const CLAIM_TYPES = [
'Marketing Activity', 'Riders Mania Claims',
'Promotional Event', 'Marketing Cost Bike to Vendor',
'Dealer Training', 'Media Bike Service',
'Infrastructure Development', 'ARAI Motorcycle Liquidation',
'Customer Experience Initiative', 'ARAI Certification STA Approval CNR',
'Service Campaign' 'Procurement of Spares/Apparel/GMA for Events',
'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);
@ -64,7 +76,16 @@ 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;
@ -87,7 +108,28 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}, []); }, []);
const updateFormData = (field: string, value: any) => { const updateFormData = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value })); setFormData(prev => {
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 = () => {
@ -101,6 +143,12 @@ 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;
@ -108,7 +156,28 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}; };
const nextStep = () => { const nextStep = () => {
if (currentStep < totalSteps && isStepValid()) { if (currentStep < totalSteps) {
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);
} }
}; };
@ -149,57 +218,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
status: 'pending', status: 'pending',
currentStep: 'initiator-review', currentStep: 'initiator-review',
workflowSteps: [ // Pass approvers array to backend
{ 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
@ -372,6 +392,8 @@ 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>
@ -384,6 +406,7 @@ 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'}
@ -395,17 +418,30 @@ 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) && (
<p className="text-xs text-gray-600 mt-2"> <div className="mt-2">
{formData.periodStartDate && formData.periodEndDate {formData.periodStartDate && formData.periodEndDate ? (
? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}` <p className="text-xs text-gray-600">
: 'Please select both start and end dates for the period'} Period: {format(formData.periodStartDate, 'MMM dd, yyyy')} - {format(formData.periodEndDate, 'MMM dd, yyyy')}
</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>
@ -413,6 +449,23 @@ 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 }}
@ -489,6 +542,55 @@ 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">
@ -656,8 +758,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? ( {currentStep < totalSteps ? (
<Button <Button
onClick={nextStep} onClick={nextStep}
disabled={!isStepValid()} className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
className="gap-2 w-full sm:w-auto order-1 sm:order-2" !isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
> >
Next Next
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />

View File

@ -10,6 +10,7 @@ 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';
@ -30,6 +31,7 @@ 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';
} }
@ -40,6 +42,7 @@ 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;
@ -48,15 +51,19 @@ 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 && existingBlockedAmount > 0) { if (internalOrder && existingIONumber) {
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
@ -67,6 +74,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
organizer?.email || organizer?.email ||
'Unknown User'; 'Unknown User';
// Set IO number and remark from existing data
setIoNumber(existingIONumber);
setIoRemark(existingIORemark);
// Only set blocked details if amount is blocked
if (existingBlockedAmount > 0) {
setBlockedDetails({ setBlockedDetails({
ioNumber: existingIONumber, ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0, blockedAmount: Number(existingBlockedAmount) || 0,
@ -75,17 +88,18 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(), blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: sapDocNumber, sapDocumentNumber: sapDocNumber,
ioRemark: existingIORemark,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' : status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed', internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
}); });
setIoNumber(existingIONumber);
// Set fetched amount if available balance exists // Set fetched amount if available balance exists
if (availableBeforeBlock > 0) { if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock); setFetchedAmount(availableBeforeBlock);
} }
} }
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]); }
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
@ -128,6 +142,43 @@ 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
*/ */
@ -142,34 +193,72 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
return; return;
} }
const blockAmount = parseFloat(amountToBlock); const blockAmountRaw = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) { if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 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
await updateIODetails(requestId, { // Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
const payload = {
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
ioAvailableBalance: fetchedAmount, ioAvailableBalance: fetchedAmount,
ioBlockedAmount: blockAmount, ioBlockedAmount: blockAmount,
ioRemainingBalance: fetchedAmount - blockAmount, ioRemainingBalance: fetchedAmount - blockAmount, // Calculated value (backend will use SAP's actual value)
}); };
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
@ -180,14 +269,17 @@ 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: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount), blockedAmount: savedBlockedAmount,
availableBalance: fetchedAmount, // Available amount before block availableBalance: fetchedAmount, // Available amount before block
remainingBalance: Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount)), remainingBalance: savedRemainingBalance,
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',
}; };
@ -247,6 +339,42 @@ 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 && (
<> <>
@ -333,6 +461,12 @@ 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,15 +172,11 @@ 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 () => {
// First check if request has approvalFlow // Always load from real API to get the latest data
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 {
@ -188,8 +184,12 @@ 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
const flows = approvals.map((level: any) => ({ // Include levelName and levelNumber for proper mapping
const flows = approvals
.map((level: any) => ({
step: level.levelNumber || level.level_number || 0, step: level.levelNumber || level.level_number || 0,
levelNumber: level.levelNumber || level.level_number || 0,
levelName: level.levelName || level.level_name,
approver: level.approverName || level.approver_name || '', approver: level.approverName || level.approver_name || '',
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(), approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
status: level.status?.toLowerCase() || 'waiting', status: level.status?.toLowerCase() || 'waiting',
@ -198,19 +198,47 @@ export function DealerClaimWorkflowTab({
approvedAt: level.actionDate || level.action_date, approvedAt: level.actionDate || level.action_date,
comment: level.comments || level.comment, comment: level.comments || level.comment,
levelId: level.levelId || level.level_id, levelId: level.levelId || level.level_id,
})); }))
setApprovalFlow(flows); // 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, refreshTrigger]); }, [request?.id, request?.requestId, request?.totalLevels, refreshTrigger]);
// Also reload when request.currentStep changes // Also reload when request.currentStep or totalLevels changes (to catch step transitions and new approvers)
useEffect(() => { useEffect(() => {
if (request?.id || request?.requestId) { if (request?.id || request?.requestId) {
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
@ -219,8 +247,11 @@ 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.map((level: any) => ({ const flows = approvals
.map((level: any) => ({
step: level.levelNumber || level.level_number || 0, step: level.levelNumber || level.level_number || 0,
levelNumber: level.levelNumber || level.level_number || 0,
levelName: level.levelName || level.level_name,
approver: level.approverName || level.approver_name || '', approver: level.approverName || level.approver_name || '',
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(), approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
status: level.status?.toLowerCase() || 'waiting', status: level.status?.toLowerCase() || 'waiting',
@ -229,7 +260,11 @@ export function DealerClaimWorkflowTab({
approvedAt: level.actionDate || level.action_date, approvedAt: level.actionDate || level.action_date,
comment: level.comments || level.comment, comment: level.comments || level.comment,
levelId: level.levelId || level.level_id, 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) {
@ -238,7 +273,7 @@ export function DealerClaimWorkflowTab({
}; };
loadApprovalFlows(); loadApprovalFlows();
} }
}, [request?.currentStep]); }, [request?.currentStep, request?.totalLevels]);
// Enhanced refresh handler that also reloads approval flows // Enhanced refresh handler that also reloads approval flows
const handleRefresh = () => { const handleRefresh = () => {
@ -246,36 +281,128 @@ 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) => {
const stepTitles = [ // Get actual step number from levelNumber or step field
'Dealer - Proposal Submission', const actualStepNumber = step.levelNumber || step.level_number || step.step || index + 1;
'Requestor Evaluation & Confirmation',
'Dept Lead Approval',
'Activity Creation',
'Dealer - Completion Documents',
'Requestor - Claim Approval',
'E-Invoice Generation',
'Credit Note from SAP',
];
const stepDescriptions = [ // Get levelName from the approval level if available
'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests', const levelName = step.levelName || step.level_name;
'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 (Step 3) // Extract IO details from internalOrder table (Department Lead step - check by levelName)
let ioDetails = undefined; let ioDetails = undefined;
if (step.step === 3) { const isDeptLeadStep = levelName && levelName.toLowerCase().includes('department lead');
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;
@ -314,7 +441,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 (step.step === 6) { if (actualStepNumber === 6) {
if (approval?.dmsDetails) { if (approval?.dmsDetails) {
dmsDetails = { dmsDetails = {
dmsNumber: approval.dmsDetails.dmsNumber || '', dmsNumber: approval.dmsDetails.dmsNumber || '',
@ -343,18 +470,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: step.step || index + 1, step: actualStepNumber,
title: stepTitles[index] || `Step ${step.step || index + 1}`, title: getStepTitle(actualStepNumber, levelName, approverName),
approver: step.approver || 'Unknown', approver: approverName,
description: stepDescriptions[index] || step.description || '', description: getStepDescription(actualStepNumber, levelName, approverName) || 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,
@ -362,21 +489,33 @@ export function DealerClaimWorkflowTab({
elapsedHours, // Only non-zero for active/completed steps elapsedHours, // Only non-zero for active/completed steps
ioDetails, ioDetails,
dmsDetails, dmsDetails,
einvoiceUrl: step.step === 7 ? (approval as any)?.einvoiceUrl : undefined, einvoiceUrl: actualStepNumber === 7 ? (approval as any)?.einvoiceUrl : undefined,
emailTemplateUrl: step.step === 4 ? (approval as any)?.emailTemplateUrl : undefined, emailTemplateUrl: (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
// If no pending/in_progress step, use the request's currentStep // IMPORTANT: Use the workflow's currentLevel from backend (most accurate)
// 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 activeStep = workflowSteps.find(s => { const backendCurrentLevel = request?.currentLevel || request?.current_level || request?.currentStep;
// Find the step that matches backend's currentLevel
const activeStepFromBackend = workflowSteps.find(s => s.step === backendCurrentLevel);
// 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() || ''; const status = s.status?.toLowerCase() || '';
return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review'; return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review';
}); });
const currentStep = activeStep ? activeStep.step : (request?.currentStep || 1);
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() || '';
@ -396,8 +535,30 @@ export function DealerClaimWorkflowTab({
const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase(); const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase();
const isCurrentApprover = approverEmail && userEmail === approverEmail; const isCurrentApprover = approverEmail && userEmail === approverEmail;
// Check if user is approver for step 2 (requestor evaluation) - match by email // Find the initiator's step dynamically (Requestor Evaluation step)
const step2Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 2); // This handles cases where approvers are added between steps, causing step numbers to shift
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;
@ -406,9 +567,12 @@ export function DealerClaimWorkflowTab({
const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase(); const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase();
const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail; const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail;
// Check if user is approver for step 3 (department lead approval) - match by email // Find Department Lead step dynamically (handles step shifts)
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3); const deptLeadStepLevel = approvalFlow.find((l: any) => {
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase(); const levelName = (l.levelName || '').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
@ -469,20 +633,39 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find Step 2 levelId // Get approval levels to find the initiator's step levelId dynamically
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) =>
(level.levelNumber || level.level_number) === 2 // 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
); // Final fallback to level 2
if (!step2Level?.levelId && !step2Level?.level_id) { if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Step 2 approval level not found'); throw new Error('Initiator approval level not found');
} }
const levelId = step2Level.levelId || step2Level.level_id; const levelId = step2Level.levelId || step2Level.level_id;
// Approve Step 2 using real API // Approve the initiator's step 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
@ -505,20 +688,39 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find Step 2 levelId // Get approval levels to find the initiator's step levelId dynamically
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) =>
(level.levelNumber || level.level_number) === 2 // 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
); // Final fallback to level 2
if (!step2Level?.levelId && !step2Level?.level_id) { if (!step2Level?.levelId && !step2Level?.level_id) {
throw new Error('Step 2 approval level not found'); throw new Error('Initiator approval level not found');
} }
const levelId = step2Level.levelId || step2Level.level_id; const levelId = step2Level.levelId || step2Level.level_id;
// Reject Step 2 using real API // Reject the initiator's step 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
@ -532,7 +734,7 @@ export function DealerClaimWorkflowTab({
} }
}; };
// Handle IO approval (Step 3) // Handle IO approval (Department Lead step - found dynamically)
const handleIOApproval = async (data: { const handleIOApproval = async (data: {
ioNumber: string; ioNumber: string;
ioRemark: string; ioRemark: string;
@ -545,15 +747,20 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find Step 3 levelId // Get approval levels to find Department Lead step levelId dynamically
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('Step 3 approval level not found'); throw new Error('Department Lead approval level not found');
} }
const levelId = step3Level.levelId || step3Level.level_id; const levelId = step3Level.levelId || step3Level.level_id;
@ -675,20 +882,25 @@ export function DealerClaimWorkflowTab({
const requestId = request.id || request.requestId; const requestId = request.id || request.requestId;
// Get approval levels to find Step 3 levelId // Get approval levels to find Department Lead step levelId dynamically
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('Step 3 approval level not found'); throw new Error('Department Lead approval level not found');
} }
const levelId = step3Level.levelId || step3Level.level_id; const levelId = step3Level.levelId || step3Level.level_id;
// Reject Step 3 using real API // Reject Department Lead step 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
@ -826,8 +1038,16 @@ 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 it's pending or in_progress and matches currentStep // Step is active if:
const isActive = (step.status === 'pending' || step.status === 'in_progress') && step.step === currentStep; // 1. It's pending or in_progress
// 2. AND it matches currentStep (from backend or calculated)
// 3. AND it's the actual current step (not a future step that happens to be pending)
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
@ -865,8 +1085,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 (Step 4) - Show when approved */} {/* Email Template Button - Show when step has emailTemplateUrl and is approved */}
{step.step === 4 && step.status === 'approved' && ( {step.emailTemplateUrl && step.status === 'approved' && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -1078,7 +1298,23 @@ export function DealerClaimWorkflowTab({
)} )}
{/* Action Buttons */} {/* Action Buttons */}
{isActive && ( {/* Only show action buttons if:
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) && (
@ -1093,8 +1329,9 @@ export function DealerClaimWorkflowTab({
</Button> </Button>
)} )}
{/* Step 2: Confirm Request - Only for initiator or step 2 approver */} {/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
{step.step === 2 && (isInitiator || isStep2Approver) && ( {/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
{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={() => {
@ -1102,31 +1339,77 @@ export function DealerClaimWorkflowTab({
}} }}
> >
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
Confirm Request Review Request
</Button> </Button>
)} )}
{/* Step 3: Approve and Organise IO - Only for department lead (step 3 approver) */} {/* Department Lead Step: Approve and Organise IO - Find dynamically by levelName */}
{step.step === 3 && (() => { {(() => {
// Find step 3 from approvalFlow to get approverEmail // Find Department Lead step dynamically (handles step shifts)
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3); const deptLeadStepLevel = approvalFlow.find((l: any) => {
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase(); const levelName = (l.levelName || '').toLowerCase();
const isStep3ApproverByEmail = step3ApproverEmail && userEmail === step3ApproverEmail; return levelName.includes('department lead');
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 <Button
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => { onClick={() => {
setShowIOApprovalModal(true); setShowIOApprovalModal(true);
}} }}
disabled={!hasIONumber}
> >
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
Approve and Organise IO Approve and Organise IO
</Button> </Button>
)} </div>
);
})()}
{/* Step 5: Upload Completion Documents - Only for dealer */} {/* Step 5 (or shifted step): Upload Completion Documents - Only for dealer */}
{step.step === 5 && isDealer && ( {/* 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 <Button
className="bg-purple-600 hover:bg-purple-700" className="bg-purple-600 hover:bg-purple-700"
onClick={() => { onClick={() => {
@ -1226,6 +1509,7 @@ 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'; status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED';
}; };
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 } from 'react'; import { useState, useRef, useMemo, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,8 +18,9 @@ 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 } from 'lucide-react'; import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download } 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;
@ -63,12 +64,64 @@ 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);
@ -214,6 +267,11 @@ 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([]);
@ -417,17 +475,41 @@ 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">
{canPreviewFile(file) && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2" className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
onClick={() => handlePreviewFile(file)}
title="Preview file"
>
<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)} onClick={() => handleRemoveCompletionDoc(index)}
title="Remove document" title="Remove document"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
@ -498,17 +580,41 @@ 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">
{canPreviewFile(file) && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2" className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
onClick={() => handlePreviewFile(file)}
title="Preview photo"
>
<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)} onClick={() => handleRemovePhoto(index)}
title="Remove photo" title="Remove photo"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
@ -587,17 +693,41 @@ 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">
{canPreviewFile(file) && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2" className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
onClick={() => handlePreviewFile(file)}
title="Preview file"
>
<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)} onClick={() => handleRemoveInvoice(index)}
title="Remove document" title="Remove document"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
@ -662,11 +792,34 @@ 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">
{canPreviewFile(attendanceSheet) && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2" className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
onClick={() => handlePreviewFile(attendanceSheet)}
title="Preview file"
>
<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(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={() => { onClick={() => {
setAttendanceSheet(null); setAttendanceSheet(null);
if (attendanceInputRef.current) attendanceInputRef.current.value = ''; if (attendanceInputRef.current) attendanceInputRef.current.value = '';
@ -677,6 +830,7 @@ export function DealerCompletionDocumentsModal({
</Button> </Button>
</div> </div>
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>
@ -728,6 +882,96 @@ 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 } from 'react'; import { useState, useRef, useMemo, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -18,8 +18,9 @@ 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 } from 'lucide-react'; import { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText, Eye, Download } 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;
@ -60,10 +61,63 @@ 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);
@ -164,6 +218,11 @@ 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');
@ -243,17 +302,41 @@ 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"> <span className="text-xs text-green-600 mb-2">
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" />
@ -467,17 +550,41 @@ export function DealerProposalSubmissionModal({
{file.name} {file.name}
</span> </span>
</div> </div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
{canPreviewFile(file) && (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2" className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
onClick={() => handlePreviewFile(file)}
title="Preview file"
>
<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)} onClick={() => handleRemoveOtherDoc(index)}
title="Remove document" title="Remove document"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
)} )}
@ -531,6 +638,96 @@ 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,6 +35,7 @@ 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;
} }
@ -47,6 +48,7 @@ export function DeptLeadIOApprovalModal({
requestTitle, requestTitle,
requestId: _requestId, requestId: _requestId,
preFilledIONumber, preFilledIONumber,
preFilledIORemark,
preFilledBlockedAmount, preFilledBlockedAmount,
preFilledRemainingBalance, preFilledRemainingBalance,
}: DeptLeadIOApprovalModalProps) { }: DeptLeadIOApprovalModalProps) {
@ -61,12 +63,12 @@ export function DeptLeadIOApprovalModal({
// Reset form when modal opens/closes // Reset form when modal opens/closes
React.useEffect(() => { React.useEffect(() => {
if (isOpen) { if (isOpen) {
// Reset form when modal opens // Prefill IO remark from props if available
setIoRemark(''); setIoRemark(preFilledIORemark || '');
setComments(''); setComments('');
setActionType('approve'); setActionType('approve');
} }
}, [isOpen]); }, [isOpen, preFilledIORemark]);
const ioRemarkChars = ioRemark.length; const ioRemarkChars = ioRemark.length;
const commentsChars = comments.length; const commentsChars = comments.length;
@ -269,7 +271,7 @@ export function DeptLeadIOApprovalModal({
)} )}
</div> </div>
{/* IO Remark - Only editable field */} {/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
<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>
@ -284,11 +286,18 @@ export function DeptLeadIOApprovalModal({
setIoRemark(value); setIoRemark(value);
} }
}} }}
rows={2} rows={3}
className="bg-white text-sm min-h-[60px] resize-none" className="bg-white text-sm min-h-[80px] resize-none"
disabled={false}
readOnly={false}
/> />
<div className="flex justify-end text-xs text-gray-600"> <div className="flex items-center justify-between text-xs">
<span>{ioRemarkChars}/{maxIoRemarkChars}</span> {preFilledIORemark && (
<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 } from 'react'; import { useState, useMemo, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -25,8 +25,11 @@ 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;
@ -75,6 +78,13 @@ 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(() => {
@ -110,6 +120,88 @@ 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');
@ -216,30 +308,41 @@ export function InitiatorProposalApprovalModal({
)} )}
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex items-center gap-2">
{proposalData.proposalDocument.url && ( {proposalData.proposalDocument.id && (
<> <>
<Button {canPreviewDocument(proposalData.proposalDocument) && (
variant="outline" <button
size="sm" type="button"
onClick={() => window.open(proposalData.proposalDocument?.url, '_blank')} onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
disabled={previewLoading}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document"
> >
<Eye className="w-4 h-4 mr-1" /> {previewLoading ? (
View <Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
</Button> ) : (
<Button <Eye className="w-5 h-5 text-blue-600" />
variant="outline" )}
size="sm" </button>
onClick={() => { )}
const link = document.createElement('a'); <button
link.href = proposalData.proposalDocument?.url || ''; type="button"
link.download = proposalData.proposalDocument?.name || ''; onClick={async () => {
link.click(); 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-4 h-4 mr-1" /> <Download className="w-5 h-5 text-gray-600" />
Download </button>
</Button>
</> </>
)} )}
</div> </div>
@ -342,14 +445,41 @@ 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.url && ( {doc.id && (
<Button <div className="flex items-center gap-1">
variant="ghost" {canPreviewDocument(doc) && (
size="sm" <button
onClick={() => window.open(doc.url, '_blank')} type="button"
onClick={() => handlePreviewDocument(doc)}
disabled={previewLoading}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Preview document"
> >
<Download className="w-4 h-4" /> {previewLoading ? (
</Button> <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>
))} ))}
@ -437,6 +567,110 @@ 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,54 +129,36 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
accessDenied, accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user); } = useRequestDetails(requestIdentifier, dynamicRequests, user);
// Determine if user is initiator // Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
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 || [];
// Try to find Step 3 from approvalFlow first (has 'step' property), then from approvals (has 'levelNumber') // Find Department Lead step dynamically by levelName (handles step shifts when approvers are added)
const step3Level = approvalFlow.find((level: any) => const deptLeadLevel = 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 = step3Level?.approverId || step3Level?.approver_id || step3Level?.approver?.userId; const deptLeadUserId = deptLeadLevel?.approverId || deptLeadLevel?.approver_id || deptLeadLevel?.approver?.userId;
const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver_email || step3Level?.approver?.email || '').toLowerCase().trim(); const deptLeadEmail = (deptLeadLevel?.approverEmail || deptLeadLevel?.approver_email || deptLeadLevel?.approver?.email || '').toLowerCase().trim();
// User is department lead if they match the Step 3 approver (regardless of status) // User is department lead if they match the Department Lead approver (regardless of status or step number)
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 if user is initiator, department lead (Step 3 approver), or current Step 3 approver // Show IO tab only for department lead (found dynamically, not hardcoded to step 3)
// Also show if Step 3 has been approved (to view IO details) const showIOTab = isDeptLead;
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 as any)?.templateType || (request as any)?.template_type || ''; const templateType = request.templateType || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType

View File

@ -17,6 +17,7 @@ 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,6 +28,7 @@ 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,7 +19,14 @@ 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;
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one approvers?: Array<{
email: string;
name?: string;
userId?: string;
level: number;
tat?: number | string;
tatType?: 'hours' | 'days';
}>;
} }
export interface ClaimRequestResponse { export interface ClaimRequestResponse {