Compare commits

...

2 Commits

16 changed files with 2433 additions and 2503 deletions

View File

@ -412,201 +412,6 @@ function AppRoutes({ onLogout }: AppProps) {
});
}
// Keep the old code below for backward compatibility (local storage fallback)
// This can be removed once API integration is fully tested
/*
// Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
// Create full request object
const newRequest = {
id: requestId,
title: `${claimData.activityName} - Claim Request`,
description: claimData.requestDescription,
category: 'Dealer Operations',
subcategory: 'Claim Management',
status: 'pending',
priority: 'standard',
amount: 'TBD',
slaProgress: 0,
slaRemaining: '7 days',
slaEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
currentStep: 1,
totalSteps: 8,
templateType: 'claim-management',
templateName: 'Claim Management',
initiator: {
name: 'Current User',
role: 'Regional Marketing Coordinator',
department: 'Marketing',
email: 'current.user@royalenfield.com',
phone: '+91 98765 43290',
avatar: 'CU'
},
department: 'Marketing',
createdAt: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}),
updatedAt: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}),
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
conclusionRemark: '',
claimDetails: {
activityName: claimData.activityName,
activityType: claimData.activityType,
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
location: claimData.location,
dealerCode: claimData.dealerCode,
dealerName: claimData.dealerName,
dealerEmail: claimData.dealerEmail || 'N/A',
dealerPhone: claimData.dealerPhone || 'N/A',
dealerAddress: claimData.dealerAddress || 'N/A',
requestDescription: claimData.requestDescription,
estimatedBudget: claimData.estimatedBudget || 'TBD',
periodStart: claimData.periodStartDate ? new Date(claimData.periodStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
periodEnd: claimData.periodEndDate ? new Date(claimData.periodEndDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
},
approvalFlow: claimData.workflowSteps || [
{
step: 1,
approver: `${claimData.dealerName} (Dealer)`,
role: 'Dealer - Document Upload',
status: 'pending',
tatHours: 72,
elapsedHours: 0,
assignedAt: new Date().toISOString(),
comment: null,
timestamp: null,
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
},
{
step: 2,
approver: 'Current User (Initiator)',
role: 'Initiator Evaluation',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator reviews dealer documents and approves or requests modifications'
},
{
step: 3,
approver: 'System Auto-Process',
role: 'IO Confirmation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
},
{
step: 4,
approver: 'Rajesh Kumar',
role: 'Department Lead Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Department head approves and blocks budget in IO for this activity'
},
{
step: 5,
approver: `${claimData.dealerName} (Dealer)`,
role: 'Dealer - Completion Documents',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Dealer submits activity completion documents and description'
},
{
step: 6,
approver: 'Current User (Initiator)',
role: 'Initiator Verification',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator verifies completion documents and can modify approved amount'
},
{
step: 7,
approver: 'System Auto-Process',
role: 'E-Invoice Generation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Auto-generate e-invoice based on final approved amount'
},
{
step: 8,
approver: 'Finance Team',
role: 'Credit Note Issuance',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Finance team issues credit note to dealer'
}
],
documents: [],
spectators: [],
auditTrail: [
{
type: 'created',
action: 'Request Created',
details: `Claim request for ${claimData.activityName} created`,
user: 'Current User',
timestamp: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})
}
],
tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')]
};
// Add to dynamic requests
setDynamicRequests(prev => [...prev, newRequest]);
// Also add to REQUEST_DATABASE for immediate viewing
(REQUEST_DATABASE as any)[requestId] = newRequest;
toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.',
});
navigate('/my-requests');
*/
};
return (

View File

@ -31,7 +31,7 @@ export function AnalyticsConfig() {
});
const handleSave = () => {
// TODO: Implement API call to save configuration
toast.success('Analytics configuration saved successfully');
};

View File

@ -8,7 +8,7 @@ import { toast } from 'sonner';
export type Role = 'Initiator' | 'Approver' | 'Spectator';
export type KPICard =
export type KPICard =
| 'Total Requests'
| 'Open Requests'
| 'Approved Requests'
@ -59,7 +59,7 @@ export function DashboardConfig() {
});
const handleSave = () => {
// TODO: Implement API call to save dashboard configuration
toast.success('Dashboard layout saved successfully');
};

View File

@ -28,7 +28,7 @@ export function NotificationConfig() {
});
const handleSave = () => {
// TODO: Implement API call to save notification configuration
toast.success('Notification configuration saved successfully');
};

View File

@ -23,7 +23,7 @@ export function SharingConfig() {
});
const handleSave = () => {
// TODO: Implement API call to save sharing configuration
toast.success('Sharing policy saved successfully');
};

View File

