delaer code pulled
This commit is contained in:
commit
01d69bb1eb
@ -275,7 +275,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setApprovalAction(null);
|
setApprovalAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaimManagementSubmit = async (claimData: any, selectedManagerEmail?: string) => {
|
const handleClaimManagementSubmit = async (claimData: any, _selectedManagerEmail?: string) => {
|
||||||
try {
|
try {
|
||||||
// Prepare payload for API
|
// Prepare payload for API
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -292,7 +292,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
periodStartDate: claimData.periodStartDate ? new Date(claimData.periodStartDate).toISOString() : undefined,
|
||||||
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
periodEndDate: claimData.periodEndDate ? new Date(claimData.periodEndDate).toISOString() : undefined,
|
||||||
estimatedBudget: claimData.estimatedBudget || undefined,
|
estimatedBudget: claimData.estimatedBudget || undefined,
|
||||||
selectedManagerEmail: selectedManagerEmail || undefined,
|
approvers: claimData.approvers || [], // Pass approvers array
|
||||||
};
|
};
|
||||||
|
|
||||||
// Call API to create claim request
|
// Call API to create claim request
|
||||||
|
|||||||
@ -6,7 +6,8 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|||||||
|
|
||||||
export interface SLAData {
|
export interface SLAData {
|
||||||
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
|
status: 'on_track' | 'normal' | 'approaching' | 'critical' | 'breached';
|
||||||
percentageUsed: number;
|
percentageUsed?: number;
|
||||||
|
percent?: number; // Simplified format (alternative to percentageUsed)
|
||||||
elapsedText: string;
|
elapsedText: string;
|
||||||
elapsedHours: number;
|
elapsedHours: number;
|
||||||
remainingText: string;
|
remainingText: string;
|
||||||
|
|||||||
@ -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,
|
CheckCircle,
|
||||||
Info,
|
Info,
|
||||||
FileText,
|
FileText,
|
||||||
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
||||||
|
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
interface ClaimManagementWizardProps {
|
interface ClaimManagementWizardProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@ -33,20 +36,29 @@ interface ClaimManagementWizardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLAIM_TYPES = [
|
const CLAIM_TYPES = [
|
||||||
'Marketing Activity',
|
'Riders Mania Claims',
|
||||||
'Promotional Event',
|
'Marketing Cost – Bike to Vendor',
|
||||||
'Dealer Training',
|
'Media Bike Service',
|
||||||
'Infrastructure Development',
|
'ARAI Motorcycle Liquidation',
|
||||||
'Customer Experience Initiative',
|
'ARAI Certification – STA Approval CNR',
|
||||||
'Service Campaign'
|
'Procurement of Spares/Apparel/GMA for Events',
|
||||||
|
'Fuel for Media Bike Used for Event',
|
||||||
|
'Motorcycle Buyback and Goodwill Support',
|
||||||
|
'Liquidation of Used Motorcycle',
|
||||||
|
'Motorcycle Registration CNR (Owned or Gifted by RE)',
|
||||||
|
'Legal Claims Reimbursement',
|
||||||
|
'Service Camp Claims',
|
||||||
|
'Corporate Claims – Institutional Sales PDI'
|
||||||
];
|
];
|
||||||
|
|
||||||
const STEP_NAMES = [
|
const STEP_NAMES = [
|
||||||
'Claim Details',
|
'Claim Details',
|
||||||
|
'Approver Selection',
|
||||||
'Review & Submit'
|
'Review & Submit'
|
||||||
];
|
];
|
||||||
|
|
||||||
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
||||||
const [loadingDealers, setLoadingDealers] = useState(true);
|
const [loadingDealers, setLoadingDealers] = useState(true);
|
||||||
@ -64,7 +76,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
requestDescription: '',
|
requestDescription: '',
|
||||||
periodStartDate: undefined as Date | undefined,
|
periodStartDate: undefined as Date | undefined,
|
||||||
periodEndDate: undefined as Date | undefined,
|
periodEndDate: undefined as Date | undefined,
|
||||||
estimatedBudget: ''
|
estimatedBudget: '',
|
||||||
|
// Approvers array for all 8 steps
|
||||||
|
approvers: [] as Array<{
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
userId?: string;
|
||||||
|
level: number;
|
||||||
|
tat?: number | string;
|
||||||
|
tatType?: 'hours' | 'days';
|
||||||
|
}>
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSteps = STEP_NAMES.length;
|
const totalSteps = STEP_NAMES.length;
|
||||||
@ -87,7 +108,28 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateFormData = (field: string, value: any) => {
|
const updateFormData = (field: string, value: any) => {
|
||||||
setFormData(prev => ({ ...prev, [field]: value }));
|
setFormData(prev => {
|
||||||
|
const updated = { ...prev, [field]: value };
|
||||||
|
|
||||||
|
// Validate period dates
|
||||||
|
if (field === 'periodStartDate') {
|
||||||
|
// If start date is selected and end date exists, validate end date
|
||||||
|
if (value && updated.periodEndDate && value > updated.periodEndDate) {
|
||||||
|
// Clear end date if it's before the new start date
|
||||||
|
updated.periodEndDate = undefined;
|
||||||
|
toast.error('End date must be on or after the start date. End date has been cleared.');
|
||||||
|
}
|
||||||
|
} else if (field === 'periodEndDate') {
|
||||||
|
// If end date is selected and start date exists, validate end date
|
||||||
|
if (value && updated.periodStartDate && value < updated.periodStartDate) {
|
||||||
|
toast.error('End date must be on or after the start date.');
|
||||||
|
// Don't update the end date if it's invalid
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const isStepValid = () => {
|
const isStepValid = () => {
|
||||||
@ -101,6 +143,12 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
formData.location &&
|
formData.location &&
|
||||||
formData.requestDescription;
|
formData.requestDescription;
|
||||||
case 2:
|
case 2:
|
||||||
|
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
|
||||||
|
const approvers = formData.approvers || [];
|
||||||
|
const step3Approver = approvers.find((a: any) => a.level === 3);
|
||||||
|
// Step 8 is now a system step, no validation needed
|
||||||
|
return step3Approver?.email && step3Approver?.userId && step3Approver?.tat;
|
||||||
|
case 3:
|
||||||
return true;
|
return true;
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
@ -108,7 +156,28 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
};
|
};
|
||||||
|
|
||||||
const nextStep = () => {
|
const nextStep = () => {
|
||||||
if (currentStep < totalSteps && isStepValid()) {
|
if (currentStep < totalSteps) {
|
||||||
|
if (!isStepValid()) {
|
||||||
|
// Show specific error messages for step 2 (approver selection)
|
||||||
|
if (currentStep === 2) {
|
||||||
|
const approvers = formData.approvers || [];
|
||||||
|
const step3Approver = approvers.find((a: any) => a.level === 3);
|
||||||
|
const missingSteps: string[] = [];
|
||||||
|
|
||||||
|
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
|
||||||
|
missingSteps.push('Step 3: Department Lead Approval');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingSteps.length > 0) {
|
||||||
|
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
toast.error('Please complete all required approver selections (email, user verification, and TAT) before proceeding.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast.error('Please complete all required fields before proceeding.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
setCurrentStep(currentStep + 1);
|
setCurrentStep(currentStep + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -149,57 +218,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
currentStep: 'initiator-review',
|
currentStep: 'initiator-review',
|
||||||
workflowSteps: [
|
// Pass approvers array to backend
|
||||||
{
|
approvers: formData.approvers || []
|
||||||
step: 1,
|
|
||||||
name: 'Initiator Evaluation',
|
|
||||||
status: 'pending',
|
|
||||||
approver: 'Current User (Initiator)',
|
|
||||||
description: 'Review and confirm all claim details and documents'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
name: 'IO Confirmation',
|
|
||||||
status: 'waiting',
|
|
||||||
approver: 'System',
|
|
||||||
description: 'Automatic IO generation upon initiator approval'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
name: 'Department Lead Approval',
|
|
||||||
status: 'waiting',
|
|
||||||
approver: 'Department Lead',
|
|
||||||
description: 'Budget blocking and final approval'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 4,
|
|
||||||
name: 'Document Submission',
|
|
||||||
status: 'waiting',
|
|
||||||
approver: 'Dealer',
|
|
||||||
description: 'Dealer submits completion documents'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 5,
|
|
||||||
name: 'Document Verification',
|
|
||||||
status: 'waiting',
|
|
||||||
approver: 'Initiator',
|
|
||||||
description: 'Verify completion documents'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 6,
|
|
||||||
name: 'E-Invoice Generation',
|
|
||||||
status: 'waiting',
|
|
||||||
approver: 'System',
|
|
||||||
description: 'Auto-generate e-invoice based on approved amount'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 7,
|
|
||||||
name: 'Credit Note Issuance',
|
|
||||||
status: 'waiting',
|
|
||||||
approver: 'Finance',
|
|
||||||
description: 'Issue credit note to dealer'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't show toast here - let the parent component handle success/error after API call
|
// Don't show toast here - let the parent component handle success/error after API call
|
||||||
@ -372,6 +392,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
selected={formData.periodStartDate}
|
selected={formData.periodStartDate}
|
||||||
onSelect={(date) => updateFormData('periodStartDate', date)}
|
onSelect={(date) => updateFormData('periodStartDate', date)}
|
||||||
initialFocus
|
initialFocus
|
||||||
|
// Maximum date is the end date (if selected)
|
||||||
|
toDate={formData.periodEndDate || undefined}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
@ -384,6 +406,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start text-left mt-2 h-12"
|
className="w-full justify-start text-left mt-2 h-12"
|
||||||
|
disabled={!formData.periodStartDate}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
|
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
|
||||||
@ -395,17 +418,30 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
selected={formData.periodEndDate}
|
selected={formData.periodEndDate}
|
||||||
onSelect={(date) => updateFormData('periodEndDate', date)}
|
onSelect={(date) => updateFormData('periodEndDate', date)}
|
||||||
initialFocus
|
initialFocus
|
||||||
|
// Minimum date is the start date (if selected)
|
||||||
|
fromDate={formData.periodStartDate || undefined}
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
{!formData.periodStartDate && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Please select start date first</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{(formData.periodStartDate || formData.periodEndDate) && (
|
{(formData.periodStartDate || formData.periodEndDate) && (
|
||||||
<p className="text-xs text-gray-600 mt-2">
|
<div className="mt-2">
|
||||||
{formData.periodStartDate && formData.periodEndDate
|
{formData.periodStartDate && formData.periodEndDate ? (
|
||||||
? `Period: ${format(formData.periodStartDate, 'MMM dd, yyyy')} - ${format(formData.periodEndDate, 'MMM dd, yyyy')}`
|
<p className="text-xs text-gray-600">
|
||||||
: 'Please select both start and end dates for the period'}
|
Period: {format(formData.periodStartDate, 'MMM dd, yyyy')} - {format(formData.periodEndDate, 'MMM dd, yyyy')}
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formData.periodStartDate
|
||||||
|
? 'Please select end date for the period'
|
||||||
|
: 'Please select start date first'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -413,6 +449,23 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
);
|
);
|
||||||
|
|
||||||
case 2:
|
case 2:
|
||||||
|
return (
|
||||||
|
<ClaimApproverSelectionStep
|
||||||
|
formData={formData}
|
||||||
|
updateFormData={updateFormData}
|
||||||
|
currentUserEmail={(user as any)?.email || ''}
|
||||||
|
currentUserId={(user as any)?.userId || ''}
|
||||||
|
currentUserName={
|
||||||
|
(user as any)?.displayName ||
|
||||||
|
(user as any)?.name ||
|
||||||
|
((user as any)?.firstName && (user as any)?.lastName
|
||||||
|
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
||||||
|
: (user as any)?.email || 'User')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3:
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -489,6 +542,55 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Approver Information */}
|
||||||
|
<Card className="border-2">
|
||||||
|
<CardHeader className="bg-gradient-to-br from-purple-50 to-indigo-50">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-purple-600" />
|
||||||
|
Selected Approvers
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6 space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => {
|
||||||
|
const stepNames: Record<number, string> = {
|
||||||
|
1: 'Dealer Proposal Submission',
|
||||||
|
2: 'Requestor Evaluation',
|
||||||
|
3: 'Department Lead Approval',
|
||||||
|
4: 'Activity Creation',
|
||||||
|
5: 'Dealer Completion Documents',
|
||||||
|
6: 'Requestor Claim Approval',
|
||||||
|
7: 'E-Invoice Generation',
|
||||||
|
8: 'Credit Note Confirmation',
|
||||||
|
};
|
||||||
|
const tat = Number(approver.tat || 0);
|
||||||
|
const tatType = approver.tatType || 'hours';
|
||||||
|
const hours = tatType === 'days' ? tat * 24 : tat;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={approver.level} className="p-3 bg-gray-50 rounded-lg border">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">
|
||||||
|
Step {approver.level}: {stepNames[approver.level]}
|
||||||
|
</Label>
|
||||||
|
<p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
|
||||||
|
{approver.email && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{approver.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{hours} hours</p>
|
||||||
|
<p className="text-xs text-gray-500">TAT</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Date & Location */}
|
{/* Date & Location */}
|
||||||
<Card className="border-2">
|
<Card className="border-2">
|
||||||
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
|
<CardHeader className="bg-gradient-to-br from-purple-50 to-pink-50">
|
||||||
@ -656,8 +758,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
{currentStep < totalSteps ? (
|
{currentStep < totalSteps ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
disabled={!isStepValid()}
|
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
|
||||||
className="gap-2 w-full sm:w-auto order-1 sm:order-2"
|
!isStepValid()
|
||||||
|
? 'opacity-50 cursor-pointer hover:opacity-60'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
|
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -30,6 +31,7 @@ interface IOBlockedDetails {
|
|||||||
blockedDate: string;
|
blockedDate: string;
|
||||||
blockedBy: string; // User who blocked
|
blockedBy: string; // User who blocked
|
||||||
sapDocumentNumber: string;
|
sapDocumentNumber: string;
|
||||||
|
ioRemark?: string; // IO remark
|
||||||
status: 'blocked' | 'released' | 'failed';
|
status: 'blocked' | 'released' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -40,6 +42,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
// Load existing IO data from apiRequest or request
|
// Load existing IO data from apiRequest or request
|
||||||
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
|
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
|
||||||
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
||||||
|
const existingIORemark = internalOrder?.ioRemark || internalOrder?.io_remark || '';
|
||||||
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
||||||
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
|
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
|
||||||
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
||||||
@ -48,15 +51,19 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
const organizer = internalOrder?.organizer || null;
|
const organizer = internalOrder?.organizer || null;
|
||||||
|
|
||||||
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
||||||
|
const [ioRemark, setIoRemark] = useState(existingIORemark);
|
||||||
const [fetchingAmount, setFetchingAmount] = useState(false);
|
const [fetchingAmount, setFetchingAmount] = useState(false);
|
||||||
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
||||||
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
||||||
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
||||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
const [blockingBudget, setBlockingBudget] = useState(false);
|
||||||
|
|
||||||
|
const maxIoRemarkChars = 300;
|
||||||
|
const ioRemarkChars = ioRemark.length;
|
||||||
|
|
||||||
// Load existing IO block details from apiRequest
|
// Load existing IO block details from apiRequest
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (internalOrder && existingIONumber && existingBlockedAmount > 0) {
|
if (internalOrder && existingIONumber) {
|
||||||
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
|
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
|
||||||
// Get blocked by user name from organizer association (who blocked the amount)
|
// Get blocked by user name from organizer association (who blocked the amount)
|
||||||
// When amount is blocked, organizedBy stores the user who blocked it
|
// When amount is blocked, organizedBy stores the user who blocked it
|
||||||
@ -67,25 +74,32 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
organizer?.email ||
|
organizer?.email ||
|
||||||
'Unknown User';
|
'Unknown User';
|
||||||
|
|
||||||
setBlockedDetails({
|
// Set IO number and remark from existing data
|
||||||
ioNumber: existingIONumber,
|
|
||||||
blockedAmount: Number(existingBlockedAmount) || 0,
|
|
||||||
availableBalance: availableBeforeBlock, // Available amount before block
|
|
||||||
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
|
|
||||||
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
|
||||||
blockedBy: blockedByName,
|
|
||||||
sapDocumentNumber: sapDocNumber,
|
|
||||||
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
|
||||||
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
|
||||||
});
|
|
||||||
setIoNumber(existingIONumber);
|
setIoNumber(existingIONumber);
|
||||||
|
setIoRemark(existingIORemark);
|
||||||
|
|
||||||
// Set fetched amount if available balance exists
|
// Only set blocked details if amount is blocked
|
||||||
if (availableBeforeBlock > 0) {
|
if (existingBlockedAmount > 0) {
|
||||||
setFetchedAmount(availableBeforeBlock);
|
setBlockedDetails({
|
||||||
|
ioNumber: existingIONumber,
|
||||||
|
blockedAmount: Number(existingBlockedAmount) || 0,
|
||||||
|
availableBalance: availableBeforeBlock, // Available amount before block
|
||||||
|
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
|
||||||
|
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
||||||
|
blockedBy: blockedByName,
|
||||||
|
sapDocumentNumber: sapDocNumber,
|
||||||
|
ioRemark: existingIORemark,
|
||||||
|
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
||||||
|
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set fetched amount if available balance exists
|
||||||
|
if (availableBeforeBlock > 0) {
|
||||||
|
setFetchedAmount(availableBeforeBlock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available budget from SAP
|
* Fetch available budget from SAP
|
||||||
@ -128,6 +142,43 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save IO details (IO number and remark) without blocking budget
|
||||||
|
*/
|
||||||
|
const handleSaveIODetails = async () => {
|
||||||
|
if (!ioNumber.trim()) {
|
||||||
|
toast.error('Please enter an IO number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!requestId) {
|
||||||
|
toast.error('Request ID not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBlockingBudget(true);
|
||||||
|
try {
|
||||||
|
// Save only IO number and remark (no balance fields)
|
||||||
|
const payload = {
|
||||||
|
ioNumber: ioNumber.trim(),
|
||||||
|
ioRemark: ioRemark.trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateIODetails(requestId, payload);
|
||||||
|
|
||||||
|
toast.success('IO details saved successfully');
|
||||||
|
|
||||||
|
// Refresh request details
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save IO details:', error);
|
||||||
|
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to save IO details';
|
||||||
|
toast.error(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setBlockingBudget(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block budget in SAP system
|
* Block budget in SAP system
|
||||||
*/
|
*/
|
||||||
@ -142,34 +193,72 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockAmount = parseFloat(amountToBlock);
|
const blockAmountRaw = parseFloat(amountToBlock);
|
||||||
|
|
||||||
if (!amountToBlock || isNaN(blockAmount) || blockAmount <= 0) {
|
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
|
||||||
toast.error('Please enter a valid amount to block');
|
toast.error('Please enter a valid amount to block');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Round to 2 decimal places to avoid floating point precision issues
|
||||||
|
// This ensures we send clean values like 240.00 instead of 239.9999999
|
||||||
|
const blockAmount = Math.round(blockAmountRaw * 100) / 100;
|
||||||
|
|
||||||
if (blockAmount > fetchedAmount) {
|
if (blockAmount > fetchedAmount) {
|
||||||
toast.error('Amount to block exceeds available IO budget');
|
toast.error('Amount to block exceeds available IO budget');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Log the amount being sent to backend for debugging
|
||||||
|
console.log('[IOTab] Blocking budget:', {
|
||||||
|
ioNumber: ioNumber.trim(),
|
||||||
|
originalInput: amountToBlock,
|
||||||
|
parsedAmount: blockAmountRaw,
|
||||||
|
roundedAmount: blockAmount,
|
||||||
|
fetchedAmount,
|
||||||
|
calculatedRemaining: fetchedAmount - blockAmount,
|
||||||
|
});
|
||||||
|
|
||||||
setBlockingBudget(true);
|
setBlockingBudget(true);
|
||||||
try {
|
try {
|
||||||
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
|
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
|
||||||
// This will store in internal_orders and claim_budget_tracking tables
|
// This will store in internal_orders and claim_budget_tracking tables
|
||||||
await updateIODetails(requestId, {
|
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
|
||||||
|
const payload = {
|
||||||
ioNumber: ioNumber.trim(),
|
ioNumber: ioNumber.trim(),
|
||||||
|
ioRemark: ioRemark.trim(),
|
||||||
ioAvailableBalance: fetchedAmount,
|
ioAvailableBalance: fetchedAmount,
|
||||||
ioBlockedAmount: blockAmount,
|
ioBlockedAmount: blockAmount,
|
||||||
ioRemainingBalance: fetchedAmount - blockAmount,
|
ioRemainingBalance: fetchedAmount - blockAmount, // Calculated value (backend will use SAP's actual value)
|
||||||
});
|
};
|
||||||
|
|
||||||
|
console.log('[IOTab] Sending to backend:', payload);
|
||||||
|
|
||||||
|
await updateIODetails(requestId, payload);
|
||||||
|
|
||||||
// Fetch updated claim details to get the blocked IO data
|
// Fetch updated claim details to get the blocked IO data
|
||||||
const claimData = await getClaimDetails(requestId);
|
const claimData = await getClaimDetails(requestId);
|
||||||
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
||||||
|
|
||||||
if (updatedInternalOrder) {
|
if (updatedInternalOrder) {
|
||||||
|
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
|
||||||
|
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount));
|
||||||
|
|
||||||
|
// Log what was saved vs what we sent
|
||||||
|
console.log('[IOTab] Blocking result:', {
|
||||||
|
sentAmount: blockAmount,
|
||||||
|
savedBlockedAmount,
|
||||||
|
sentRemaining: fetchedAmount - blockAmount,
|
||||||
|
savedRemainingBalance,
|
||||||
|
availableBalance: fetchedAmount,
|
||||||
|
difference: savedBlockedAmount - blockAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Warn if the saved amount differs from what we sent
|
||||||
|
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
|
||||||
|
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
|
||||||
|
}
|
||||||
|
|
||||||
const currentUser = user as any;
|
const currentUser = user as any;
|
||||||
// When blocking, always use the current user who is performing the block action
|
// When blocking, always use the current user who is performing the block action
|
||||||
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
||||||
@ -180,14 +269,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
currentUser?.email ||
|
currentUser?.email ||
|
||||||
'Current User';
|
'Current User';
|
||||||
|
|
||||||
|
const savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
|
||||||
|
|
||||||
const blocked: IOBlockedDetails = {
|
const blocked: IOBlockedDetails = {
|
||||||
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
||||||
blockedAmount: Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount),
|
blockedAmount: savedBlockedAmount,
|
||||||
availableBalance: fetchedAmount, // Available amount before block
|
availableBalance: fetchedAmount, // Available amount before block
|
||||||
remainingBalance: Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount)),
|
remainingBalance: savedRemainingBalance,
|
||||||
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
|
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
|
||||||
blockedBy: blockedByName,
|
blockedBy: blockedByName,
|
||||||
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
||||||
|
ioRemark: savedIoRemark,
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -247,6 +339,42 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* IO Remark Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900">
|
||||||
|
IO Remark
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="ioRemark"
|
||||||
|
placeholder="Enter remarks about IO organization"
|
||||||
|
value={ioRemark}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
if (value.length <= maxIoRemarkChars) {
|
||||||
|
setIoRemark(value);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={3}
|
||||||
|
disabled={!!blockedDetails}
|
||||||
|
className="bg-white text-sm min-h-[80px] resize-none"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end text-xs text-gray-600">
|
||||||
|
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save IO Details Button (shown when IO number is entered but amount not fetched) */}
|
||||||
|
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
|
||||||
|
<Button
|
||||||
|
onClick={handleSaveIODetails}
|
||||||
|
disabled={blockingBudget || !ioNumber.trim()}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-[#2d4a3e] text-[#2d4a3e] hover:bg-[#2d4a3e] hover:text-white"
|
||||||
|
>
|
||||||
|
{blockingBudget ? 'Saving...' : 'Save IO Details'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fetched Amount Display */}
|
{/* Fetched Amount Display */}
|
||||||
{fetchedAmount !== null && !blockedDetails && (
|
{fetchedAmount !== null && !blockedDetails && (
|
||||||
<>
|
<>
|
||||||
@ -333,6 +461,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
{blockedDetails.ioRemark && (
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Remark</p>
|
||||||
|
<p className="text-sm font-medium text-gray-900 whitespace-pre-wrap">{blockedDetails.ioRemark}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="p-4 bg-green-50">
|
<div className="p-4 bg-green-50">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
||||||
<p className="text-xl font-bold text-green-700">
|
<p className="text-xl font-bold text-green-700">
|
||||||
|
|||||||
@ -172,15 +172,11 @@ export function DealerClaimWorkflowTab({
|
|||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
// Reload approval flows whenever request changes or after refresh
|
// Reload approval flows whenever request changes or after refresh
|
||||||
|
// Always fetch from API to ensure fresh data (don't rely on cached request.approvalFlow)
|
||||||
|
// Also watch for changes in totalLevels to detect when approvers are added
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadApprovalFlows = async () => {
|
const loadApprovalFlows = async () => {
|
||||||
// First check if request has approvalFlow
|
// Always load from real API to get the latest data
|
||||||
if (request?.approvalFlow && request.approvalFlow.length > 0) {
|
|
||||||
setApprovalFlow(request.approvalFlow);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load from real API
|
|
||||||
if (request?.id || request?.requestId) {
|
if (request?.id || request?.requestId) {
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
try {
|
try {
|
||||||
@ -188,29 +184,61 @@ export function DealerClaimWorkflowTab({
|
|||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||||
if (approvals && approvals.length > 0) {
|
if (approvals && approvals.length > 0) {
|
||||||
// Transform approval levels to match expected format
|
// Transform approval levels to match expected format
|
||||||
const flows = approvals.map((level: any) => ({
|
// Include levelName and levelNumber for proper mapping
|
||||||
step: level.levelNumber || level.level_number || 0,
|
const flows = approvals
|
||||||
approver: level.approverName || level.approver_name || '',
|
.map((level: any) => ({
|
||||||
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
|
step: level.levelNumber || level.level_number || 0,
|
||||||
status: level.status?.toLowerCase() || 'waiting',
|
levelNumber: level.levelNumber || level.level_number || 0,
|
||||||
tatHours: level.tatHours || level.tat_hours || 24,
|
levelName: level.levelName || level.level_name,
|
||||||
elapsedHours: level.elapsedHours || level.elapsed_hours,
|
approver: level.approverName || level.approver_name || '',
|
||||||
approvedAt: level.actionDate || level.action_date,
|
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
|
||||||
comment: level.comments || level.comment,
|
status: level.status?.toLowerCase() || 'waiting',
|
||||||
levelId: level.levelId || level.level_id,
|
tatHours: level.tatHours || level.tat_hours || 24,
|
||||||
}));
|
elapsedHours: level.elapsedHours || level.elapsed_hours,
|
||||||
setApprovalFlow(flows);
|
approvedAt: level.actionDate || level.action_date,
|
||||||
|
comment: level.comments || level.comment,
|
||||||
|
levelId: level.levelId || level.level_id,
|
||||||
|
}))
|
||||||
|
// Sort by levelNumber to ensure correct order (critical for proper display)
|
||||||
|
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0));
|
||||||
|
|
||||||
|
// Only update if the data actually changed (avoid unnecessary re-renders)
|
||||||
|
setApprovalFlow(prevFlows => {
|
||||||
|
// Check if flows are different
|
||||||
|
if (prevFlows.length !== flows.length) {
|
||||||
|
return flows;
|
||||||
|
}
|
||||||
|
// Check if any levelNumber or levelName changed
|
||||||
|
const hasChanges = prevFlows.some((prev: any, idx: number) => {
|
||||||
|
const curr = flows[idx];
|
||||||
|
return !curr ||
|
||||||
|
prev.levelNumber !== curr.levelNumber ||
|
||||||
|
prev.levelName !== curr.levelName ||
|
||||||
|
prev.approverEmail !== curr.approverEmail;
|
||||||
|
});
|
||||||
|
return hasChanges ? flows : prevFlows;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If no approvals found, clear the flow
|
||||||
|
setApprovalFlow([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Failed to load approval flows from API:', error);
|
console.warn('Failed to load approval flows from API:', error);
|
||||||
|
// On error, try to use request.approvalFlow as fallback
|
||||||
|
if (request?.approvalFlow && request.approvalFlow.length > 0) {
|
||||||
|
setApprovalFlow(request.approvalFlow);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if (request?.approvalFlow && request.approvalFlow.length > 0) {
|
||||||
|
// Fallback: use request.approvalFlow only if no requestId available
|
||||||
|
setApprovalFlow(request.approvalFlow);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadApprovalFlows();
|
loadApprovalFlows();
|
||||||
}, [request, refreshTrigger]);
|
}, [request?.id, request?.requestId, request?.totalLevels, refreshTrigger]);
|
||||||
|
|
||||||
// Also reload when request.currentStep changes
|
// Also reload when request.currentStep or totalLevels changes (to catch step transitions and new approvers)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (request?.id || request?.requestId) {
|
if (request?.id || request?.requestId) {
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
@ -219,17 +247,24 @@ export function DealerClaimWorkflowTab({
|
|||||||
const details = await getWorkflowDetails(requestId);
|
const details = await getWorkflowDetails(requestId);
|
||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||||
if (approvals && approvals.length > 0) {
|
if (approvals && approvals.length > 0) {
|
||||||
const flows = approvals.map((level: any) => ({
|
const flows = approvals
|
||||||
step: level.levelNumber || level.level_number || 0,
|
.map((level: any) => ({
|
||||||
approver: level.approverName || level.approver_name || '',
|
step: level.levelNumber || level.level_number || 0,
|
||||||
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
|
levelNumber: level.levelNumber || level.level_number || 0,
|
||||||
status: level.status?.toLowerCase() || 'waiting',
|
levelName: level.levelName || level.level_name,
|
||||||
tatHours: level.tatHours || level.tat_hours || 24,
|
approver: level.approverName || level.approver_name || '',
|
||||||
elapsedHours: level.elapsedHours || level.elapsed_hours,
|
approverEmail: (level.approverEmail || level.approver_email || '').toLowerCase(),
|
||||||
approvedAt: level.actionDate || level.action_date,
|
status: level.status?.toLowerCase() || 'waiting',
|
||||||
comment: level.comments || level.comment,
|
tatHours: level.tatHours || level.tat_hours || 24,
|
||||||
levelId: level.levelId || level.level_id,
|
elapsedHours: level.elapsedHours || level.elapsed_hours,
|
||||||
}));
|
approvedAt: level.actionDate || level.action_date,
|
||||||
|
comment: level.comments || level.comment,
|
||||||
|
levelId: level.levelId || level.level_id,
|
||||||
|
}))
|
||||||
|
// Sort by levelNumber to ensure correct order
|
||||||
|
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0));
|
||||||
|
|
||||||
|
// Update state with new flows
|
||||||
setApprovalFlow(flows);
|
setApprovalFlow(flows);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -238,7 +273,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
};
|
};
|
||||||
loadApprovalFlows();
|
loadApprovalFlows();
|
||||||
}
|
}
|
||||||
}, [request?.currentStep]);
|
}, [request?.currentStep, request?.totalLevels]);
|
||||||
|
|
||||||
// Enhanced refresh handler that also reloads approval flows
|
// Enhanced refresh handler that also reloads approval flows
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
@ -246,36 +281,128 @@ export function DealerClaimWorkflowTab({
|
|||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Step title and description mapping based on actual step number (not array index)
|
||||||
|
// This handles cases where approvers are added between steps
|
||||||
|
const getStepTitle = (stepNumber: number, levelName?: string, approverName?: string): string => {
|
||||||
|
// Use levelName from backend if available (most accurate)
|
||||||
|
// Check if it's an "Additional Approver" - this indicates a dynamically added approver
|
||||||
|
if (levelName && levelName.trim()) {
|
||||||
|
// If it starts with "Additional Approver", use it as-is (it's already formatted)
|
||||||
|
if (levelName.toLowerCase().includes('additional approver')) {
|
||||||
|
return levelName;
|
||||||
|
}
|
||||||
|
// Otherwise use the levelName from backend (preserved from original step)
|
||||||
|
return levelName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to mapping based on step number
|
||||||
|
const stepTitleMap: Record<number, string> = {
|
||||||
|
1: 'Dealer - Proposal Submission',
|
||||||
|
2: 'Requestor Evaluation & Confirmation',
|
||||||
|
3: 'Department Lead Approval',
|
||||||
|
4: 'Activity Creation',
|
||||||
|
5: 'Dealer - Completion Documents',
|
||||||
|
6: 'Requestor - Claim Approval',
|
||||||
|
7: 'E-Invoice Generation',
|
||||||
|
8: 'Credit Note from SAP',
|
||||||
|
};
|
||||||
|
|
||||||
|
// If step number exists in map, use it
|
||||||
|
if (stepTitleMap[stepNumber]) {
|
||||||
|
return stepTitleMap[stepNumber];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For dynamically added steps, create a title from approver name or generic
|
||||||
|
if (approverName && approverName !== 'Unknown' && approverName !== 'System') {
|
||||||
|
return `Additional Approver - ${approverName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Additional Approver - Step ${stepNumber}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepDescription = (stepNumber: number, levelName?: string, approverName?: string): string => {
|
||||||
|
// Check if this is an "Additional Approver" (dynamically added)
|
||||||
|
const isAdditionalApprover = levelName && levelName.toLowerCase().includes('additional approver');
|
||||||
|
|
||||||
|
// If this is an additional approver, use generic description
|
||||||
|
if (isAdditionalApprover) {
|
||||||
|
if (approverName && approverName !== 'Unknown' && approverName !== 'System') {
|
||||||
|
return `${approverName} will review and approve this request as an additional approver.`;
|
||||||
|
}
|
||||||
|
return `Additional approver will review and approve this request.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use levelName to determine description (handles shifted steps correctly)
|
||||||
|
// This ensures descriptions shift with their steps when approvers are added
|
||||||
|
if (levelName && levelName.trim()) {
|
||||||
|
const levelNameLower = levelName.toLowerCase();
|
||||||
|
|
||||||
|
// Map level names to descriptions (works even after shifting)
|
||||||
|
if (levelNameLower.includes('dealer') && levelNameLower.includes('proposal')) {
|
||||||
|
return 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('requestor') && (levelNameLower.includes('evaluation') || levelNameLower.includes('confirmation'))) {
|
||||||
|
return 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('department lead')) {
|
||||||
|
return 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('activity creation')) {
|
||||||
|
return 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('dealer') && (levelNameLower.includes('completion') || levelNameLower.includes('documents'))) {
|
||||||
|
return 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('requestor') && (levelNameLower.includes('claim') || levelNameLower.includes('approval'))) {
|
||||||
|
return 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('e-invoice') || levelNameLower.includes('invoice generation')) {
|
||||||
|
return 'E-invoice will be generated through DMS.';
|
||||||
|
}
|
||||||
|
if (levelNameLower.includes('credit note') || levelNameLower.includes('sap')) {
|
||||||
|
return 'Got credit note from SAP. Review and send to dealer to complete the claim management process.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to step number mapping (for backwards compatibility)
|
||||||
|
const stepDescriptionMap: Record<number, string> = {
|
||||||
|
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
|
||||||
|
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
|
||||||
|
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
|
||||||
|
4: 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
|
||||||
|
5: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
|
||||||
|
6: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
|
||||||
|
7: 'E-invoice will be generated through DMS.',
|
||||||
|
8: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stepDescriptionMap[stepNumber]) {
|
||||||
|
return stepDescriptionMap[stepNumber];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback
|
||||||
|
if (approverName && approverName !== 'Unknown' && approverName !== 'System') {
|
||||||
|
return `${approverName} will review and approve this request.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Step ${stepNumber} approval required.`;
|
||||||
|
};
|
||||||
|
|
||||||
// Transform approval flow to dealer claim workflow steps
|
// Transform approval flow to dealer claim workflow steps
|
||||||
const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => {
|
const workflowSteps: WorkflowStep[] = approvalFlow.map((step: any, index: number) => {
|
||||||
const stepTitles = [
|
// Get actual step number from levelNumber or step field
|
||||||
'Dealer - Proposal Submission',
|
const actualStepNumber = step.levelNumber || step.level_number || step.step || index + 1;
|
||||||
'Requestor Evaluation & Confirmation',
|
|
||||||
'Dept Lead Approval',
|
// Get levelName from the approval level if available
|
||||||
'Activity Creation',
|
const levelName = step.levelName || step.level_name;
|
||||||
'Dealer - Completion Documents',
|
|
||||||
'Requestor - Claim Approval',
|
|
||||||
'E-Invoice Generation',
|
|
||||||
'Credit Note from SAP',
|
|
||||||
];
|
|
||||||
|
|
||||||
const stepDescriptions = [
|
|
||||||
'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
|
|
||||||
'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
|
|
||||||
'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
|
|
||||||
'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
|
|
||||||
'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
|
|
||||||
'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
|
|
||||||
'E-invoice will be generated through DMS.',
|
|
||||||
'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Find approval data for this step
|
// Find approval data for this step
|
||||||
const approval = request?.approvals?.find((a: any) => a.levelId === step.levelId);
|
const approval = request?.approvals?.find((a: any) => a.levelId === step.levelId);
|
||||||
|
|
||||||
// Extract IO details from internalOrder table (Step 3)
|
// Extract IO details from internalOrder table (Department Lead step - check by levelName)
|
||||||
let ioDetails = undefined;
|
let ioDetails = undefined;
|
||||||
if (step.step === 3) {
|
const isDeptLeadStep = levelName && levelName.toLowerCase().includes('department lead');
|
||||||
|
if (isDeptLeadStep || actualStepNumber === 3) {
|
||||||
// Get IO details from dedicated internalOrder table
|
// Get IO details from dedicated internalOrder table
|
||||||
const internalOrder = request?.internalOrder || request?.internal_order;
|
const internalOrder = request?.internalOrder || request?.internal_order;
|
||||||
|
|
||||||
@ -314,7 +441,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
// Extract DMS details from approval data (Step 6)
|
// Extract DMS details from approval data (Step 6)
|
||||||
let dmsDetails = undefined;
|
let dmsDetails = undefined;
|
||||||
if (step.step === 6) {
|
if (actualStepNumber === 6) {
|
||||||
if (approval?.dmsDetails) {
|
if (approval?.dmsDetails) {
|
||||||
dmsDetails = {
|
dmsDetails = {
|
||||||
dmsNumber: approval.dmsDetails.dmsNumber || '',
|
dmsNumber: approval.dmsDetails.dmsNumber || '',
|
||||||
@ -348,11 +475,13 @@ export function DealerClaimWorkflowTab({
|
|||||||
// For waiting steps, elapsedHours should be 0 (they haven't started yet)
|
// For waiting steps, elapsedHours should be 0 (they haven't started yet)
|
||||||
const elapsedHours = isWaiting ? 0 : (step.elapsedHours || 0);
|
const elapsedHours = isWaiting ? 0 : (step.elapsedHours || 0);
|
||||||
|
|
||||||
|
const approverName = step.approver || step.approverName || 'Unknown';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: step.step || index + 1,
|
step: actualStepNumber,
|
||||||
title: stepTitles[index] || `Step ${step.step || index + 1}`,
|
title: getStepTitle(actualStepNumber, levelName, approverName),
|
||||||
approver: step.approver || 'Unknown',
|
approver: approverName,
|
||||||
description: stepDescriptions[index] || step.description || '',
|
description: getStepDescription(actualStepNumber, levelName, approverName) || step.description || '',
|
||||||
tatHours: step.tatHours || 24,
|
tatHours: step.tatHours || 24,
|
||||||
status: normalizedStatus as any,
|
status: normalizedStatus as any,
|
||||||
comment: step.comment || approval?.comment,
|
comment: step.comment || approval?.comment,
|
||||||
@ -360,21 +489,33 @@ export function DealerClaimWorkflowTab({
|
|||||||
elapsedHours, // Only non-zero for active/completed steps
|
elapsedHours, // Only non-zero for active/completed steps
|
||||||
ioDetails,
|
ioDetails,
|
||||||
dmsDetails,
|
dmsDetails,
|
||||||
einvoiceUrl: step.step === 7 ? (approval as any)?.einvoiceUrl : undefined,
|
einvoiceUrl: actualStepNumber === 7 ? (approval as any)?.einvoiceUrl : undefined,
|
||||||
emailTemplateUrl: step.step === 4 ? (approval as any)?.emailTemplateUrl : undefined,
|
emailTemplateUrl: (approval as any)?.emailTemplateUrl || undefined,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSteps = request?.totalSteps || 8;
|
const totalSteps = request?.totalSteps || 8;
|
||||||
|
|
||||||
// Calculate currentStep from approval flow - find the first pending or in_progress step
|
// Calculate currentStep from approval flow - find the first pending or in_progress step
|
||||||
// If no pending/in_progress step, use the request's currentStep
|
// IMPORTANT: Use the workflow's currentLevel from backend (most accurate)
|
||||||
|
// Fallback to finding first pending step if currentLevel not available
|
||||||
// Note: Status normalization already handled in workflowSteps mapping above
|
// Note: Status normalization already handled in workflowSteps mapping above
|
||||||
const activeStep = workflowSteps.find(s => {
|
const backendCurrentLevel = request?.currentLevel || request?.current_level || request?.currentStep;
|
||||||
const status = s.status?.toLowerCase() || '';
|
|
||||||
return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review';
|
// Find the step that matches backend's currentLevel
|
||||||
});
|
const activeStepFromBackend = workflowSteps.find(s => s.step === backendCurrentLevel);
|
||||||
const currentStep = activeStep ? activeStep.step : (request?.currentStep || 1);
|
|
||||||
|
// If backend currentLevel exists and step is pending/in_progress, use it
|
||||||
|
// Otherwise, find first pending/in_progress step
|
||||||
|
const activeStep = activeStepFromBackend &&
|
||||||
|
(activeStepFromBackend.status === 'pending' || activeStepFromBackend.status === 'in_progress')
|
||||||
|
? activeStepFromBackend
|
||||||
|
: workflowSteps.find(s => {
|
||||||
|
const status = s.status?.toLowerCase() || '';
|
||||||
|
return status === 'pending' || status === 'in_progress' || status === 'in-review' || status === 'in_review';
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentStep = activeStep ? activeStep.step : (backendCurrentLevel || request?.currentStep || 1);
|
||||||
|
|
||||||
// Check if current user is the dealer (for steps 1 and 5)
|
// Check if current user is the dealer (for steps 1 and 5)
|
||||||
const userEmail = (user as any)?.email?.toLowerCase() || '';
|
const userEmail = (user as any)?.email?.toLowerCase() || '';
|
||||||
@ -394,8 +535,30 @@ export function DealerClaimWorkflowTab({
|
|||||||
const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase();
|
const approverEmail = (currentApprovalLevel?.approverEmail || '').toLowerCase();
|
||||||
const isCurrentApprover = approverEmail && userEmail === approverEmail;
|
const isCurrentApprover = approverEmail && userEmail === approverEmail;
|
||||||
|
|
||||||
// Check if user is approver for step 2 (requestor evaluation) - match by email
|
// Find the initiator's step dynamically (Requestor Evaluation step)
|
||||||
const step2Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 2);
|
// This handles cases where approvers are added between steps, causing step numbers to shift
|
||||||
|
const initiatorEmail = (
|
||||||
|
(request as any)?.initiator?.email?.toLowerCase() ||
|
||||||
|
(request as any)?.initiatorEmail?.toLowerCase() ||
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the step where the initiator is the approver
|
||||||
|
// Check by: 1) approverEmail matches initiatorEmail, OR 2) levelName contains "Requestor Evaluation"
|
||||||
|
const initiatorStepLevel = approvalFlow.find((l: any) => {
|
||||||
|
const levelApproverEmail = (l.approverEmail || '').toLowerCase();
|
||||||
|
const levelName = (l.levelName || '').toLowerCase();
|
||||||
|
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
|
||||||
|
levelName.includes('requestor evaluation') ||
|
||||||
|
levelName.includes('requestor') && levelName.includes('confirmation');
|
||||||
|
});
|
||||||
|
|
||||||
|
const initiatorStepNumber = initiatorStepLevel
|
||||||
|
? (initiatorStepLevel.step || initiatorStepLevel.levelNumber || initiatorStepLevel.level_number || 2)
|
||||||
|
: 2; // Fallback to 2 if not found
|
||||||
|
|
||||||
|
// Check if user is approver for the initiator's step (requestor evaluation)
|
||||||
|
const step2Level = initiatorStepLevel || approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 2);
|
||||||
const step2ApproverEmail = (step2Level?.approverEmail || '').toLowerCase();
|
const step2ApproverEmail = (step2Level?.approverEmail || '').toLowerCase();
|
||||||
const isStep2Approver = step2ApproverEmail && userEmail === step2ApproverEmail;
|
const isStep2Approver = step2ApproverEmail && userEmail === step2ApproverEmail;
|
||||||
|
|
||||||
@ -404,9 +567,12 @@ export function DealerClaimWorkflowTab({
|
|||||||
const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase();
|
const step1ApproverEmail = (step1Level?.approverEmail || '').toLowerCase();
|
||||||
const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail;
|
const isStep1Approver = step1ApproverEmail && userEmail === step1ApproverEmail;
|
||||||
|
|
||||||
// Check if user is approver for step 3 (department lead approval) - match by email
|
// Find Department Lead step dynamically (handles step shifts)
|
||||||
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
|
const deptLeadStepLevel = approvalFlow.find((l: any) => {
|
||||||
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
|
const levelName = (l.levelName || '').toLowerCase();
|
||||||
|
return levelName.includes('department lead');
|
||||||
|
});
|
||||||
|
const step3ApproverEmail = (deptLeadStepLevel?.approverEmail || '').toLowerCase();
|
||||||
const isStep3Approver = step3ApproverEmail && userEmail === step3ApproverEmail;
|
const isStep3Approver = step3ApproverEmail && userEmail === step3ApproverEmail;
|
||||||
|
|
||||||
// Handle proposal submission
|
// Handle proposal submission
|
||||||
@ -467,20 +633,39 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
|
|
||||||
// Get approval levels to find Step 2 levelId
|
// Get approval levels to find the initiator's step levelId dynamically
|
||||||
const details = await getWorkflowDetails(requestId);
|
const details = await getWorkflowDetails(requestId);
|
||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||||
const step2Level = approvals.find((level: any) =>
|
|
||||||
(level.levelNumber || level.level_number) === 2
|
// Find the initiator's step by checking approverEmail or levelName
|
||||||
|
const initiatorEmail = (
|
||||||
|
(request as any)?.initiator?.email?.toLowerCase() ||
|
||||||
|
(request as any)?.initiatorEmail?.toLowerCase() ||
|
||||||
|
''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const step2Level = approvals.find((level: any) => {
|
||||||
|
const levelApproverEmail = (level.approverEmail || level.approver_email || '').toLowerCase();
|
||||||
|
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
||||||
|
const levelNumber = level.levelNumber || level.level_number;
|
||||||
|
|
||||||
|
// Check if this is the initiator's step
|
||||||
|
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
|
||||||
|
levelName.includes('requestor evaluation') ||
|
||||||
|
(levelName.includes('requestor') && levelName.includes('confirmation')) ||
|
||||||
|
// Fallback: if initiatorStepNumber was found earlier, use it
|
||||||
|
(levelNumber === initiatorStepNumber);
|
||||||
|
}) || approvals.find((level: any) =>
|
||||||
|
(level.levelNumber || level.level_number) === 2
|
||||||
|
); // Final fallback to level 2
|
||||||
|
|
||||||
if (!step2Level?.levelId && !step2Level?.level_id) {
|
if (!step2Level?.levelId && !step2Level?.level_id) {
|
||||||
throw new Error('Step 2 approval level not found');
|
throw new Error('Initiator approval level not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelId = step2Level.levelId || step2Level.level_id;
|
const levelId = step2Level.levelId || step2Level.level_id;
|
||||||
|
|
||||||
// Approve Step 2 using real API
|
// Approve the initiator's step using real API
|
||||||
await approveLevel(requestId, levelId, comments);
|
await approveLevel(requestId, levelId, comments);
|
||||||
|
|
||||||
// Activity is logged by backend approval service - no need to create work note
|
// Activity is logged by backend approval service - no need to create work note
|
||||||
@ -503,20 +688,39 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
|
|
||||||
// Get approval levels to find Step 2 levelId
|
// Get approval levels to find the initiator's step levelId dynamically
|
||||||
const details = await getWorkflowDetails(requestId);
|
const details = await getWorkflowDetails(requestId);
|
||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||||
const step2Level = approvals.find((level: any) =>
|
|
||||||
(level.levelNumber || level.level_number) === 2
|
// Find the initiator's step by checking approverEmail or levelName
|
||||||
|
const initiatorEmail = (
|
||||||
|
(request as any)?.initiator?.email?.toLowerCase() ||
|
||||||
|
(request as any)?.initiatorEmail?.toLowerCase() ||
|
||||||
|
''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const step2Level = approvals.find((level: any) => {
|
||||||
|
const levelApproverEmail = (level.approverEmail || level.approver_email || '').toLowerCase();
|
||||||
|
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
||||||
|
const levelNumber = level.levelNumber || level.level_number;
|
||||||
|
|
||||||
|
// Check if this is the initiator's step
|
||||||
|
return (initiatorEmail && levelApproverEmail === initiatorEmail) ||
|
||||||
|
levelName.includes('requestor evaluation') ||
|
||||||
|
(levelName.includes('requestor') && levelName.includes('confirmation')) ||
|
||||||
|
// Fallback: if initiatorStepNumber was found earlier, use it
|
||||||
|
(levelNumber === initiatorStepNumber);
|
||||||
|
}) || approvals.find((level: any) =>
|
||||||
|
(level.levelNumber || level.level_number) === 2
|
||||||
|
); // Final fallback to level 2
|
||||||
|
|
||||||
if (!step2Level?.levelId && !step2Level?.level_id) {
|
if (!step2Level?.levelId && !step2Level?.level_id) {
|
||||||
throw new Error('Step 2 approval level not found');
|
throw new Error('Initiator approval level not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelId = step2Level.levelId || step2Level.level_id;
|
const levelId = step2Level.levelId || step2Level.level_id;
|
||||||
|
|
||||||
// Reject Step 2 using real API
|
// Reject the initiator's step using real API
|
||||||
await rejectLevel(requestId, levelId, 'Proposal rejected by requestor', comments);
|
await rejectLevel(requestId, levelId, 'Proposal rejected by requestor', comments);
|
||||||
|
|
||||||
// Activity is logged by backend approval service - no need to create work note
|
// Activity is logged by backend approval service - no need to create work note
|
||||||
@ -530,7 +734,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle IO approval (Step 3)
|
// Handle IO approval (Department Lead step - found dynamically)
|
||||||
const handleIOApproval = async (data: {
|
const handleIOApproval = async (data: {
|
||||||
ioNumber: string;
|
ioNumber: string;
|
||||||
ioRemark: string;
|
ioRemark: string;
|
||||||
@ -543,15 +747,20 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
|
|
||||||
// Get approval levels to find Step 3 levelId
|
// Get approval levels to find Department Lead step levelId dynamically
|
||||||
const details = await getWorkflowDetails(requestId);
|
const details = await getWorkflowDetails(requestId);
|
||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||||
const step3Level = approvals.find((level: any) =>
|
|
||||||
|
// Find Department Lead step by levelName (handles step shifts)
|
||||||
|
const step3Level = approvals.find((level: any) => {
|
||||||
|
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
||||||
|
return levelName.includes('department lead');
|
||||||
|
}) || approvals.find((level: any) =>
|
||||||
(level.levelNumber || level.level_number) === 3
|
(level.levelNumber || level.level_number) === 3
|
||||||
);
|
); // Fallback to level 3
|
||||||
|
|
||||||
if (!step3Level?.levelId && !step3Level?.level_id) {
|
if (!step3Level?.levelId && !step3Level?.level_id) {
|
||||||
throw new Error('Step 3 approval level not found');
|
throw new Error('Department Lead approval level not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelId = step3Level.levelId || step3Level.level_id;
|
const levelId = step3Level.levelId || step3Level.level_id;
|
||||||
@ -673,20 +882,25 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
|
|
||||||
// Get approval levels to find Step 3 levelId
|
// Get approval levels to find Department Lead step levelId dynamically
|
||||||
const details = await getWorkflowDetails(requestId);
|
const details = await getWorkflowDetails(requestId);
|
||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
const approvals = details?.approvalLevels || details?.approvals || [];
|
||||||
const step3Level = approvals.find((level: any) =>
|
|
||||||
|
// Find Department Lead step by levelName (handles step shifts)
|
||||||
|
const step3Level = approvals.find((level: any) => {
|
||||||
|
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
||||||
|
return levelName.includes('department lead');
|
||||||
|
}) || approvals.find((level: any) =>
|
||||||
(level.levelNumber || level.level_number) === 3
|
(level.levelNumber || level.level_number) === 3
|
||||||
);
|
); // Fallback to level 3
|
||||||
|
|
||||||
if (!step3Level?.levelId && !step3Level?.level_id) {
|
if (!step3Level?.levelId && !step3Level?.level_id) {
|
||||||
throw new Error('Step 3 approval level not found');
|
throw new Error('Department Lead approval level not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelId = step3Level.levelId || step3Level.level_id;
|
const levelId = step3Level.levelId || step3Level.level_id;
|
||||||
|
|
||||||
// Reject Step 3 using real API
|
// Reject Department Lead step using real API
|
||||||
await rejectLevel(requestId, levelId, 'Dept Lead rejected - More clarification required', comments);
|
await rejectLevel(requestId, levelId, 'Dept Lead rejected - More clarification required', comments);
|
||||||
|
|
||||||
// Activity is logged by backend approval service - no need to create work note
|
// Activity is logged by backend approval service - no need to create work note
|
||||||
@ -824,8 +1038,16 @@ export function DealerClaimWorkflowTab({
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{workflowSteps.map((step, index) => {
|
{workflowSteps.map((step, index) => {
|
||||||
// Step is active if it's pending or in_progress and matches currentStep
|
// Step is active if:
|
||||||
const isActive = (step.status === 'pending' || step.status === 'in_progress') && step.step === currentStep;
|
// 1. It's pending or in_progress
|
||||||
|
// 2. AND it matches currentStep (from backend or calculated)
|
||||||
|
// 3. AND it's the actual current step (not a future step that happens to be pending)
|
||||||
|
const stepStatus = step.status?.toLowerCase() || '';
|
||||||
|
const isPendingOrInProgress = stepStatus === 'pending' || stepStatus === 'in_progress';
|
||||||
|
const matchesCurrentStep = step.step === currentStep;
|
||||||
|
|
||||||
|
// Step is active only if it matches the current step AND is pending/in_progress
|
||||||
|
const isActive = isPendingOrInProgress && matchesCurrentStep;
|
||||||
const isCompleted = step.status === 'approved';
|
const isCompleted = step.status === 'approved';
|
||||||
|
|
||||||
// Find approval data for this step to get SLA information
|
// Find approval data for this step to get SLA information
|
||||||
@ -863,8 +1085,8 @@ export function DealerClaimWorkflowTab({
|
|||||||
<Badge className={getStepBadgeVariant(step.status)}>
|
<Badge className={getStepBadgeVariant(step.status)}>
|
||||||
{step.status.toLowerCase()}
|
{step.status.toLowerCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* Email Template Button (Step 4) - Show when approved */}
|
{/* Email Template Button - Show when step has emailTemplateUrl and is approved */}
|
||||||
{step.step === 4 && step.status === 'approved' && (
|
{step.emailTemplateUrl && step.status === 'approved' && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -1076,7 +1298,23 @@ export function DealerClaimWorkflowTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
{isActive && (
|
{/* Only show action buttons if:
|
||||||
|
1. Step is active (pending/in_progress and matches currentStep)
|
||||||
|
2. AND current user is the approver for this step (or is dealer for dealer steps) */}
|
||||||
|
{(() => {
|
||||||
|
// Find the step level from approvalFlow to verify user is the approver
|
||||||
|
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
|
||||||
|
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
|
||||||
|
const isUserApproverForThisStep = stepApproverEmail && userEmail === stepApproverEmail;
|
||||||
|
|
||||||
|
// For dealer steps (1 and 5), also check if user is dealer
|
||||||
|
const isDealerStep = step.step === 1 ||
|
||||||
|
(stepLevel?.levelName && stepLevel.levelName.toLowerCase().includes('dealer'));
|
||||||
|
const isUserAuthorized = isUserApproverForThisStep || (isDealerStep && isDealer);
|
||||||
|
|
||||||
|
// Step must be active AND user must be authorized
|
||||||
|
return isActive && isUserAuthorized;
|
||||||
|
})() && (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
|
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
|
||||||
{step.step === 1 && (isDealer || isStep1Approver) && (
|
{step.step === 1 && (isDealer || isStep1Approver) && (
|
||||||
@ -1091,8 +1329,9 @@ export function DealerClaimWorkflowTab({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Confirm Request - Only for initiator or step 2 approver */}
|
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
||||||
{step.step === 2 && (isInitiator || isStep2Approver) && (
|
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
|
||||||
|
{step.step === initiatorStepNumber && (isInitiator || isStep2Approver) && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -1100,31 +1339,77 @@ export function DealerClaimWorkflowTab({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
Confirm Request
|
Review Request
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Approve and Organise IO - Only for department lead (step 3 approver) */}
|
{/* Department Lead Step: Approve and Organise IO - Find dynamically by levelName */}
|
||||||
{step.step === 3 && (() => {
|
{(() => {
|
||||||
// Find step 3 from approvalFlow to get approverEmail
|
// Find Department Lead step dynamically (handles step shifts)
|
||||||
const step3Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 3);
|
const deptLeadStepLevel = approvalFlow.find((l: any) => {
|
||||||
const step3ApproverEmail = (step3Level?.approverEmail || '').toLowerCase();
|
const levelName = (l.levelName || '').toLowerCase();
|
||||||
const isStep3ApproverByEmail = step3ApproverEmail && userEmail === step3ApproverEmail;
|
return levelName.includes('department lead');
|
||||||
return isStep3ApproverByEmail || isStep3Approver || isCurrentApprover;
|
});
|
||||||
|
|
||||||
|
// Check if this is the Department Lead step
|
||||||
|
const isDeptLeadStep = deptLeadStepLevel &&
|
||||||
|
(step.step === (deptLeadStepLevel.step || deptLeadStepLevel.levelNumber || deptLeadStepLevel.level_number));
|
||||||
|
|
||||||
|
if (!isDeptLeadStep) return null;
|
||||||
|
|
||||||
|
// Check if user is the Department Lead approver
|
||||||
|
const deptLeadApproverEmail = (deptLeadStepLevel?.approverEmail || '').toLowerCase();
|
||||||
|
const isDeptLeadApprover = deptLeadApproverEmail && userEmail === deptLeadApproverEmail;
|
||||||
|
|
||||||
|
if (!(isDeptLeadApprover || isStep3Approver || isCurrentApprover)) return null;
|
||||||
|
|
||||||
|
// Check if IO number is available (same way as IO tab and modal)
|
||||||
|
const internalOrder = request?.internalOrder || request?.internal_order;
|
||||||
|
const ioNumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
||||||
|
const hasIONumber = ioNumber && ioNumber.trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{!hasIONumber && (
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-amber-800">IO Number Not Available</p>
|
||||||
|
<p className="text-xs text-amber-700 mt-1">
|
||||||
|
Please add an IO number in the IO tab before approving this step.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="bg-green-600 hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={() => {
|
||||||
|
setShowIOApprovalModal(true);
|
||||||
|
}}
|
||||||
|
disabled={!hasIONumber}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
|
Approve and Organise IO
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Step 5 (or shifted step): Upload Completion Documents - Only for dealer */}
|
||||||
|
{/* Check if dealer is the approver for this step (handles step shifts) */}
|
||||||
|
{(() => {
|
||||||
|
// Find the step level from approvalFlow to verify dealer is the approver
|
||||||
|
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
|
||||||
|
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
|
||||||
|
// Check if dealer is the approver for this step
|
||||||
|
const isDealerForThisStep = isDealer && stepApproverEmail === dealerEmail;
|
||||||
|
// Check if this is the Dealer Completion Documents step
|
||||||
|
// by checking if the levelName contains "Dealer Completion" or "Completion Documents"
|
||||||
|
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
||||||
|
const isDealerCompletionStep = levelName.includes('dealer completion') ||
|
||||||
|
levelName.includes('completion documents');
|
||||||
|
return isDealerForThisStep && isDealerCompletionStep;
|
||||||
})() && (
|
})() && (
|
||||||
<Button
|
|
||||||
className="bg-green-600 hover:bg-green-700"
|
|
||||||
onClick={() => {
|
|
||||||
setShowIOApprovalModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Approve and Organise IO
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 5: Upload Completion Documents - Only for dealer */}
|
|
||||||
{step.step === 5 && isDealer && (
|
|
||||||
<Button
|
<Button
|
||||||
className="bg-purple-600 hover:bg-purple-700"
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -1224,6 +1509,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
requestTitle={request?.title}
|
requestTitle={request?.title}
|
||||||
requestId={request?.id || request?.requestId}
|
requestId={request?.id || request?.requestId}
|
||||||
preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined}
|
preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined}
|
||||||
|
preFilledIORemark={request?.internalOrder?.ioRemark || request?.internalOrder?.io_remark || request?.internal_order?.ioRemark || request?.internal_order?.io_remark || undefined}
|
||||||
preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined}
|
preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined}
|
||||||
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
|
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -29,7 +29,7 @@ interface CreditNoteSAPModalProps {
|
|||||||
creditNoteNumber?: string;
|
creditNoteNumber?: string;
|
||||||
creditNoteDate?: string;
|
creditNoteDate?: string;
|
||||||
creditNoteAmount?: number;
|
creditNoteAmount?: number;
|
||||||
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT';
|
status?: 'PENDING' | 'APPROVED' | 'ISSUED' | 'SENT' | 'CONFIRMED';
|
||||||
};
|
};
|
||||||
dealerInfo?: {
|
dealerInfo?: {
|
||||||
dealerName?: string;
|
dealerName?: string;
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Allows dealers to upload completion documents, photos, expenses, and provide completion details
|
* Allows dealers to upload completion documents, photos, expenses, and provide completion details
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -18,8 +18,9 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2 } from 'lucide-react';
|
import { Upload, Plus, X, Calendar, FileText, Image, Receipt, CircleAlert, CheckCircle2, Eye, Download } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import '@/components/common/FilePreview/FilePreview.css';
|
||||||
|
|
||||||
interface ExpenseItem {
|
interface ExpenseItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -63,12 +64,64 @@ export function DealerCompletionDocumentsModal({
|
|||||||
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
|
const [attendanceSheet, setAttendanceSheet] = useState<File | null>(null);
|
||||||
const [completionDescription, setCompletionDescription] = useState('');
|
const [completionDescription, setCompletionDescription] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
|
||||||
|
|
||||||
const completionDocsInputRef = useRef<HTMLInputElement>(null);
|
const completionDocsInputRef = useRef<HTMLInputElement>(null);
|
||||||
const photosInputRef = useRef<HTMLInputElement>(null);
|
const photosInputRef = useRef<HTMLInputElement>(null);
|
||||||
const invoicesInputRef = useRef<HTMLInputElement>(null);
|
const invoicesInputRef = useRef<HTMLInputElement>(null);
|
||||||
const attendanceInputRef = useRef<HTMLInputElement>(null);
|
const attendanceInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Helper function to check if file can be previewed
|
||||||
|
const canPreviewFile = (file: File): boolean => {
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return type.includes('image') ||
|
||||||
|
type.includes('pdf') ||
|
||||||
|
name.endsWith('.pdf') ||
|
||||||
|
name.endsWith('.jpg') ||
|
||||||
|
name.endsWith('.jpeg') ||
|
||||||
|
name.endsWith('.png') ||
|
||||||
|
name.endsWith('.gif') ||
|
||||||
|
name.endsWith('.webp');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup object URLs when component unmounts or file changes
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewFile]);
|
||||||
|
|
||||||
|
// Handle file preview
|
||||||
|
const handlePreviewFile = (file: File) => {
|
||||||
|
if (!canPreviewFile(file)) {
|
||||||
|
toast.error('Preview is only available for images and PDF files');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup previous preview URL
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreviewFile({ file, url });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle download file (for non-previewable files)
|
||||||
|
const handleDownloadFile = (file: File) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate total closed expenses
|
// Calculate total closed expenses
|
||||||
const totalClosedExpenses = useMemo(() => {
|
const totalClosedExpenses = useMemo(() => {
|
||||||
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
return expenseItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||||
@ -214,6 +267,11 @@ export function DealerCompletionDocumentsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
// Cleanup preview URL if exists
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
setPreviewFile(null);
|
||||||
setActivityCompletionDate('');
|
setActivityCompletionDate('');
|
||||||
setNumberOfParticipants('');
|
setNumberOfParticipants('');
|
||||||
setExpenseItems([]);
|
setExpenseItems([]);
|
||||||
@ -417,16 +475,40 @@ export function DealerCompletionDocumentsModal({
|
|||||||
<FileText className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
<FileText className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
type="button"
|
{canPreviewFile(file) && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
|
variant="ghost"
|
||||||
onClick={() => handleRemoveCompletionDoc(index)}
|
size="sm"
|
||||||
title="Remove document"
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
||||||
>
|
onClick={() => handlePreviewFile(file)}
|
||||||
<X className="w-4 h-4" />
|
title="Preview file"
|
||||||
</Button>
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
||||||
|
onClick={() => handleDownloadFile(file)}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
||||||
|
onClick={() => handleRemoveCompletionDoc(index)}
|
||||||
|
title="Remove document"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -498,16 +580,40 @@ export function DealerCompletionDocumentsModal({
|
|||||||
<Image className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
<Image className="w-4 h-4 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
type="button"
|
{canPreviewFile(file) && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
|
variant="ghost"
|
||||||
onClick={() => handleRemovePhoto(index)}
|
size="sm"
|
||||||
title="Remove photo"
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
||||||
>
|
onClick={() => handlePreviewFile(file)}
|
||||||
<X className="w-4 h-4" />
|
title="Preview photo"
|
||||||
</Button>
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
||||||
|
onClick={() => handleDownloadFile(file)}
|
||||||
|
title="Download photo"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
||||||
|
onClick={() => handleRemovePhoto(index)}
|
||||||
|
title="Remove photo"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -587,16 +693,40 @@ export function DealerCompletionDocumentsModal({
|
|||||||
<Receipt className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
<Receipt className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
<span className="text-gray-800 font-medium break-words break-all">{file.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
type="button"
|
{canPreviewFile(file) && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
|
variant="ghost"
|
||||||
onClick={() => handleRemoveInvoice(index)}
|
size="sm"
|
||||||
title="Remove document"
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
||||||
>
|
onClick={() => handlePreviewFile(file)}
|
||||||
<X className="w-4 h-4" />
|
title="Preview file"
|
||||||
</Button>
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
||||||
|
onClick={() => handleDownloadFile(file)}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
||||||
|
onClick={() => handleRemoveInvoice(index)}
|
||||||
|
title="Remove document"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -652,7 +782,7 @@ export function DealerCompletionDocumentsModal({
|
|||||||
)}
|
)}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{attendanceSheet && (
|
{attendanceSheet && (
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<p className="text-xs font-medium text-gray-600 mb-2">
|
<p className="text-xs font-medium text-gray-600 mb-2">
|
||||||
Selected Document:
|
Selected Document:
|
||||||
@ -662,19 +792,43 @@ export function DealerCompletionDocumentsModal({
|
|||||||
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
<FileText className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
<span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span>
|
<span className="text-gray-800 font-medium break-words break-all">{attendanceSheet.name}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
type="button"
|
{canPreviewFile(attendanceSheet) && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
|
variant="ghost"
|
||||||
onClick={() => {
|
size="sm"
|
||||||
setAttendanceSheet(null);
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
||||||
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
|
onClick={() => handlePreviewFile(attendanceSheet)}
|
||||||
}}
|
title="Preview file"
|
||||||
title="Remove document"
|
>
|
||||||
>
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<X className="w-4 h-4" />
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
||||||
|
onClick={() => handleDownloadFile(attendanceSheet)}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
||||||
|
onClick={() => {
|
||||||
|
setAttendanceSheet(null);
|
||||||
|
if (attendanceInputRef.current) attendanceInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
title="Remove document"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -728,6 +882,96 @@ export function DealerCompletionDocumentsModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* File Preview Modal - Matching DocumentsTab style */}
|
||||||
|
{previewFile && (
|
||||||
|
<Dialog
|
||||||
|
open={!!previewFile}
|
||||||
|
onOpenChange={() => {
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
setPreviewFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="file-preview-dialog p-3 sm:p-6">
|
||||||
|
<div className="file-preview-content">
|
||||||
|
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
|
||||||
|
{previewFile.file.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">
|
||||||
|
{previewFile.file.type || 'Unknown type'} • {(previewFile.file.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mr-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadFile(previewFile.file)}
|
||||||
|
className="gap-2 h-9"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Download</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
|
||||||
|
{previewFile.file.type?.includes('image') ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<img
|
||||||
|
src={previewFile.url}
|
||||||
|
alt={previewFile.file.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
className="rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : previewFile.file.type?.includes('pdf') || previewFile.file.name.toLowerCase().endsWith('.pdf') ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<iframe
|
||||||
|
src={previewFile.url}
|
||||||
|
className="w-full h-full rounded-lg border-0"
|
||||||
|
title={previewFile.file.name}
|
||||||
|
style={{
|
||||||
|
minHeight: '70vh',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Eye className="w-10 h-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
This file type cannot be previewed. Please download to view.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownloadFile(previewFile.file)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download {previewFile.file.name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments
|
* Allows dealers to upload proposal documents, provide cost breakdown, timeline, and comments
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useMemo } from 'react';
|
import { useState, useRef, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -18,8 +18,9 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText } from 'lucide-react';
|
import { Upload, Plus, X, Calendar, DollarSign, CircleAlert, CheckCircle2, FileText, Eye, Download } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import '@/components/common/FilePreview/FilePreview.css';
|
||||||
|
|
||||||
interface CostItem {
|
interface CostItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -60,10 +61,63 @@ export function DealerProposalSubmissionModal({
|
|||||||
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
|
const [otherDocuments, setOtherDocuments] = useState<File[]>([]);
|
||||||
const [dealerComments, setDealerComments] = useState('');
|
const [dealerComments, setDealerComments] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
|
||||||
|
|
||||||
const proposalDocInputRef = useRef<HTMLInputElement>(null);
|
const proposalDocInputRef = useRef<HTMLInputElement>(null);
|
||||||
const otherDocsInputRef = useRef<HTMLInputElement>(null);
|
const otherDocsInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Helper function to check if file can be previewed
|
||||||
|
const canPreviewFile = (file: File): boolean => {
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
return type.includes('image') ||
|
||||||
|
type.includes('pdf') ||
|
||||||
|
name.endsWith('.pdf') ||
|
||||||
|
name.endsWith('.jpg') ||
|
||||||
|
name.endsWith('.jpeg') ||
|
||||||
|
name.endsWith('.png') ||
|
||||||
|
name.endsWith('.gif') ||
|
||||||
|
name.endsWith('.webp');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup object URLs when component unmounts or file changes
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewFile]);
|
||||||
|
|
||||||
|
// Handle file preview - instant preview using object URL
|
||||||
|
const handlePreviewFile = (file: File) => {
|
||||||
|
if (!canPreviewFile(file)) {
|
||||||
|
toast.error('Preview is only available for images and PDF files');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup previous preview URL
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create object URL immediately for instant preview
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
setPreviewFile({ file, url });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle download file (for non-previewable files)
|
||||||
|
const handleDownloadFile = (file: File) => {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate total estimated budget
|
// Calculate total estimated budget
|
||||||
const totalBudget = useMemo(() => {
|
const totalBudget = useMemo(() => {
|
||||||
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
return costItems.reduce((sum, item) => sum + (item.amount || 0), 0);
|
||||||
@ -164,6 +218,11 @@ export function DealerProposalSubmissionModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
|
// Cleanup preview URL if exists
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
setPreviewFile(null);
|
||||||
setProposalDocument(null);
|
setProposalDocument(null);
|
||||||
setCostItems([{ id: '1', description: '', amount: 0 }]);
|
setCostItems([{ id: '1', description: '', amount: 0 }]);
|
||||||
setTimelineMode('date');
|
setTimelineMode('date');
|
||||||
@ -243,17 +302,41 @@ export function DealerProposalSubmissionModal({
|
|||||||
className="cursor-pointer flex flex-col items-center gap-2"
|
className="cursor-pointer flex flex-col items-center gap-2"
|
||||||
>
|
>
|
||||||
{proposalDocument ? (
|
{proposalDocument ? (
|
||||||
<>
|
<div className="flex flex-col items-center gap-2 w-full">
|
||||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||||
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
|
<div className="flex flex-col items-center gap-1 w-full max-w-full px-2">
|
||||||
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
|
<span className="text-sm font-semibold text-green-700 break-words text-center w-full max-w-full">
|
||||||
{proposalDocument.name}
|
{proposalDocument.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-green-600">
|
<span className="text-xs text-green-600 mb-2">
|
||||||
Document selected
|
Document selected
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="flex items-center gap-2">
|
||||||
|
{canPreviewFile(proposalDocument) && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePreviewFile(proposalDocument)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadFile(proposalDocument)}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5 mr-1" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Upload className="w-8 h-8 text-gray-400" />
|
<Upload className="w-8 h-8 text-gray-400" />
|
||||||
@ -467,16 +550,40 @@ export function DealerProposalSubmissionModal({
|
|||||||
{file.name}
|
{file.name}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
|
||||||
type="button"
|
{canPreviewFile(file) && (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
type="button"
|
||||||
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700 flex-shrink-0 ml-2"
|
variant="ghost"
|
||||||
onClick={() => handleRemoveOtherDoc(index)}
|
size="sm"
|
||||||
title="Remove document"
|
className="h-7 w-7 p-0 hover:bg-blue-100 hover:text-blue-700"
|
||||||
>
|
onClick={() => handlePreviewFile(file)}
|
||||||
<X className="w-4 h-4" />
|
title="Preview file"
|
||||||
</Button>
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-green-100 hover:text-green-700"
|
||||||
|
onClick={() => handleDownloadFile(file)}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-700"
|
||||||
|
onClick={() => handleRemoveOtherDoc(index)}
|
||||||
|
title="Remove document"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -531,6 +638,96 @@ export function DealerProposalSubmissionModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* File Preview Modal - Matching DocumentsTab style */}
|
||||||
|
{previewFile && (
|
||||||
|
<Dialog
|
||||||
|
open={!!previewFile}
|
||||||
|
onOpenChange={() => {
|
||||||
|
if (previewFile?.url) {
|
||||||
|
URL.revokeObjectURL(previewFile.url);
|
||||||
|
}
|
||||||
|
setPreviewFile(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="file-preview-dialog p-3 sm:p-6">
|
||||||
|
<div className="file-preview-content">
|
||||||
|
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
|
||||||
|
{previewFile.file.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">
|
||||||
|
{previewFile.file.type || 'Unknown type'} • {(previewFile.file.size / 1024).toFixed(1)} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mr-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDownloadFile(previewFile.file)}
|
||||||
|
className="gap-2 h-9"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Download</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
|
||||||
|
{previewFile.file.type?.includes('image') ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<img
|
||||||
|
src={previewFile.url}
|
||||||
|
alt={previewFile.file.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
className="rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : previewFile.file.type?.includes('pdf') || previewFile.file.name.toLowerCase().endsWith('.pdf') ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<iframe
|
||||||
|
src={previewFile.url}
|
||||||
|
className="w-full h-full rounded-lg border-0"
|
||||||
|
title={previewFile.file.name}
|
||||||
|
style={{
|
||||||
|
minHeight: '70vh',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Eye className="w-10 h-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
This file type cannot be previewed. Please download to view.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDownloadFile(previewFile.file)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download {previewFile.file.name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ interface DeptLeadIOApprovalModalProps {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
// Pre-filled IO data from IO table
|
// Pre-filled IO data from IO table
|
||||||
preFilledIONumber?: string;
|
preFilledIONumber?: string;
|
||||||
|
preFilledIORemark?: string;
|
||||||
preFilledBlockedAmount?: number;
|
preFilledBlockedAmount?: number;
|
||||||
preFilledRemainingBalance?: number;
|
preFilledRemainingBalance?: number;
|
||||||
}
|
}
|
||||||
@ -47,6 +48,7 @@ export function DeptLeadIOApprovalModal({
|
|||||||
requestTitle,
|
requestTitle,
|
||||||
requestId: _requestId,
|
requestId: _requestId,
|
||||||
preFilledIONumber,
|
preFilledIONumber,
|
||||||
|
preFilledIORemark,
|
||||||
preFilledBlockedAmount,
|
preFilledBlockedAmount,
|
||||||
preFilledRemainingBalance,
|
preFilledRemainingBalance,
|
||||||
}: DeptLeadIOApprovalModalProps) {
|
}: DeptLeadIOApprovalModalProps) {
|
||||||
@ -61,12 +63,12 @@ export function DeptLeadIOApprovalModal({
|
|||||||
// Reset form when modal opens/closes
|
// Reset form when modal opens/closes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Reset form when modal opens
|
// Prefill IO remark from props if available
|
||||||
setIoRemark('');
|
setIoRemark(preFilledIORemark || '');
|
||||||
setComments('');
|
setComments('');
|
||||||
setActionType('approve');
|
setActionType('approve');
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen, preFilledIORemark]);
|
||||||
|
|
||||||
const ioRemarkChars = ioRemark.length;
|
const ioRemarkChars = ioRemark.length;
|
||||||
const commentsChars = comments.length;
|
const commentsChars = comments.length;
|
||||||
@ -269,7 +271,7 @@ export function DeptLeadIOApprovalModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IO Remark - Only editable field */}
|
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
IO Remark <span className="text-red-500">*</span>
|
IO Remark <span className="text-red-500">*</span>
|
||||||
@ -284,11 +286,18 @@ export function DeptLeadIOApprovalModal({
|
|||||||
setIoRemark(value);
|
setIoRemark(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={2}
|
rows={3}
|
||||||
className="bg-white text-sm min-h-[60px] resize-none"
|
className="bg-white text-sm min-h-[80px] resize-none"
|
||||||
|
disabled={false}
|
||||||
|
readOnly={false}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end text-xs text-gray-600">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
|
{preFilledIORemark && (
|
||||||
|
<span className="text-blue-600">
|
||||||
|
✓ Prefilled from IO tab (editable)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Allows initiator to review dealer's proposal and approve/reject
|
* Allows initiator to review dealer's proposal and approve/reject
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -25,8 +25,11 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
||||||
|
import '@/components/common/FilePreview/FilePreview.css';
|
||||||
|
|
||||||
interface CostItem {
|
interface CostItem {
|
||||||
id: string;
|
id: string;
|
||||||
@ -75,6 +78,13 @@ export function InitiatorProposalApprovalModal({
|
|||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
|
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
|
||||||
|
const [previewDocument, setPreviewDocument] = useState<{
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
type?: string;
|
||||||
|
size?: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
|
||||||
// Calculate total budget
|
// Calculate total budget
|
||||||
const totalBudget = useMemo(() => {
|
const totalBudget = useMemo(() => {
|
||||||
@ -110,6 +120,88 @@ export function InitiatorProposalApprovalModal({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Check if document can be previewed
|
||||||
|
const canPreviewDocument = (doc: { name: string; url?: string; id?: string }): boolean => {
|
||||||
|
if (!doc.name) return false;
|
||||||
|
const name = doc.name.toLowerCase();
|
||||||
|
return name.endsWith('.pdf') ||
|
||||||
|
name.endsWith('.jpg') ||
|
||||||
|
name.endsWith('.jpeg') ||
|
||||||
|
name.endsWith('.png') ||
|
||||||
|
name.endsWith('.gif') ||
|
||||||
|
name.endsWith('.webp');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle document preview - fetch as blob to avoid CSP issues
|
||||||
|
const handlePreviewDocument = async (doc: { name: string; url?: string; id?: string }) => {
|
||||||
|
if (!doc.id) {
|
||||||
|
toast.error('Document preview not available - document ID missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreviewLoading(true);
|
||||||
|
try {
|
||||||
|
const previewUrl = getDocumentPreviewUrl(doc.id);
|
||||||
|
|
||||||
|
// Determine file type from name
|
||||||
|
const fileName = doc.name.toLowerCase();
|
||||||
|
const isPDF = fileName.endsWith('.pdf');
|
||||||
|
const isImage = fileName.match(/\.(jpg|jpeg|png|gif|webp)$/i);
|
||||||
|
|
||||||
|
// Fetch the document as a blob to create a blob URL (CSP compliant)
|
||||||
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
const token = isProduction ? null : localStorage.getItem('accessToken');
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
'Accept': isPDF ? 'application/pdf' : '*/*'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isProduction && token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(previewUrl, {
|
||||||
|
headers,
|
||||||
|
credentials: 'include',
|
||||||
|
mode: 'cors'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to load file: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
|
||||||
|
if (blob.size === 0) {
|
||||||
|
throw new Error('File is empty or could not be loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create blob URL (CSP compliant - uses 'blob:' protocol)
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
setPreviewDocument({
|
||||||
|
name: doc.name,
|
||||||
|
url: blobUrl,
|
||||||
|
type: blob.type || (isPDF ? 'application/pdf' : isImage ? 'image' : undefined),
|
||||||
|
size: blob.size,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load document preview:', error);
|
||||||
|
toast.error('Failed to load document preview');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cleanup blob URLs on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (previewDocument?.url && previewDocument.url.startsWith('blob:')) {
|
||||||
|
window.URL.revokeObjectURL(previewDocument.url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [previewDocument]);
|
||||||
|
|
||||||
const handleApprove = async () => {
|
const handleApprove = async () => {
|
||||||
if (!comments.trim()) {
|
if (!comments.trim()) {
|
||||||
toast.error('Please provide approval comments');
|
toast.error('Please provide approval comments');
|
||||||
@ -216,30 +308,41 @@ export function InitiatorProposalApprovalModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{proposalData.proposalDocument.url && (
|
{proposalData.proposalDocument.id && (
|
||||||
<>
|
<>
|
||||||
<Button
|
{canPreviewDocument(proposalData.proposalDocument) && (
|
||||||
variant="outline"
|
<button
|
||||||
size="sm"
|
type="button"
|
||||||
onClick={() => window.open(proposalData.proposalDocument?.url, '_blank')}
|
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
|
||||||
>
|
disabled={previewLoading}
|
||||||
<Eye className="w-4 h-4 mr-1" />
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
View
|
title="Preview document"
|
||||||
</Button>
|
>
|
||||||
<Button
|
{previewLoading ? (
|
||||||
variant="outline"
|
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
||||||
size="sm"
|
) : (
|
||||||
onClick={() => {
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
const link = document.createElement('a');
|
)}
|
||||||
link.href = proposalData.proposalDocument?.url || '';
|
</button>
|
||||||
link.download = proposalData.proposalDocument?.name || '';
|
)}
|
||||||
link.click();
|
<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 className="w-5 h-5 text-gray-600" />
|
||||||
Download
|
</button>
|
||||||
</Button>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -342,14 +445,41 @@ export function InitiatorProposalApprovalModal({
|
|||||||
<FileText className="w-5 h-5 text-gray-600" />
|
<FileText className="w-5 h-5 text-gray-600" />
|
||||||
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
|
<p className="text-sm font-medium text-gray-900">{doc.name}</p>
|
||||||
</div>
|
</div>
|
||||||
{doc.url && (
|
{doc.id && (
|
||||||
<Button
|
<div className="flex items-center gap-1">
|
||||||
variant="ghost"
|
{canPreviewDocument(doc) && (
|
||||||
size="sm"
|
<button
|
||||||
onClick={() => window.open(doc.url, '_blank')}
|
type="button"
|
||||||
>
|
onClick={() => handlePreviewDocument(doc)}
|
||||||
<Download className="w-4 h-4" />
|
disabled={previewLoading}
|
||||||
</Button>
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
title="Preview document"
|
||||||
|
>
|
||||||
|
{previewLoading ? (
|
||||||
|
<Loader2 className="w-5 h-5 text-blue-600 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (doc.id) {
|
||||||
|
await downloadDocument(doc.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download document:', error);
|
||||||
|
toast.error('Failed to download document');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -437,6 +567,110 @@ export function InitiatorProposalApprovalModal({
|
|||||||
</div>
|
</div>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* File Preview Modal - Matching DocumentsTab style */}
|
||||||
|
{previewDocument && (
|
||||||
|
<Dialog
|
||||||
|
open={!!previewDocument}
|
||||||
|
onOpenChange={() => setPreviewDocument(null)}
|
||||||
|
>
|
||||||
|
<DialogContent className="file-preview-dialog p-3 sm:p-6">
|
||||||
|
<div className="file-preview-content">
|
||||||
|
<DialogHeader className="pb-4 flex-shrink-0 pr-8">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<Eye className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<DialogTitle className="text-base sm:text-lg font-bold text-gray-900 truncate pr-2">
|
||||||
|
{previewDocument.name}
|
||||||
|
</DialogTitle>
|
||||||
|
{previewDocument.type && (
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">
|
||||||
|
{previewDocument.type} {previewDocument.size && `• ${(previewDocument.size / 1024).toFixed(1)} KB`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap mr-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = previewDocument.url;
|
||||||
|
link.download = previewDocument.name;
|
||||||
|
link.click();
|
||||||
|
}}
|
||||||
|
className="gap-2 h-9"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Download</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="file-preview-body bg-gray-100 rounded-lg p-2 sm:p-4">
|
||||||
|
{previewLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-full min-h-[70vh]">
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-600">Loading preview...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : previewDocument.name.toLowerCase().endsWith('.pdf') || previewDocument.type?.includes('pdf') ? (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
<iframe
|
||||||
|
src={previewDocument.url}
|
||||||
|
className="w-full h-full rounded-lg border-0"
|
||||||
|
title={previewDocument.name}
|
||||||
|
style={{
|
||||||
|
minHeight: '70vh',
|
||||||
|
height: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : previewDocument.name.match(/\.(jpg|jpeg|png|gif|webp)$/i) || previewDocument.type?.includes('image') ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<img
|
||||||
|
src={previewDocument.url}
|
||||||
|
alt={previewDocument.name}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain'
|
||||||
|
}}
|
||||||
|
className="rounded-lg shadow-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||||
|
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Eye className="w-10 h-10 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Preview Not Available</h3>
|
||||||
|
<p className="text-sm text-gray-600 mb-6">
|
||||||
|
This file type cannot be previewed. Please download to view.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = previewDocument.url;
|
||||||
|
link.download = previewDocument.name;
|
||||||
|
link.click();
|
||||||
|
}}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Download {previewDocument.name}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -129,54 +129,36 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
accessDenied,
|
accessDenied,
|
||||||
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
|
||||||
|
|
||||||
// Determine if user is initiator
|
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
|
||||||
const currentUserId = (user as any)?.userId || '';
|
const currentUserId = (user as any)?.userId || '';
|
||||||
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
|
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
|
||||||
const initiatorUserId = apiRequest?.initiator?.userId;
|
|
||||||
const initiatorEmail = apiRequest?.initiator?.email?.toLowerCase();
|
|
||||||
const isUserInitiator = apiRequest?.initiator && (
|
|
||||||
(initiatorUserId && initiatorUserId === currentUserId) ||
|
|
||||||
(initiatorEmail && initiatorEmail === currentUserEmail)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Determine if user is department lead (whoever is in step 3 / approval level 3)
|
|
||||||
// Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber
|
// Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber
|
||||||
const approvalFlow = apiRequest?.approvalFlow || [];
|
const approvalFlow = apiRequest?.approvalFlow || [];
|
||||||
const approvals = apiRequest?.approvals || [];
|
const approvals = apiRequest?.approvals || [];
|
||||||
|
|
||||||
// Try to find Step 3 from approvalFlow first (has 'step' property), then from approvals (has 'levelNumber')
|
// Find Department Lead step dynamically by levelName (handles step shifts when approvers are added)
|
||||||
const step3Level = approvalFlow.find((level: any) =>
|
const deptLeadLevel = approvalFlow.find((level: any) => {
|
||||||
|
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
||||||
|
return levelName.includes('department lead');
|
||||||
|
}) || approvals.find((level: any) => {
|
||||||
|
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
||||||
|
return levelName.includes('department lead');
|
||||||
|
}) || approvalFlow.find((level: any) =>
|
||||||
(level.step || level.levelNumber || level.level_number) === 3
|
(level.step || level.levelNumber || level.level_number) === 3
|
||||||
) || approvals.find((level: any) =>
|
) || approvals.find((level: any) =>
|
||||||
(level.levelNumber || level.level_number) === 3
|
(level.levelNumber || level.level_number) === 3
|
||||||
);
|
); // Fallback to step 3 for backwards compatibility
|
||||||
|
|
||||||
const deptLeadUserId = step3Level?.approverId || step3Level?.approver_id || step3Level?.approver?.userId;
|
const deptLeadUserId = deptLeadLevel?.approverId || deptLeadLevel?.approver_id || deptLeadLevel?.approver?.userId;
|
||||||
const deptLeadEmail = (step3Level?.approverEmail || step3Level?.approver_email || step3Level?.approver?.email || '').toLowerCase().trim();
|
const deptLeadEmail = (deptLeadLevel?.approverEmail || deptLeadLevel?.approver_email || deptLeadLevel?.approver?.email || '').toLowerCase().trim();
|
||||||
|
|
||||||
// User is department lead if they match the Step 3 approver (regardless of status)
|
// User is department lead if they match the Department Lead approver (regardless of status or step number)
|
||||||
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
|
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
|
||||||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
|
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
|
||||||
|
|
||||||
const step3Status = step3Level?.status ? String(step3Level.status).toUpperCase() : '';
|
|
||||||
const isStep3PendingOrInProgress = step3Status === 'PENDING' ||
|
|
||||||
step3Status === 'IN_PROGRESS' ||
|
|
||||||
step3Status === 'PAUSED';
|
|
||||||
|
|
||||||
const currentLevel = apiRequest?.currentLevel || apiRequest?.current_level || apiRequest?.currentStep || 0;
|
|
||||||
const isStep3CurrentLevel = currentLevel === 3;
|
|
||||||
|
|
||||||
// User is current approver for Step 3 if Step 3 is active and they are the approver
|
|
||||||
const isStep3CurrentApprover = step3Level && isStep3PendingOrInProgress && isStep3CurrentLevel && (
|
|
||||||
(deptLeadUserId && deptLeadUserId === currentUserId) ||
|
|
||||||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail)
|
|
||||||
);
|
|
||||||
|
|
||||||
// IO tab visibility for dealer claims
|
// IO tab visibility for dealer claims
|
||||||
// Show IO tab if user is initiator, department lead (Step 3 approver), or current Step 3 approver
|
// Show IO tab only for department lead (found dynamically, not hardcoded to step 3)
|
||||||
// Also show if Step 3 has been approved (to view IO details)
|
const showIOTab = isDeptLead;
|
||||||
const isStep3Approved = step3Status === 'APPROVED';
|
|
||||||
const showIOTab = isUserInitiator || isDeptLead || isStep3CurrentApprover || isStep3Approved;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mergedMessages,
|
mergedMessages,
|
||||||
|
|||||||
@ -63,7 +63,7 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
|||||||
</Badge>
|
</Badge>
|
||||||
{/* Template Type Badge */}
|
{/* Template Type Badge */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
|
const templateType = request.templateType || '';
|
||||||
const templateTypeUpper = templateType?.toUpperCase() || '';
|
const templateTypeUpper = templateType?.toUpperCase() || '';
|
||||||
|
|
||||||
// Direct mapping from templateType
|
// Direct mapping from templateType
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface ClosedRequest {
|
|||||||
department?: string;
|
department?: string;
|
||||||
totalLevels?: number;
|
totalLevels?: number;
|
||||||
completedLevels?: number;
|
completedLevels?: number;
|
||||||
|
templateType?: string; // Template type for badge display
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClosedRequestsProps {
|
export interface ClosedRequestsProps {
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
|||||||
department: r.department,
|
department: r.department,
|
||||||
totalLevels: r.totalLevels || 0,
|
totalLevels: r.totalLevels || 0,
|
||||||
completedLevels: r.summary?.approvedLevels || 0,
|
completedLevels: r.summary?.approvedLevels || 0,
|
||||||
|
templateType: r.templateType || r.template_type, // Template type for badge display
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,14 @@ export interface CreateClaimRequestPayload {
|
|||||||
periodStartDate?: string; // ISO date string
|
periodStartDate?: string; // ISO date string
|
||||||
periodEndDate?: string; // ISO date string
|
periodEndDate?: string; // ISO date string
|
||||||
estimatedBudget?: string | number;
|
estimatedBudget?: string | number;
|
||||||
selectedManagerEmail?: string; // Optional: When multiple managers found, user selects one
|
approvers?: Array<{
|
||||||
|
email: string;
|
||||||
|
name?: string;
|
||||||
|
userId?: string;
|
||||||
|
level: number;
|
||||||
|
tat?: number | string;
|
||||||
|
tatType?: 'hours' | 'days';
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaimRequestResponse {
|
export interface ClaimRequestResponse {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user