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 ( return (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,12 +1,16 @@
.dms-push-modal { .settlement-push-modal {
width: 90vw !important; width: 90vw !important;
max-width: 90vw !important; max-width: 1000px !important;
min-width: 320px !important;
max-height: 95vh !important; max-height: 95vh !important;
overflow: hidden;
display: flex;
flex-direction: column;
} }
/* Mobile responsive */ /* Mobile responsive */
@media (max-width: 640px) { @media (max-width: 640px) {
.dms-push-modal { .settlement-push-modal {
width: 95vw !important; width: 95vw !important;
max-width: 95vw !important; max-width: 95vw !important;
max-height: 95vh !important; max-height: 95vh !important;
@ -15,25 +19,48 @@
/* Tablet and small desktop */ /* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) { @media (min-width: 641px) and (max-width: 1023px) {
.dms-push-modal { .settlement-push-modal {
width: 90vw !important; width: 90vw !important;
max-width: 90vw !important; max-width: 900px !important;
} }
} }
/* Large screens - fixed max-width for better readability */ /* Scrollable content area */
@media (min-width: 1024px) { .settlement-push-modal .flex-1 {
.dms-push-modal { overflow-y: auto;
width: 90vw !important; padding-right: 4px;
max-width: 1000px !important;
}
} }
/* Extra large screens */ /* Custom scrollbar for the modal content */
@media (min-width: 1536px) { .settlement-push-modal .flex-1::-webkit-scrollbar {
.dms-push-modal { width: 6px;
width: 90vw !important;
max-width: 1000px !important;
}
} }
.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 { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal'; import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
// Dealer Claim Components (import from index to get properly aliased exports) // Dealer Claim Components (import from index to get properly aliased exports)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index'; 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) // 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 || '';
// IO tab visibility for dealer claims // IO tab visibility for dealer claims
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO // Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const showIOTab = isInitiator; 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) // State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null); const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel // Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel; const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
@ -220,7 +220,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Check both lowercase and uppercase status values // Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase(); const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator; const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
// Closure check completed // Closure check completed
const { const {
conclusionRemark, conclusionRemark,
@ -335,7 +335,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try { try {
setLoadingSummary(true); setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId); const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) { if (summary?.summaryId) {
setSummaryId(summary.summaryId); setSummaryId(summary.summaryId);
try { try {
@ -376,9 +376,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id; const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number; const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId && if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier && notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return; notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata // Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) { if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -427,15 +427,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message} {accessDenied.message}
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" 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. The dealer claim request you're looking for doesn't exist or may have been deleted.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -598,8 +598,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{isClosed && ( {isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content"> <TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab <SummaryTab
summary={summaryDetails} summary={summaryDetails}
loading={loadingSummary} loading={loadingSummary}
onShare={handleShareSummary} onShare={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}
@ -673,7 +673,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
request={request} request={request}
isInitiator={isInitiator} isInitiator={isInitiator}
isSpectator={isSpectator} isSpectator={isSpectator}
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel} currentApprovalLevel={currentApprovalLevel}
onAddApprover={() => setShowAddApproverModal(true)} onAddApprover={() => setShowAddApproverModal(true)}
onAddSpectator={() => setShowAddSpectatorModal(true)} onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)} onApprove={() => setShowApproveModal(true)}

View File

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

View File

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

View File

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