@ -2,18 +2,18 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Plus,
Search,
Users,
Shield,
import {
Plus,
Search,
Users,
Shield,
Loader2,
CheckCircle,
AlertCircle,
@ -75,7 +75,7 @@ export function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
// Pagination and filtering
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
const [currentPage, setCurrentPage] = useState(1);
@ -135,14 +135,14 @@ export function UserManagement() {
// We'll search with a broader filter to find the user
const response = await userApi.getUsersByRole('ALL', 1, 1000);
const allUsers = response.data?.data?.users || [];
const foundUser = allUsers.find((u: any) =>
const foundUser = allUsers.find((u: any) =>
u.email?.toLowerCase() === email.toLowerCase()
);
if (foundUser && foundUser.role) {
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
}
return null; // User not found in system, no role assigned
} catch (error) {
console.error('Failed to fetch user role:', error);
@ -156,7 +156,7 @@ export function UserManagement() {
setSearchQuery(user.email);
setSearchResults([]);
setFetchingRole(true);
try {
// Fetch and set the user's current role if they have one
const currentRole = await fetchUserRole(user.email);
@ -186,7 +186,7 @@ export function UserManagement() {
try {
await userApi.assignRole(selectedUser.email, selectedRole);
setMessage({
type: 'success',
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
@ -200,7 +200,7 @@ export function UserManagement() {
// Refresh the users list
await fetchUsers();
await fetchRoleStatistics();
toast.success(`Role assigned successfully`);
} catch (error: any) {
console.error('Role assignment failed:', error);
@ -220,7 +220,7 @@ export function UserManagement() {
setLoadingUsers(true);
try {
const response = await userApi.getUsersByRole(roleFilter, page, limit);
const usersData = response.data?.data?.users || [];
const paginationData = response.data?.data?.pagination;
const summaryData = response.data?.data?.summary;
@ -234,13 +234,13 @@ export function UserManagement() {
designation: u.designation,
isActive: u.isActive !== false // Default to true if not specified
})));
if (paginationData) {
setCurrentPage(paginationData.currentPage);
setTotalPages(paginationData.totalPages);
setTotalUsers(paginationData.totalUsers);
}
// Update summary stats if available
if (summaryData) {
setRoleStats(prev => ({
@ -264,13 +264,13 @@ export function UserManagement() {
try {
const response = await userApi.getRoleStatistics();
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
const stats = {
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
};
setRoleStats(prev => ({
...prev,
...stats,
@ -317,8 +317,8 @@ export function UserManagement() {
const handleToggleUserStatus = async (userId: string) => {
const user = users.find(u => u.userId === userId);
if (!user) return;
// TODO: Implement backend API for toggling user status
toast.info('User status toggle functionality coming soon');
};
@ -326,13 +326,12 @@ export function UserManagement() {
const handleDeleteUser = async (userId: string) => {
const user = users.find(u => u.userId === userId);
if (!user) return;
if (user.role === 'ADMIN') {
toast.error('Cannot delete admin user');
return;
}
// TODO: Implement backend API for deleting users
toast.info('User deletion functionality coming soon');
};
@ -515,11 +514,10 @@ export function UserManagement() {
{/* Message */}
{message && (
<div className={`border-2 rounded-lg p-4 ${
message.type === 'success'
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<div className={`border-2 rounded-lg p-4 ${message.type === 'success'
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<div className="flex items-start gap-3">
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
@ -602,7 +600,7 @@ export function UserManagement() {
</div>
<p className="font-medium text-gray-700">No users found</p>
<p className="text-sm text-gray-500 mt-1">
{roleFilter === 'ELEVATED'
{roleFilter === 'ELEVATED'
? 'Assign ADMIN or MANAGEMENT roles to see users here'
: 'No users match the selected filter'
}
@ -664,11 +662,10 @@ export function UserManagement() {
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${
currentPage === pageNum
? 'bg-re-green hover:bg-re-green/90'
: ''
}`}
className={`w-9 h-9 p-0 ${currentPage === pageNum
? 'bg-re-green hover:bg-re-green/90'
: ''
}`}
>
{pageNum}
</Button>

View File

@ -70,7 +70,7 @@ export function ClaimApproverSelectionStep({
onPolicyViolation,
}: ClaimApproverSelectionStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
// State for add approver modal
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
const [addApproverEmail, setAddApproverEmail] = useState('');
@ -96,7 +96,7 @@ export function ClaimApproverSelectionStep({
// 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.name}`);
}
@ -120,20 +120,20 @@ export function ClaimApproverSelectionStep({
// Initialize approvers array for all 8 steps
useEffect(() => {
const currentApprovers = formData.approvers || [];
// If we already have approvers (including additional ones), don't reinitialize
// This prevents creating duplicates when approvers have been shifted
if (currentApprovers.length > 0) {
// Just ensure all fixed steps have their approvers, but don't recreate shifted ones
const newApprovers: ClaimApprover[] = [];
const additionalApprovers = currentApprovers.filter((a: ClaimApprover) => a.isAdditional);
CLAIM_STEPS.forEach((step) => {
// Find existing approver by originalStepLevel (handles shifted levels)
const existing = currentApprovers.find((a: ClaimApprover) =>
const existing = currentApprovers.find((a: ClaimApprover) =>
a.originalStepLevel === step.level || (!a.originalStepLevel && !a.isAdditional && a.level === step.level)
);
if (existing) {
// Use existing approver (preserves shifted level)
newApprovers.push(existing);
@ -182,19 +182,19 @@ export function ClaimApproverSelectionStep({
}
}
});
// Add back all additional approvers
additionalApprovers.forEach((addApprover: ClaimApprover) => {
newApprovers.push(addApprover);
});
// Sort by level
newApprovers.sort((a, b) => a.level - b.level);
// Only update if there are actual changes (to avoid infinite loops)
const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !==
JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel })));
const hasChanges = JSON.stringify(currentApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel }))) !==
JSON.stringify(newApprovers.map(a => ({ level: a.level, originalStepLevel: a.originalStepLevel })));
if (hasChanges) {
updateFormData('approvers', newApprovers);
}
@ -246,10 +246,10 @@ export function ClaimApproverSelectionStep({
const handleApproverEmailChange = (level: number, value: string) => {
const approvers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility
const index = approvers.findIndex((a: ClaimApprover) =>
const index = approvers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
);
if (index === -1) {
// Create new approver entry
const step = CLAIM_STEPS.find(s => s.level === level);
@ -304,8 +304,8 @@ export function ClaimApproverSelectionStep({
// Check for duplicates across other steps
const approvers = formData.approvers || [];
const isDuplicate = approvers.some(
(a: ClaimApprover) =>
a.level !== level &&
(a: ClaimApprover) =>
a.level !== level &&
(a.userId === selectedUser.userId || a.email?.toLowerCase() === selectedUser.email?.toLowerCase())
);
@ -343,10 +343,10 @@ export function ClaimApproverSelectionStep({
// Update approver in array
const updatedApprovers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) =>
const approverIndex = updatedApprovers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
);
if (approverIndex === -1) {
const step = CLAIM_STEPS.find(s => s.level === level);
updatedApprovers.push({
@ -391,10 +391,10 @@ export function ClaimApproverSelectionStep({
const handleTatChange = (level: number, tat: number | string) => {
const approvers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility
const index = approvers.findIndex((a: ClaimApprover) =>
const index = approvers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
);
if (index !== -1) {
const existingApprover = approvers[index];
if (existingApprover) {
@ -410,10 +410,10 @@ export function ClaimApproverSelectionStep({
const handleTatTypeChange = (level: number, tatType: 'hours' | 'days') => {
const approvers = [...(formData.approvers || [])];
// Find by originalStepLevel first, then fallback to level for backwards compatibility
const index = approvers.findIndex((a: ClaimApprover) =>
const index = approvers.findIndex((a: ClaimApprover) =>
a.originalStepLevel === level || (!a.originalStepLevel && a.level === level && !a.isAdditional)
);
if (index !== -1) {
const existingApprover = approvers[index];
if (existingApprover) {
@ -430,12 +430,12 @@ export function ClaimApproverSelectionStep({
// Handle adding additional approver between steps
const handleAddApproverEmailChange = (value: string) => {
setAddApproverEmail(value);
// Clear selectedUser when manually editing
if (selectedAddApproverUser && selectedAddApproverUser.email.toLowerCase() !== value.toLowerCase()) {
setSelectedAddApproverUser(null);
}
// Clear existing timer
if (addApproverSearchTimer.current) {
clearTimeout(addApproverSearchTimer.current);
@ -484,7 +484,7 @@ export function ClaimApproverSelectionStep({
secondEmail: user.secondEmail,
location: user.location
});
setAddApproverEmail(user.email);
setSelectedAddApproverUser(user);
setAddApproverSearchResults([]);
@ -497,7 +497,7 @@ export function ClaimApproverSelectionStep({
const handleConfirmAddApprover = async () => {
const emailToAdd = addApproverEmail.trim().toLowerCase();
if (!emailToAdd) {
toast.error('Please enter an email address');
return;
@ -540,7 +540,7 @@ export function ClaimApproverSelectionStep({
// Check for duplicates
const approvers = formData.approvers || [];
const isDuplicate = approvers.some(
(a: ClaimApprover) =>
(a: ClaimApprover) =>
(a.userId && selectedAddApproverUser?.userId && a.userId === selectedAddApproverUser.userId) ||
a.email?.toLowerCase() === emailToAdd
);
@ -552,15 +552,15 @@ export function ClaimApproverSelectionStep({
// Find the approver for the selected step by its originalStepLevel
// This handles cases where steps have been shifted due to previous additional approvers
const approverAfter = approvers.find((a: ClaimApprover) =>
a.originalStepLevel === addApproverInsertAfter ||
const approverAfter = approvers.find((a: ClaimApprover) =>
a.originalStepLevel === addApproverInsertAfter ||
(!a.originalStepLevel && !a.isAdditional && a.level === addApproverInsertAfter)
);
// Get the current level of the approver we're inserting after
// If the step has been shifted, use its current level; otherwise use the original level
const currentLevelAfter = approverAfter ? approverAfter.level : addApproverInsertAfter;
// Calculate insert level based on current shifted level
const insertLevel = currentLevelAfter + 1;
@ -570,7 +570,7 @@ export function ClaimApproverSelectionStep({
// After shifting, we'll have the same number of unique levels + 1 (the new approver)
const currentUniqueLevels = new Set(approvers.map((a: ClaimApprover) => a.level)).size;
const newTotalLevels = currentUniqueLevels + 1;
if (newTotalLevels > maxApprovalLevels) {
const violations = [{
type: 'max_approval_levels',
@ -578,7 +578,7 @@ export function ClaimApproverSelectionStep({
currentValue: newTotalLevels,
maxValue: maxApprovalLevels
}];
if (onPolicyViolation) {
onPolicyViolation(violations);
} else {
@ -593,12 +593,12 @@ export function ClaimApproverSelectionStep({
try {
const response = await searchUsers(emailToAdd, 1);
const searchOktaResults = response.data?.data || [];
if (searchOktaResults.length === 0) {
toast.error('User not found in organization directory. Please use @ to search for users.');
return;
}
const foundUser = searchOktaResults[0];
await ensureUserExists({
userId: foundUser.userId,
@ -617,7 +617,7 @@ export function ClaimApproverSelectionStep({
secondEmail: foundUser.secondEmail,
location: foundUser.location
});
// Use found user - insert at integer level and shift subsequent approvers
// insertLevel is already calculated above based on current shifted level
const newApprover: ClaimApprover = {
@ -631,7 +631,7 @@ export function ClaimApproverSelectionStep({
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
stepName: `Additional Approver - ${foundUser.displayName || foundUser.email}`,
};
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
const updatedApprovers = approvers.map((a: ClaimApprover) => {
if (a.level >= insertLevel) {
@ -639,13 +639,13 @@ export function ClaimApproverSelectionStep({
}
return a;
});
// Insert the new approver
updatedApprovers.push(newApprover);
// Sort by level to maintain order
updatedApprovers.sort((a, b) => a.level - b.level);
updateFormData('approvers', updatedApprovers);
toast.success(`Additional approver added and subsequent steps shifted`);
} catch (error) {
@ -667,7 +667,7 @@ export function ClaimApproverSelectionStep({
insertAfterLevel: addApproverInsertAfter, // Store original step level for reference
stepName: `Additional Approver - ${selectedAddApproverUser.displayName || selectedAddApproverUser.email}`,
};
// Shift all approvers with level >= insertLevel up by 1 (both fixed and additional)
const updatedApprovers = approvers.map((a: ClaimApprover) => {
if (a.level >= insertLevel) {
@ -675,13 +675,13 @@ export function ClaimApproverSelectionStep({
}
return a;
});
// Insert the new approver
updatedApprovers.push(newApprover);
// Sort by level to maintain order
updatedApprovers.sort((a, b) => a.level - b.level);
updateFormData('approvers', updatedApprovers);
toast.success(`Additional approver added and subsequent steps shifted`);
}
@ -699,12 +699,12 @@ export function ClaimApproverSelectionStep({
const handleRemoveAdditionalApprover = (level: number) => {
const approvers = [...(formData.approvers || [])];
const approverToRemove = approvers.find((a: ClaimApprover) => a.level === level);
if (!approverToRemove) return;
// Remove the additional approver
const filtered = approvers.filter((a: ClaimApprover) => a.level !== level);
// Shift all approvers with level > removed level down by 1
const updatedApprovers = filtered.map((a: ClaimApprover) => {
if (a.level > level && !a.isAdditional) {
@ -712,10 +712,10 @@ export function ClaimApproverSelectionStep({
}
return a;
});
// Sort by level to maintain order
updatedApprovers.sort((a, b) => a.level - b.level);
updateFormData('approvers', updatedApprovers);
toast.success('Additional approver removed and subsequent steps shifted back');
};
@ -829,15 +829,15 @@ export function ClaimApproverSelectionStep({
{/* Dynamic Approver Cards - Filter out system steps (auto-processed) */}
{(() => {
// Count additional approvers before first step
const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) =>
const additionalBeforeFirst = sortedApprovers.filter((a: ClaimApprover) =>
a.isAdditional && a.insertAfterLevel === 0
);
let displayIndex = additionalBeforeFirst.length; // Start index after additional approvers before first step
return CLAIM_STEPS.filter((step) => !step.isAuto).map((step, index, filteredSteps) => {
// Find approver by originalStepLevel first, then fallback to level
const approver = approvers.find((a: ClaimApprover) =>
const approver = approvers.find((a: ClaimApprover) =>
a.originalStepLevel === step.level || (!a.originalStepLevel && a.level === step.level && !a.isAdditional)
) || {
email: '',
@ -856,17 +856,17 @@ export function ClaimApproverSelectionStep({
// Additional approvers inserted after this step will have insertAfterLevel === step.level
// and their level will be step.level + 1 (or higher if multiple are added)
const additionalApproversAfter = sortedApprovers.filter(
(a: ClaimApprover) =>
a.isAdditional &&
(a: ClaimApprover) =>
a.isAdditional &&
a.insertAfterLevel === step.level
).sort((a, b) => a.level - b.level);
// Calculate current step's display number
const currentStepDisplayNumber = displayIndex + 1;
// Increment display index for this step
displayIndex++;
// Increment display index for each additional approver after this step
displayIndex += additionalApproversAfter.length;
@ -875,238 +875,259 @@ export function ClaimApproverSelectionStep({
<div className="flex justify-center">
<div className="w-px h-3 bg-gray-300"></div>
</div>
{/* Render additional approvers before this step if any */}
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
return (
<div key={`additional-${addApprover.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 border-purple-200 bg-purple-50">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
<span className="text-white font-semibold text-sm">{addDisplayNumber}</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">
Additional Approver
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-3 h-3" />
</Button>
</div>
<p className="text-xs text-gray-600 mb-2">
{addApprover.name || addApprover.email}
</p>
<div className="text-xs text-gray-500">
<div>Email: {addApprover.email}</div>
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
</div>
{/* Render additional approvers before this step if any */}
{index === 0 && additionalBeforeFirst.map((addApprover: ClaimApprover, addIndex: number) => {
const addDisplayNumber = addIndex + 1; // Number from 1 for first additional approvers
return (
<div key={`additional-${addApprover.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 border-purple-200 bg-purple-50">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
<span className="text-white font-semibold text-sm">{addDisplayNumber}</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">
Additional Approver
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-3 h-3" />
</Button>
</div>
<p className="text-xs text-gray-600 mb-2">
{addApprover.name || addApprover.email}
</p>
<div className="text-xs text-gray-500">
<div>Email: {addApprover.email}</div>
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
<div className={`p-3 rounded-lg border-2 transition-all ${
approver.email && approver.userId
? 'border-green-200 bg-green-50'
);
})}
<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'
? 'border-blue-200 bg-blue-50'
: 'border-gray-200 bg-gray-50'
}`}>
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</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 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">{currentStepDisplayNumber}</span>
</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 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 && (() => {
const isVerified = !!(approver.email && approver.userId);
const isEmpty = !approver.email && !isPreFilled;
return (
<div className="space-y-2">
<div>
<div className="flex items-center justify-between mb-1">
<Label htmlFor={`approver-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
}`}>
Approver Email {!isPreFilled && '*'}
{isEmpty && <span className="ml-2 text-[10px] font-semibold italic text-blue-600">(Required)</span>}
</Label>
{isVerified && (
<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 : "@username or email..."}
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 transition-all mt-1 w-full text-sm ${isPreFilled
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
: isVerified
? 'bg-green-50/50 border-green-600 focus:border-green-700 ring-offset-green-50 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
}`}
/>
{/* 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-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
}`}>
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 transition-all flex-1 text-sm ${isPreFilled
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
: isVerified
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
}`}
/>
<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 transition-all text-sm ${isPreFilled
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed'
: isVerified
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 text-gray-900 font-medium'
: 'bg-white border-blue-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</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>
</div>
{/* Render additional approvers after this step */}
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
// Additional approvers come after the current step, so they should be numbered after it
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
return (
<div key={`additional-${addApprover.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 border-purple-200 bg-purple-50">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
<span className="text-white font-semibold text-sm">{addDisplayNumber}</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">
{addApprover.stepName || 'Additional Approver'}
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
{addApprover.email && addApprover.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>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-3 h-3" />
</Button>
</div>
<p className="text-xs text-gray-600 mb-2">
{addApprover.name || addApprover.email || 'No approver assigned'}
</p>
{addApprover.email && (
<div className="text-xs text-gray-500 space-y-1">
<div>Email: {addApprover.email}</div>
{addApprover.tat && (
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
)}
</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>
{/* Render additional approvers after this step */}
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
// Additional approvers come after the current step, so they should be numbered after it
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
return (
<div key={`additional-${addApprover.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 border-purple-200 bg-purple-50">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 bg-purple-600">
<span className="text-white font-semibold text-sm">{addDisplayNumber}</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">
{addApprover.stepName || 'Additional Approver'}
</span>
<Badge variant="outline" className="text-xs bg-purple-50 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
{addApprover.email && addApprover.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>
)}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
>
<X className="w-3 h-3" />
</Button>
</div>
<p className="text-xs text-gray-600 mb-2">
{addApprover.name || addApprover.email || 'No approver assigned'}
</p>
{addApprover.email && (
<div className="text-xs text-gray-500 space-y-1">
<div>Email: {addApprover.email}</div>
{addApprover.tat && (
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
);
});
})()}
</CardContent>
@ -1125,17 +1146,17 @@ export function ClaimApproverSelectionStep({
{sortedApprovers.map((approver: ClaimApprover) => {
// Skip system/auto steps
// Find step by originalStepLevel first, then fallback to level
const step = approver.originalStepLevel
const step = approver.originalStepLevel
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
: CLAIM_STEPS.find(s => s.level === approver.level && !approver.isAdditional);
if (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;
// Handle additional approvers
if (approver.isAdditional) {
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
@ -1148,7 +1169,7 @@ export function ClaimApproverSelectionStep({
</div>
);
}
return (
<div key={approver.level} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm font-medium">{step?.name || 'Unknown'}</span>
@ -1173,13 +1194,13 @@ export function ClaimApproverSelectionStep({
Add an additional approver between workflow steps. The approver will be inserted after the selected step. Additional approvers cannot be added after "Requestor Claim Approval".
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Insert After Level Selection */}
<div className="space-y-2">
<Label className="text-sm font-medium">Insert After Step *</Label>
<Select
value={addApproverInsertAfter.toString()}
<Select
value={addApproverInsertAfter.toString()}
onValueChange={(value) => setAddApproverInsertAfter(Number(value))}
>
<SelectTrigger className="h-11 border-gray-300">
@ -1211,7 +1232,7 @@ export function ClaimApproverSelectionStep({
<p className="text-xs text-amber-600 font-medium">
Additional approvers cannot be added after "Requestor Claim Approval" as it is considered final.
</p>
{/* Max Approval Levels Note */}
{maxApprovalLevels && (
<p className="text-xs text-gray-600 mt-2">
@ -1290,7 +1311,7 @@ export function ClaimApproverSelectionStep({
className="pl-10 h-11 border-gray-300"
autoFocus
/>
{/* Search Results Dropdown */}
{(isSearchingApprover || addApproverSearchResults.length > 0) && (
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg max-h-60 overflow-auto">

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
/**
* ProcessDetailsCard Component
* Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns
* Displays process-related details: IO Number, E-Invoice, Claim Amount, and Budget Breakdowns
* Visibility controlled by user role
*/
@ -172,21 +172,18 @@ export function ProcessDetailsCard({
</div>
)}
{/* DMS Details */}
{/* E-Invoice Details */}
{visibility.showDMSDetails && dmsDetails && (
<div className="bg-white rounded-lg p-3 border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
DMS & E-Invoice Details
E-Invoice Details
</Label>
</div>
<div className="grid grid-cols-2 gap-3 mb-2">
<div>
<p className="text-[10px] text-gray-500 uppercase">DMS Number</p>
<p className="font-bold text-sm text-gray-900">{dmsDetails.dmsNumber || 'N/A'}</p>
</div>
{dmsDetails.ackNo && (
<div>
<p className="text-[10px] text-gray-500 uppercase">Ack No</p>

View File

@ -22,6 +22,7 @@ interface ProposalCostItem {
interface ProposalDetails {
costBreakup: ProposalCostItem[];
estimatedBudgetTotal?: number | null;
totalEstimatedBudget?: number | null;
timelineForClosure?: string | null;
dealerComments?: string | null;
submittedOn?: string | null;
@ -35,8 +36,9 @@ interface ProposalDetailsCardProps {
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
// Calculate estimated total from costBreakup if not provided
const calculateEstimatedTotal = () => {
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
return proposalDetails.estimatedBudgetTotal;
const total = proposalDetails.totalEstimatedBudget ?? proposalDetails.estimatedBudgetTotal;
if (total !== undefined && total !== null) {
return total;
}
// Calculate sum from costBreakup items

View File

@ -1,12 +1,16 @@
.dms-push-modal {
.settlement-push-modal {
width: 90vw !important;
max-width: 90vw !important;
max-width: 1000px !important;
min-width: 320px !important;
max-height: 95vh !important;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dms-push-modal {
.settlement-push-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
@ -15,25 +19,48 @@
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dms-push-modal {
.settlement-push-modal {
width: 90vw !important;
max-width: 90vw !important;
max-width: 900px !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
}
/* Scrollable content area */
.settlement-push-modal .flex-1 {
overflow-y: auto;
padding-right: 4px;
}
/* Extra large screens */
@media (min-width: 1536px) {
.dms-push-modal {
width: 90vw !important;
max-width: 1000px !important;
}
/* Custom scrollbar for the modal content */
.settlement-push-modal .flex-1::-webkit-scrollbar {
width: 6px;
}
.settlement-push-modal .flex-1::-webkit-scrollbar-track {
background: transparent;
}
.settlement-push-modal .flex-1::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
.settlement-push-modal .flex-1::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.file-preview-dialog {
width: 95vw !important;
max-width: 1200px !important;
max-height: 95vh !important;
padding: 0 !important;
display: flex;
flex-direction: column;
}
.file-preview-content {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}

View File

@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
// Dealer Claim Components (import from index to get properly aliased exports)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
@ -153,7 +153,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const showIOTab = isInitiator;
@ -177,7 +177,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
@ -220,7 +220,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
// Closure check completed
const {
conclusionRemark,
@ -335,7 +335,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try {
setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
try {
@ -376,9 +376,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -427,15 +427,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message}
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -460,15 +460,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
The dealer claim request you're looking for doesn't exist or may have been deleted.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -598,8 +598,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab
summary={summaryDetails}
<SummaryTab
summary={summaryDetails}
loading={loadingSummary}
onShare={handleShareSummary}
isInitiator={isInitiator}
@ -673,7 +673,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
request={request}
isInitiator={isInitiator}
isSpectator={isSpectator}
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
currentApprovalLevel={currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)}

View File

@ -175,14 +175,14 @@ export function mapToClaimManagementRequest(
// Get closed expenses breakdown from new completionExpenses table
const closedExpensesBreakdown = Array.isArray(completionExpenses) && completionExpenses.length > 0
? completionExpenses.map((exp: any) => ({
description: exp.description || exp.itemDescription || '',
description: exp.description || exp.itemDescription || exp.item_description || '',
amount: Number(exp.amount) || 0,
gstRate: exp.gstRate,
gstAmt: exp.gstAmt,
cgstAmt: exp.cgstAmt,
sgstAmt: exp.sgstAmt,
igstAmt: exp.igstAmt,
totalAmt: exp.totalAmt
gstRate: exp.gstRate ?? exp.gst_rate,
gstAmt: exp.gstAmt ?? exp.gst_amt,
cgstAmt: exp.cgstAmt ?? exp.cgst_amt,
sgstAmt: exp.sgstAmt ?? exp.sgst_amt,
igstAmt: exp.igstAmt ?? exp.igst_amt,
totalAmt: exp.totalAmt ?? exp.total_amt
}))
: (completionDetails?.closedExpenses ||
completionDetails?.closed_expenses ||
@ -232,14 +232,14 @@ export function mapToClaimManagementRequest(
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
costBreakup: Array.isArray(proposalDetails.costBreakup || proposalDetails.cost_breakup)
? (proposalDetails.costBreakup || proposalDetails.cost_breakup).map((item: any) => ({
description: item.description || '',
description: item.description || item.itemDescription || item.item_description || '',
amount: Number(item.amount) || 0,
gstRate: item.gstRate,
gstAmt: item.gstAmt,
cgstAmt: item.cgstAmt,
sgstAmt: item.sgstAmt,
igstAmt: item.igstAmt,
totalAmt: item.totalAmt
gstRate: item.gstRate ?? item.gst_rate,
gstAmt: item.gstAmt ?? item.gst_amt,
cgstAmt: item.cgstAmt ?? item.cgst_amt,
sgstAmt: item.sgstAmt ?? item.sgst_amt,
igstAmt: item.igstAmt ?? item.igst_amt,
totalAmt: item.totalAmt ?? item.total_amt
}))
: [],
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,

View File

@ -16,7 +16,7 @@ let configLoaded = false;
// Lazy initialization of configuration
async function ensureConfigLoaded() {
if (configLoaded) return;
try {
const config = await configService.getConfig();
WORK_START_HOUR = config.workingHours.START_HOUR;
@ -30,7 +30,7 @@ async function ensureConfigLoaded() {
}
// Initialize config on first import (non-blocking)
ensureConfigLoaded().catch(() => {});
ensureConfigLoaded().catch(() => { });
/**
* Check if current time is within working hours
@ -40,7 +40,7 @@ ensureConfigLoaded().catch(() => {});
export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean {
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
const hour = date.getHours();
// For standard priority: exclude weekends
// For express priority: include weekends (calendar days)
if (priority === 'standard') {
@ -48,14 +48,13 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
return false;
}
}
// Working hours check (applies to both priorities)
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
return false;
}
// TODO: Add holiday check if holiday API is available
return true;
}
@ -66,12 +65,12 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
*/
export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date {
const result = new Date(date);
// If already in working time, return as is
if (isWorkingTime(result, priority)) {
return result;
}
// For standard priority: skip weekends
if (priority === 'standard') {
const day = result.getDay();
@ -86,13 +85,13 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
return result;
}
}
// If before work hours, move to work start
if (result.getHours() < WORK_START_HOUR) {
result.setHours(WORK_START_HOUR, 0, 0, 0);
return result;
}
// If after work hours, move to next day work start
if (result.getHours() >= WORK_END_HOUR) {
result.setDate(result.getDate() + 1);
@ -100,7 +99,7 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
// Check if next day is weekend (only for standard priority)
return getNextWorkingTime(result, priority);
}
return result;
}
@ -114,19 +113,19 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
let current = new Date(startDate);
const end = new Date(endDate);
let elapsedMinutes = 0;
// Move minute by minute and count only working minutes
while (current < end) {
if (isWorkingTime(current, priority)) {
elapsedMinutes++;
}
current.setMinutes(current.getMinutes() + 1);
// Safety: stop if calculating more than 1 year
const hoursSoFar = elapsedMinutes / 60;
if (hoursSoFar > 8760) break;
}
// Convert minutes to hours (with decimal precision)
return elapsedMinutes / 60;
}
@ -140,12 +139,12 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number {
const deadlineTime = new Date(deadline).getTime();
const currentTime = new Date(fromDate).getTime();
// If deadline has passed
if (deadlineTime <= currentTime) {
return 0;
}
// Calculate remaining working hours
return calculateElapsedWorkingHours(fromDate, deadline, priority);
}
@ -160,9 +159,9 @@ export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date =
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number {
const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority);
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority);
if (totalHours === 0) return 0;
const progress = (elapsedHours / totalHours) * 100;
return Math.min(Math.max(progress, 0), 100); // Clamp between 0 and 100
}
@ -185,17 +184,17 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
const start = new Date(startDate);
const end = new Date(deadline);
const now = new Date();
const isWorking = isWorkingTime(now, priority);
const elapsedHours = calculateElapsedWorkingHours(start, now, priority);
const totalHours = calculateElapsedWorkingHours(start, end, priority);
const remainingHours = Math.max(0, totalHours - elapsedHours);
const progress = calculateSLAProgress(start, end, now, priority);
let statusText = '';
if (!isWorking) {
statusText = priority === 'express'
? 'SLA tracking paused (outside working hours)'
statusText = priority === 'express'
? 'SLA tracking paused (outside working hours)'
: 'SLA tracking paused (outside working hours/days)';
} else if (remainingHours === 0) {
statusText = 'SLA deadline reached';
@ -208,7 +207,7 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
} else {
statusText = 'On track';
}
return {
isWorkingTime: isWorking,
progress,
@ -231,38 +230,38 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
export function formatHoursMinutes(hours: number | null | undefined): string {
if (hours === null || hours === undefined || hours < 0) return '0 hours';
if (hours === 0) return '0 hours';
const WORKING_HOURS_PER_DAY = 8;
// If less than 1 hour, show minutes only
if (hours < 1) {
const m = Math.round(hours * 60);
return m > 0 ? `${m}m` : '0 hours';
}
// Calculate days and remaining hours (8 hours = 1 day)
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
const minutes = Math.round((hours % 1) * 60);
// If we have days, format with days (matching backend format)
if (days > 0) {
const dayLabel = days === 1 ? 'day' : 'days';
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
const minuteLabel = minutes === 1 ? 'min' : 'm';
if (minutes > 0) {
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
} else {
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
}
}
// No days, just hours and minutes
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
const minuteLabel = minutes === 1 ? 'min' : 'm';
if (minutes > 0) {
return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
} else {
@ -276,13 +275,13 @@ export function formatHoursMinutes(hours: number | null | undefined): string {
export function formatWorkingHours(hours: number): string {
if (hours === 0) return '0h';
if (hours < 0) return '0h';
const totalMinutes = Math.round(hours * 60);
const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day
const remainingMinutes = totalMinutes % (8 * 60);
const remainingHours = Math.floor(remainingMinutes / 60);
const minutes = remainingMinutes % 60;
if (days > 0 && remainingHours > 0 && minutes > 0) {
return `${days}d ${remainingHours}h ${minutes}m`;
} else if (days > 0 && remainingHours > 0) {
@ -306,14 +305,14 @@ export function getTimeUntilNextWorking(priority: string = 'standard'): string {
if (isWorkingTime(new Date(), priority)) {
return 'In working hours';
}
const now = new Date();
const next = getNextWorkingTime(now, priority);
const diff = next.getTime() - now.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `Resumes in ${days}d ${hours % 24}h`;

View File

@ -57,14 +57,14 @@ export const cookieUtils = {
*/
clearAll(): void {
const cookieNames = [ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, ID_TOKEN_KEY, USER_DATA_KEY];
cookieNames.forEach(name => {
// Remove with default path
this.remove(name);
// Remove with root path explicitly
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
// Remove with domain (if applicable)
const hostname = window.location.hostname;
if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
@ -75,82 +75,60 @@ export const cookieUtils = {
},
};
/**
* Token Manager - Handles token storage and retrieval
*
* SECURITY MODES:
* - Production: Tokens stored in httpOnly cookies by backend only
* Frontend does NOT store access/refresh tokens anywhere
* All API requests rely on cookies being sent automatically
*
* - Development: Tokens stored in localStorage for debugging
* Needed because frontend/backend run on different ports
*/
export class TokenManager {
/**
* Store access token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage for Authorization header
*/
static setAccessToken(token: string): void {
// SECURITY: In production, don't store tokens client-side
// Backend sets httpOnly cookies that are sent automatically
if (isProduction()) {
return; // No-op - rely on httpOnly cookies
}
// Development only: Store for debugging and cross-port requests
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
/**
* Get access token
* In production: Returns null (cookies are sent automatically)
* In development: Returns from localStorage
*
*/
static getAccessToken(): string | null {
// SECURITY: In production, return null - cookies are used instead
if (isProduction()) {
return null; // API calls use cookies via withCredentials: true
return null;
}
// Development: Return from localStorage
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
/**
* Store refresh token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage
*/
static setRefreshToken(token: string): void {
// SECURITY: In production, don't store tokens client-side
if (isProduction()) {
return; // No-op - rely on httpOnly cookies
}
// Development only
localStorage.setItem(REFRESH_TOKEN_KEY, token);
}
/**
* Get refresh token
* In production: Returns null (cookies are used)
* In development: Returns from localStorage
*/
static getRefreshToken(): string | null {
// SECURITY: In production, return null - backend reads from cookie
if (isProduction()) {
return null;
}
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
/**
* Store ID token (from Okta) - needed for logout
* Stored in sessionStorage (cleared when tab closes)
*/
static setIdToken(token: string): void {
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
sessionStorage.setItem(ID_TOKEN_KEY, token);
@ -183,18 +161,7 @@ export class TokenManager {
}
}
/**
* Clear all tokens and user data
*
* PRODUCTION MODE:
* - Clears user data from localStorage
* - Clears ID token from sessionStorage
* - Backend logout endpoint clears httpOnly cookies
*
* DEVELOPMENT MODE:
* - Clears all localStorage and sessionStorage
* - Clears client-side cookies
*/
static clearAll(): void {
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
// This flag survives the redirect and prevents auto-authentication
@ -204,7 +171,7 @@ export class TokenManager {
} catch (e) {
console.warn('Could not set logout flags:', e);
}
// Clear user data (stored in both modes)
try {
localStorage.removeItem(USER_DATA_KEY);
@ -212,7 +179,7 @@ export class TokenManager {
} catch (e) {
console.warn('Error clearing user data:', e);
}
// In production, httpOnly cookies are cleared by backend
// Only need to clear user data above
if (isProduction()) {
@ -225,7 +192,7 @@ export class TokenManager {
}
return;
}
// DEVELOPMENT MODE: Clear everything
const authKeys = [
ACCESS_TOKEN_KEY,
@ -246,7 +213,7 @@ export class TokenManager {
'persist:auth',
'redux-persist',
];
authKeys.forEach(key => {
try {
localStorage.removeItem(key);
@ -255,14 +222,14 @@ export class TokenManager {
console.warn(`Error removing ${key}:`, e);
}
});
// Clear ALL localStorage
try {
localStorage.clear();
} catch (e) {
console.error('Error clearing localStorage:', e);
}
// Clear ALL sessionStorage except logout flags
try {
const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
@ -277,7 +244,7 @@ export class TokenManager {
} catch (e) {
console.error('Error clearing sessionStorage:', e);
}
// Clear client-side cookies (development only)
cookieUtils.clearAll();
}
@ -296,11 +263,7 @@ export class TokenManager {
return !!this.getAccessToken();
}
/**
* Check if refresh token exists
* In production: Always returns true if user data exists
* In development: Checks localStorage
*/
static hasRefreshToken(): boolean {
if (isProduction()) {
return !!this.getUserData();
@ -318,7 +281,7 @@ export class TokenManager {
window.location.hostname === ''
);
}
/**
* Check if we're in production mode
*/