Compare commits
2 Commits
bbae59e271
...
08374f9b04
| Author | SHA1 | Date | |
|---|---|---|---|
| 08374f9b04 | |||
| 92b5584e22 |
@ -275,7 +275,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
setApprovalAction(null);
|
||||
};
|
||||
|
||||
const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
|
||||
const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => {
|
||||
try {
|
||||
// Prepare payload for API
|
||||
const payload = {
|
||||
@ -292,7 +292,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
||||
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
||||
estimatedBudget: claimData.estimatedBudget || undefined,
|
||||
selectedManagerEmail: selectedManagerEmail || undefined,
|
||||
approvers: claimData.approvers || [], // Pass approvers array
|
||||
};
|
||||
|
||||
// Call API to create claim request
|
||||
|
||||
@ -6,7 +6,8 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
export interface SLAData {
|
||||
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
|
||||
percentageUsed: number;
|
||||
percentageUsed?: number;
|
||||
percent?: number; // Simplified format (alternative to percentageUsed)
|
||||
elapsedText: string;
|
||||
elapsedHours: number;
|
||||
remainingText: string;
|
||||
@ -30,7 +31,7 @@ export function SLAProgressBar({
|
||||
// Pure presentational component - no business logic
|
||||
// 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)
|
||||
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') {
|
||||
return (
|
||||
@ -51,8 +52,7 @@ export function SLAProgressBar({
|
||||
// Use percentage-based colors to match approver SLA tracker
|
||||
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
|
||||
// Grey: When paused (frozen state)
|
||||
// Handle both full format (percentageUsed) and simplified format (percent)
|
||||
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : (sla.percent || 0);
|
||||
const percentageUsed = sla.percentageUsed !== undefined ? sla.percentageUsed : 0;
|
||||
const rawStatus = sla.status || 'on_track';
|
||||
|
||||
// Determine colors based on percentage (matching ApprovalStepCard logic)
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,10 +22,13 @@ import {
|
||||
CheckCircle,
|
||||
Info,
|
||||
FileText,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
||||
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
interface ClaimManagementWizardProps {
|
||||
onBack?: () => void;
|
||||
@ -33,20 +36,29 @@ interface ClaimManagementWizardProps {
|
||||
}
|
||||
|
||||
const CLAIM_TYPES = [
|
||||
'Marketing Activity',
|
||||
'Promotional Event',
|
||||
'Dealer Training',
|
||||
'Infrastructure Development',
|
||||
'Customer Experience Initiative',
|
||||
'Service Campaign'
|
||||
'Riders Mania Claims',
|
||||
'Marketing Cost – Bike to Vendor',
|
||||
'Media Bike Service',
|
||||
'ARAI Motorcycle Liquidation',
|
||||
'ARAI Certification – STA Approval CNR',
|
||||
'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 = [
|
||||
'Claim Details',
|
||||
'Approver Selection',
|
||||
'Review & Submit'
|
||||
];
|
||||
|
||||
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
||||
const { user } = useAuth();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
||||
const [loadingDealers, setLoadingDealers] = useState(true);
|
||||
@ -64,7 +76,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
requestDescription: '',
|
||||
periodStartDate: 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;
|
||||
@ -87,7 +108,28 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
}, []);
|
||||
|
||||
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 = () => {
|
||||
@ -101,6 +143,12 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
formData.location &&
|
||||
formData.requestDescription;
|
||||
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;
|
||||
default:
|
||||
return false;
|
||||
@ -108,7 +156,28 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
@ -149,57 +218,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
submittedAt: new Date().toISOString(),
|
||||
status: 'pending',
|
||||
currentStep: 'initiator-review',
|
||||
workflowSteps: [
|
||||
{
|
||||
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'
|
||||
}
|
||||
]
|
||||
// Pass approvers array to backend
|
||||
approvers: formData.approvers || []
|
||||
};
|
||||
|
||||
// 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}
|
||||
onSelect={(date) => updateFormData('periodStartDate', date)}
|
||||
initialFocus
|
||||
// Maximum date is the end date (if selected)
|
||||
toDate={formData.periodEndDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@ -384,6 +406,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left mt-2 h-12"
|
||||
disabled={!formData.periodStartDate}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
|
||||
@ -395,17 +418,30 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
selected={formData.periodEndDate}
|
||||
onSelect={(date) => updateFormData('periodEndDate', date)}
|
||||
initialFocus
|
||||
// Minimum date is the start date (if selected)
|
||||
fromDate={formData.periodStartDate || undefined}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!formData.periodStartDate && (
|
||||
<p className="text-xs text-gray-500 mt-1">Please select start date first</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(formData.periodStartDate || formData.periodEndDate) && (
|
||||
<p className="text-xs text-gray-600 mt-2">
|
||||
{formData.periodStartDate && formData.periodEndDate
|
||||
? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}`
|
||||
: 'Please select both start and end dates for the period'}
|
||||
<div className="mt-2">
|
||||
{formData.periodStartDate && formData.periodEndDate ? (
|
||||
<p className="text-xs text-gray-600">
|
||||
Period: {format(formData.periodStartDate, 'MMM dd, yyyy')} - {format(formData.periodEndDate, 'MMM dd, yyyy')}
|
||||
</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>
|
||||
@ -413,6 +449,23 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
);
|
||||
|
||||
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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@ -489,6 +542,55 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
</CardContent>
|
||||
</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 */}
|
||||
<Card className="border-2">
|
||||
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
|
||||
@ -656,8 +758,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
||||
{currentStep < totalSteps ? (
|
||||
<Button
|
||||
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
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
|
||||
@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@ -30,6 +31,7 @@ interface IOBlockedDetails {
|
||||
blockedDate: string;
|
||||
blockedBy: string; // User who blocked
|
||||
sapDocumentNumber: string;
|
||||
ioRemark?: string; // IO remark
|
||||
status: 'blocked' | 'released' | 'failed';
|
||||
}
|
||||
|
||||
@ -40,6 +42,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
// Load existing IO data from apiRequest or request
|
||||
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
|
||||
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
||||
const existingIORemark = internalOrder?.ioRemark || internalOrder?.io_remark || '';
|
||||
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
||||
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
|
||||
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
||||
@ -48,15 +51,19 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
const organizer = internalOrder?.organizer || null;
|
||||
|
||||
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
||||
const [ioRemark, setIoRemark] = useState(existingIORemark);
|
||||
const [fetchingAmount, setFetchingAmount] = useState(false);
|
||||
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
||||
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
||||
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
||||
|
||||
const maxIoRemarkChars = 300;
|
||||
const ioRemarkChars = ioRemark.length;
|
||||
|
||||
// Load existing IO block details from apiRequest
|
||||
useEffect(() => {
|
||||
if (internalOrder && existingIONumber && existingBlockedAmount > 0) {
|
||||
if (internalOrder && existingIONumber) {
|
||||
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
|
||||
// Get blocked by user name from organizer association (who blocked the amount)
|
||||
// When amount is blocked, organizedBy stores the user who blocked it
|
||||
@ -67,6 +74,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
organizer?.email ||
|
||||
'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({
|
||||
ioNumber: existingIONumber,
|
||||
blockedAmount: Number(existingBlockedAmount) || 0,
|
||||
@ -75,17 +88,18 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
||||
blockedBy: blockedByName,
|
||||
sapDocumentNumber: sapDocNumber,
|
||||
ioRemark: existingIORemark,
|
||||
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
||||
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
||||
});
|
||||
setIoNumber(existingIONumber);
|
||||
|
||||
// Set fetched amount if available balance exists
|
||||
if (availableBeforeBlock > 0) {
|
||||
setFetchedAmount(availableBeforeBlock);
|
||||
}
|
||||
}
|
||||
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
||||
}
|
||||
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@ -142,34 +193,72 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
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');
|
||||
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) {
|
||||
toast.error('Amount to block exceeds available IO budget');
|
||||
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);
|
||||
try {
|
||||
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
|
||||
// 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(),
|
||||
ioRemark: ioRemark.trim(),
|
||||
ioAvailableBalance: fetchedAmount,
|
||||
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
|
||||
const claimData = await getClaimDetails(requestId);
|
||||
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
||||
|
||||
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;
|
||||
// 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
|
||||
@ -180,14 +269,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
currentUser?.email ||
|
||||
'Current User';
|
||||
|
||||
const savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
|
||||
|
||||
const blocked: IOBlockedDetails = {
|
||||
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
||||
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
|
||||
blockedAmount: savedBlockedAmount,
|
||||
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(),
|
||||
blockedBy: blockedByName,
|
||||
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
||||
ioRemark: savedIoRemark,
|
||||
status: 'blocked',
|
||||
};
|
||||
|
||||
@ -247,6 +339,42 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||
</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 */}
|
||||
{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-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
||||
</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">
|
||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
||||
<p className="text-xl font-bold text-green-700">
|
||||
|
||||
@ -172,15 +172,11 @@ export function DealerClaimWorkflowTab({
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
|
||||
// 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(() => {
|
||||
const loadApprovalFlows = async () => {
|
||||
// First check if request has approvalFlow
|
||||
if (request?.approvalFlow && request.approvalFlow.length > 0) {
|
||||
setApprovalFlow(request.approvalFlow);
|
||||
return;
|
||||
}
|
||||
|
||||
// Load from real API
|
||||
// Always load from real API to get the latest data
|
||||
if (request?.id || request?.requestId) {
|
||||
const requestId = request.id || request.requestId;
|
||||
try {
|
||||
@ -188,8 +184,12 @@ export function DealerClaimWorkflowTab({
|
||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||
if (approvals && approvals.length > 0) {
|
||||
// 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,
|
||||
levelNumber: level.levelNumber || level.level_number || 0,
|
||||
levelName: level.levelName || level.level_name,
|
||||
approver: level.approverName || level.approver_name || '',
|
||||
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
|
||||
status: level.status?.toLowerCase() || 'waiting',
|
||||
@ -198,19 +198,47 @@ export function DealerClaimWorkflowTab({
|
||||
approvedAt: level.actionDate || level.action_date,
|
||||
comment: level.comments || level.comment,
|
||||
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) {
|
||||
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();
|
||||
}, [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(() => {
|
||||
if (request?.id || request?.requestId) {
|
||||
const requestId = request.id || request.requestId;
|
||||
@ -219,8 +247,11 @@ export function DealerClaimWorkflowTab({
|
||||
const details = await getWorkflowDetails(requestId);
|
||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||
if (approvals && approvals.length > 0) {
|
||||
const flows = approvals.map((level: any) => ({
|
||||
const flows = approvals
|
||||
.map((level: any) => ({
|
||||
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 || '',
|
||||
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
|
||||
status: level.status?.toLowerCase() || 'waiting',
|
||||
@ -229,7 +260,11 @@ export function DealerClaimWorkflowTab({
|
||||
approvedAt: level.actionDate || level.action_date,
|
||||
comment: level.comments || level.comment,
|
||||
levelId: level.levelId || level.level_id,
|
||||
}));
|
||||
}))
|
||||
// Sort by levelNumber to ensure correct order
|
||||
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0));
|
||||
|
||||
// Update state with new flows
|
||||
setApprovalFlow(flows);
|
||||
}
|
||||
} catch (error) {
|
||||
@ -238,7 +273,7 @@ export function DealerClaimWorkflowTab({
|
||||
};
|
||||
loadApprovalFlows();
|
||||
}
|
||||
}, [request?.currentStep]);
|
||||
}, [request?.currentStep, request?.totalLevels]);
|
||||
|
||||
// Enhanced refresh handler that also reloads approval flows
|
||||
const handleRefresh = () => {
|
||||
@ -246,36 +281,128 @@ export function DealerClaimWorkflowTab({
|
||||
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
|
||||
const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => {
|
||||
const stepTitles = [
|
||||
'Dealer - Proposal Submission',
|
||||
'Requestor Evaluation & Confirmation',
|
||||
'Dept Lead Approval',
|
||||
'Activity Creation',
|
||||
'Dealer - Completion Documents',
|
||||
'Requestor - Claim Approval',
|
||||
'E-Invoice Generation',
|
||||
'Credit Note from SAP',
|
||||
];
|
||||
// Get actual step number from levelNumber or step field
|
||||
const actualStepNumber = step.levelNumber || step.level_number || step.step || index + 1;
|
||||
|
||||
const stepDescriptions = [
|
||||
'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
|
||||
'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
|
||||
'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
|
||||
'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
|
||||
'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
|
||||
'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
|
||||
'E-invoice will be generated through DMS.',
|
||||
'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
|
||||
];
|
||||
// Get levelName from the approval level if available
|
||||
const levelName = step.levelName || step.level_name;
|
||||
|
||||
// Find approval data for this step
|
||||
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;
|
||||
if (step.step === 3) {
|
||||
const isDeptLeadStep = levelName && levelName.toLowerCase().includes('department lead');
|
||||
if (isDeptLeadStep || actualStepNumber === 3) {
|
||||
// Get IO details from dedicated internalOrder table
|
||||
const internalOrder = request?.internalOrder || request?.internal_order;
|
||||
|
||||
@ -314,7 +441,7 @@ export function DealerClaimWorkflowTab({
|
||||
|
||||
// Extract DMS details from approval data (Step 6)
|
||||
let dmsDetails = undefined;
|
||||
if (step.step === 6) {
|
||||
if (actualStepNumber === 6) {
|
||||
if (approval?.dmsDetails) {
|
||||
dmsDetails = {
|
||||
dmsNumber: approval.dmsDetails.dmsNumber || '',
|
||||
@ -343,18 +470,18 @@ export function DealerClaimWorkflowTab({
|
||||
// Waiting steps (future steps) should have elapsedHours = 0
|
||||
// This ensures that when in step 1, only step 1 shows elapsed time, others show 0
|
||||
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
|
||||
// For waiting steps, elapsedHours should be 0 (they haven't started yet)
|
||||
const elapsedHours = isWaiting ? 0 : (step.elapsedHours || 0);
|
||||
|
||||
const approverName = step.approver || step.approverName || 'Unknown';
|
||||
|
||||
return {
|
||||
step: step.step || index + 1,
|
||||
title: stepTitles[index] || `Step ${step.step || index + 1}`,
|
||||
approver: step.approver || 'Unknown',
|
||||
description: stepDescriptions[index] || step.description || '',
|
||||
step: actualStepNumber,
|
||||
title: getStepTitle(actualStepNumber, levelName, approverName),
|
||||
approver: approverName,
|
||||
description: getStepDescription(actualStepNumber, levelName, approverName) || step.description || '',
|
||||
tatHours: step.tatHours || 24,
|
||||
status: normalizedStatus as any,
|
||||
comment: step.comment || approval?.comment,
|
||||
@ -362,21 +489,33 @@ export function DealerClaimWorkflowTab({
|
||||
elapsedHours, // Only non-zero for active/completed steps
|
||||
ioDetails,
|
||||
dmsDetails,
|
||||
einvoiceUrl: step.step === 7 ? (approval as any)?.einvoiceUrl : undefined,
|
||||
emailTemplateUrl: step.step === 4 ? (approval as any)?.emailTemplateUrl : undefined,
|
||||
einvoiceUrl: actualStepNumber === 7 ? (approval as any)?.einvoiceUrl : undefined,
|
||||
emailTemplateUrl: (approval as any)?.emailTemplateUrl || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const totalSteps = request?.totalSteps || 8;
|
||||
|
||||
// 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
|
||||
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() || '';
|
||||
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)
|
||||
const userEmail = (user as any)?.email?.toLowerCase() || '';
|
||||
@ -396,8 +535,30 @@ export function DealerClaimWorkflowTab({
|
||||
const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase();
|
||||
const isCurrentApprover = approverEmail && userEmail === approverEmail;
|
||||
|
||||
// Check if user is approver for step 2 (requestor evaluation) - match by email
|
||||
const step2Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 2);
|
||||
// Find the initiator's step dynamically (Requestor Evaluation step)
|
||||
// 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 isStep2Approver = step2ApproverEmail && userEmail === step2ApproverEmail;
|
||||
|
||||
@ -406,9 +567,12 @@ export function DealerClaimWorkflowTab({
|
||||
const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase();
|
||||
const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail;
|
||||
|
||||
// Check if user is approver for step 3 (department lead approval) - match by email
|
||||
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
|
||||
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
|
||||
// Find Department Lead step dynamically (handles step shifts)
|
||||
const deptLeadStepLevel = approvalFlow.find((l: any) => {
|
||||
const levelName = (l.levelName || '').toLowerCase();
|
||||
return levelName.includes('department lead');
|
||||
});
|
||||
const step3ApproverEmail = (deptLeadStepLevel?.approverEmail || '').toLowerCase();
|
||||
const isStep3Approver = step3ApproverEmail && userEmail === step3ApproverEmail;
|
||||
|
||||
// Handle proposal submission
|
||||
@ -469,20 +633,39 @@ export function DealerClaimWorkflowTab({
|
||||
|
||||
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 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) {
|
||||
throw new Error('Step 2 approval level not found');
|
||||
throw new Error('Initiator approval level not found');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// 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 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) {
|
||||
throw new Error('Step 2 approval level not found');
|
||||
throw new Error('Initiator approval level not found');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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: {
|
||||
ioNumber: string;
|
||||
ioRemark: string;
|
||||
@ -545,15 +747,20 @@ export function DealerClaimWorkflowTab({
|
||||
|
||||
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 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
|
||||
);
|
||||
); // Fallback to level 3
|
||||
|
||||
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;
|
||||
@ -675,20 +882,25 @@ export function DealerClaimWorkflowTab({
|
||||
|
||||
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 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
|
||||
);
|
||||
); // Fallback to level 3
|
||||
|
||||
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;
|
||||
|
||||
// Reject Step 3 using real API
|
||||
// Reject Department Lead step using real API
|
||||
await rejectLevel(requestId, levelId, 'Dept Lead rejected - More clarification required', comments);
|
||||
|
||||
// Activity is logged by backend approval service - no need to create work note
|
||||
@ -826,8 +1038,16 @@ export function DealerClaimWorkflowTab({
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{workflowSteps.map((step, index) => {
|
||||
// Step is active if it's pending or in_progress and matches currentStep
|
||||
const isActive = (step.status === 'pending' || step.status === 'in_progress') && step.step === currentStep;
|
||||
// Step is active if:
|
||||
// 1. It's pending or in_progress
|
||||
// 2. AND it matches currentStep (from backend or calculated)
|
||||
// 3. AND it's the actual current step (not a future step that happens to be pending)
|
||||
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';
|
||||
|
||||
// Find approval data for this step to get SLA information
|
||||
@ -865,8 +1085,8 @@ export function DealerClaimWorkflowTab({
|
||||
<Badge className={getStepBadgeVariant(step.status)}>
|
||||
{step.status.toLowerCase()}
|
||||
</Badge>
|
||||
{/* Email Template Button (Step 4) - Show when approved */}
|
||||
{step.step === 4 && step.status === 'approved' && (
|
||||
{/* Email Template Button - Show when step has emailTemplateUrl and is approved */}
|
||||
{step.emailTemplateUrl && step.status === 'approved' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@ -1078,7 +1298,23 @@ export function DealerClaimWorkflowTab({
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
|
||||
{step.step === 1 && (isDealer || isStep1Approver) && (
|
||||
@ -1093,8 +1329,9 @@ export function DealerClaimWorkflowTab({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Step 2: Confirm Request - Only for initiator or step 2 approver */}
|
||||
{step.step === 2 && (isInitiator || isStep2Approver) && (
|
||||
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
||||
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
|
||||
{step.step === initiatorStepNumber && (isInitiator || isStep2Approver) && (
|
||||
<Button
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
onClick={() => {
|
||||
@ -1102,31 +1339,77 @@ export function DealerClaimWorkflowTab({
|
||||
}}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Confirm Request
|
||||
Review Request
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Step 3: Approve and Organise IO - Only for department lead (step 3 approver) */}
|
||||
{step.step === 3 && (() => {
|
||||
// Find step 3 from approvalFlow to get approverEmail
|
||||
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
|
||||
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
|
||||
const isStep3ApproverByEmail = step3ApproverEmail && userEmail === step3ApproverEmail;
|
||||
return isStep3ApproverByEmail || isStep3Approver || isCurrentApprover;
|
||||
})() && (
|
||||
{/* Department Lead Step: Approve and Organise IO - Find dynamically by levelName */}
|
||||
{(() => {
|
||||
// Find Department Lead step dynamically (handles step shifts)
|
||||
const deptLeadStepLevel = approvalFlow.find((l: any) => {
|
||||
const levelName = (l.levelName || '').toLowerCase();
|
||||
return levelName.includes('department lead');
|
||||
});
|
||||
|
||||
// Check if this is the Department Lead step
|
||||
const isDeptLeadStep = deptLeadStepLevel &&
|
||||
(step.step === (deptLeadStepLevel.step || deptLeadStepLevel.levelNumber || deptLeadStepLevel.level_number));
|
||||
|
||||
if (!isDeptLeadStep) return null;
|
||||
|
||||
// Check if user is the Department Lead approver
|
||||
const deptLeadApproverEmail = (deptLeadStepLevel?.approverEmail || '').toLowerCase();
|
||||
const isDeptLeadApprover = deptLeadApproverEmail && userEmail === deptLeadApproverEmail;
|
||||
|
||||
if (!(isDeptLeadApprover || isStep3Approver || isCurrentApprover)) return null;
|
||||
|
||||
// Check if IO number is available (same way as IO tab and modal)
|
||||
const internalOrder = request?.internalOrder || request?.internal_order;
|
||||
const ioNumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
||||
const hasIONumber = ioNumber && ioNumber.trim() !== '';
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!hasIONumber && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-800">IO Number Not Available</p>
|
||||
<p className="text-xs text-amber-700 mt-1">
|
||||
Please add an IO number in the IO tab before approving this step.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
className="bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
onClick={() => {
|
||||
setShowIOApprovalModal(true);
|
||||
}}
|
||||
disabled={!hasIONumber}
|
||||
>
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Approve and Organise IO
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Step 5: Upload Completion Documents - Only for dealer */}
|
||||
{step.step === 5 && isDealer && (
|
||||
{/* Step 5 (or shifted step): Upload Completion Documents - Only for dealer */}
|
||||
{/* Check if dealer is the approver for this step (handles step shifts) */}
|
||||
{(() => {
|
||||
// Find the step level from approvalFlow to verify dealer is the approver
|
||||
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
|
||||
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
|
||||
// Check if dealer is the approver for this step
|
||||
const isDealerForThisStep = isDealer && stepApproverEmail === dealerEmail;
|
||||
// Check if this is the Dealer Completion Documents step
|
||||
// by checking if the levelName contains "Dealer Completion" or "Completion Documents"
|
||||
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
||||
const isDealerCompletionStep = levelName.includes('dealer completion') ||
|
||||
levelName.includes('completion documents');
|
||||
return isDealerForThisStep && isDealerCompletionStep;
|
||||
})() && (
|
||||
<Button
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
onClick={() => {
|
||||
@ -1226,6 +1509,7 @@ export function DealerClaimWorkflowTab({
|
||||
requestTitle={request?.title}
|
||||
requestId={request?.id || request?.requestId}
|
||||
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}
|
||||
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
|
||||
/>
|
||||
|
||||
@ -29,7 +29,7 @@ interface CreditNoteSAPModalProps {
|
||||
creditNoteNumber?: string;
|
||||
creditNoteDate?: string;
|
||||
creditNoteAmount?: number;
|
||||
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT';
|
||||
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED';
|
||||
};
|
||||
dealerInfo?: {
|
||||
dealerName?: string;
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -18,8 +18,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
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 '@/components/common/FilePreview/FilePreview.css';
|
||||
|
||||
interface ExpenseItem {
|
||||
id: string;
|
||||
@ -63,12 +64,64 @@ export function DealerCompletionDocumentsModal({
|
||||
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
|
||||
const [completionDescription, setCompletionDescription] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
|
||||
|
||||
const completionDocsInputRef = useRef<HTMLInputElement>(null);
|
||||
const photosInputRef = useRef<HTMLInputElement>(null);
|
||||
const invoicesInputRef = 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
|
||||
const totalClosedExpenses = useMemo(() => {
|
||||
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
@ -214,6 +267,11 @@ export function DealerCompletionDocumentsModal({
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Cleanup preview URL if exists
|
||||
if (previewFile?.url) {
|
||||
URL.revokeObjectURL(previewFile.url);
|
||||
}
|
||||
setPreviewFile(null);
|
||||
setActivityCompletionDate('');
|
||||
setNumberOfParticipants('');
|
||||
setExpenseItems([]);
|
||||
@ -417,17 +475,41 @@ export function DealerCompletionDocumentsModal({
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
{canPreviewFile(file) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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)}
|
||||
title="Remove document"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</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" />
|
||||
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
{canPreviewFile(file) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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)}
|
||||
title="Remove photo"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</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" />
|
||||
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
{canPreviewFile(file) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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)}
|
||||
title="Remove document"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</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" />
|
||||
<span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
{canPreviewFile(attendanceSheet) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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={() => {
|
||||
setAttendanceSheet(null);
|
||||
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
|
||||
@ -677,6 +830,7 @@ export function DealerCompletionDocumentsModal({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -728,6 +882,96 @@ export function DealerCompletionDocumentsModal({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -18,8 +18,9 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
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 '@/components/common/FilePreview/FilePreview.css';
|
||||
|
||||
interface CostItem {
|
||||
id: string;
|
||||
@ -60,10 +61,63 @@ export function DealerProposalSubmissionModal({
|
||||
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
|
||||
const [dealerComments, setDealerComments] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
|
||||
|
||||
const proposalDocInputRef = 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
|
||||
const totalBudget = useMemo(() => {
|
||||
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||
@ -164,6 +218,11 @@ export function DealerProposalSubmissionModal({
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Cleanup preview URL if exists
|
||||
if (previewFile?.url) {
|
||||
URL.revokeObjectURL(previewFile.url);
|
||||
}
|
||||
setPreviewFile(null);
|
||||
setProposalDocument(null);
|
||||
setCostItems([{ id: '1', description: '', amount: 0 }]);
|
||||
setTimelineMode('date');
|
||||
@ -243,17 +302,41 @@ export function DealerProposalSubmissionModal({
|
||||
className="cursor-pointer flex flex-col items-center gap-2"
|
||||
>
|
||||
{proposalDocument ? (
|
||||
<>
|
||||
<div className="flex flex-col items-center gap-2 w-full">
|
||||
<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">
|
||||
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
|
||||
{proposalDocument.name}
|
||||
</span>
|
||||
<span className="text-xs text-green-600">
|
||||
<span className="text-xs text-green-600 mb-2">
|
||||
Document selected
|
||||
</span>
|
||||
</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" />
|
||||
@ -467,17 +550,41 @@ export function DealerProposalSubmissionModal({
|
||||
{file.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||
{canPreviewFile(file) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
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)}
|
||||
title="Remove document"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@ -531,6 +638,96 @@ export function DealerProposalSubmissionModal({
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ interface DeptLeadIOApprovalModalProps {
|
||||
requestId?: string;
|
||||
// Pre-filled IO data from IO table
|
||||
preFilledIONumber?: string;
|
||||
preFilledIORemark?: string;
|
||||
preFilledBlockedAmount?: number;
|
||||
preFilledRemainingBalance?: number;
|
||||
}
|
||||
@ -47,6 +48,7 @@ export function DeptLeadIOApprovalModal({
|
||||
requestTitle,
|
||||
requestId: _requestId,
|
||||
preFilledIONumber,
|
||||
preFilledIORemark,
|
||||
preFilledBlockedAmount,
|
||||
preFilledRemainingBalance,
|
||||
}: DeptLeadIOApprovalModalProps) {
|
||||
@ -61,12 +63,12 @@ export function DeptLeadIOApprovalModal({
|
||||
// Reset form when modal opens/closes
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Reset form when modal opens
|
||||
setIoRemark('');
|
||||
// Prefill IO remark from props if available
|
||||
setIoRemark(preFilledIORemark || '');
|
||||
setComments('');
|
||||
setActionType('approve');
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, preFilledIORemark]);
|
||||
|
||||
const ioRemarkChars = ioRemark.length;
|
||||
const commentsChars = comments.length;
|
||||
@ -269,7 +271,7 @@ export function DeptLeadIOApprovalModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* IO Remark - Only editable field */}
|
||||
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||
IO Remark <span className="text-red-500">*</span>
|
||||
@ -284,11 +286,18 @@ export function DeptLeadIOApprovalModal({
|
||||
setIoRemark(value);
|
||||
}
|
||||
}}
|
||||
rows={2}
|
||||
className="bg-white text-sm min-h-[60px] resize-none"
|
||||
rows={3}
|
||||
className="bg-white text-sm min-h-[80px] resize-none"
|
||||
disabled={false}
|
||||
readOnly={false}
|
||||
/>
|
||||
<div className="flex justify-end text-xs text-gray-600">
|
||||
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
{preFilledIORemark && (
|
||||
<span className="text-blue-600">
|
||||
✓ Prefilled from IO tab (editable)
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
* Allows initiator to review dealer's proposal and approve/reject
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@ -25,8 +25,11 @@ import {
|
||||
MessageSquare,
|
||||
Download,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
||||
import '@/components/common/FilePreview/FilePreview.css';
|
||||
|
||||
interface CostItem {
|
||||
id: string;
|
||||
@ -75,6 +78,13 @@ export function InitiatorProposalApprovalModal({
|
||||
const [comments, setComments] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
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
|
||||
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 () => {
|
||||
if (!comments.trim()) {
|
||||
toast.error('Please provide approval comments');
|
||||
@ -216,30 +308,41 @@ export function InitiatorProposalApprovalModal({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{proposalData.proposalDocument.url && (
|
||||
<div className="flex items-center gap-2">
|
||||
{proposalData.proposalDocument.id && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(proposalData.proposalDocument?.url, '_blank')}
|
||||
{canPreviewDocument(proposalData.proposalDocument) && (
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = proposalData.proposalDocument?.url || '';
|
||||
link.download = proposalData.proposalDocument?.name || '';
|
||||
link.click();
|
||||
{previewLoading ? (
|
||||
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (proposalData.proposalDocument?.id) {
|
||||
await downloadDocument(proposalData.proposalDocument.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download document:', error);
|
||||
toast.error('Failed to download document');
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
title="Download document"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
Download
|
||||
</Button>
|
||||
<Download className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@ -342,14 +445,41 @@ export function InitiatorProposalApprovalModal({
|
||||
<FileText className="w-5 h-5 text-gray-600" />
|
||||
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
|
||||
</div>
|
||||
{doc.url && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(doc.url, '_blank')}
|
||||
{doc.id && (
|
||||
<div className="flex items-center gap-1">
|
||||
{canPreviewDocument(doc) && (
|
||||
<button
|
||||
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" />
|
||||
</Button>
|
||||
{previewLoading ? (
|
||||
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
||||
) : (
|
||||
<Eye className="w-5 h-5 text-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (doc.id) {
|
||||
await downloadDocument(doc.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to download document:', error);
|
||||
toast.error('Failed to download document');
|
||||
}
|
||||
}}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
title="Download document"
|
||||
>
|
||||
<Download className="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@ -437,6 +567,110 @@ export function InitiatorProposalApprovalModal({
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -129,54 +129,36 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
||||
accessDenied,
|
||||
} = 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 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
|
||||
const approvalFlow = apiRequest?.approvalFlow || [];
|
||||
const approvals = apiRequest?.approvals || [];
|
||||
|
||||
// Try to find Step 3 from approvalFlow first (has 'step' property), then from approvals (has 'levelNumber')
|
||||
const step3Level = approvalFlow.find((level: any) =>
|
||||
// Find Department Lead step dynamically by levelName (handles step shifts when approvers are added)
|
||||
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
|
||||
) || approvals.find((level: any) =>
|
||||
(level.levelNumber || level.level_number) === 3
|
||||
);
|
||||
); // Fallback to step 3 for backwards compatibility
|
||||
|
||||
const deptLeadUserId = step3Level?.approverId || step3Level?.approver_id || step3Level?.approver?.userId;
|
||||
const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver_email || step3Level?.approver?.email || '').toLowerCase().trim();
|
||||
const deptLeadUserId = deptLeadLevel?.approverId || deptLeadLevel?.approver_id || deptLeadLevel?.approver?.userId;
|
||||
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) ||
|
||||
(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
|
||||
// Show IO tab if user is initiator, department lead (Step 3 approver), or current Step 3 approver
|
||||
// Also show if Step 3 has been approved (to view IO details)
|
||||
const isStep3Approved = step3Status === 'APPROVED';
|
||||
const showIOTab = isUserInitiator || isDeptLead || isStep3CurrentApprover || isStep3Approved;
|
||||
// Show IO tab only for department lead (found dynamically, not hardcoded to step 3)
|
||||
const showIOTab = isDeptLead;
|
||||
|
||||
const {
|
||||
mergedMessages,
|
||||
|
||||
@ -63,7 +63,7 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
||||
</Badge>
|
||||
{/* Template Type Badge */}
|
||||
{(() => {
|
||||
const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
|
||||
const templateType = request.templateType || '';
|
||||
const templateTypeUpper = templateType?.toUpperCase() || '';
|
||||
|
||||
// Direct mapping from templateType
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ClosedRequest {
|
||||
department?: string;
|
||||
totalLevels?: number;
|
||||
completedLevels?: number;
|
||||
templateType?: string; // Template type for badge display
|
||||
}
|
||||
|
||||
export interface ClosedRequestsProps {
|
||||
|
||||
@ -28,6 +28,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
||||
department: r.department,
|
||||
totalLevels: r.totalLevels || 0,
|
||||
completedLevels: r.summary?.approvedLevels || 0,
|
||||
templateType: r.templateType || r.template_type, // Template type for badge display
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -19,7 +19,14 @@ export interface CreateClaimRequestPayload {
|
||||
periodStartDate?: string; // ISO date string
|
||||
periodEndDate?: string; // ISO date string
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user