Compare commits
17 Commits
d285ea88d8
...
ac10c461e4
| Author | SHA1 | Date | |
|---|---|---|---|
| ac10c461e4 | |||
| 6d6b2a3f9c | |||
| e11f13d248 | |||
| b04776a5f8 | |||
| 170f9a1788 | |||
| 32a486d6f4 | |||
| dfe94555ab | |||
| 5dce660f05 | |||
| 5e91b85854 | |||
| d2d75d93f7 | |||
| 3a6cc6894c | |||
| a16346effd | |||
| 2fa52b90e3 | |||
| 80ed407cd8 | |||
| 7ae9133b98 | |||
| 08cda349f3 | |||
| edd1967336 |
27
.env.local.backup
Normal file
27
.env.local.backup
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#Local
|
||||||
|
VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
VITE_BASE_URL=http://localhost:3000
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||||
|
VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
|
||||||
|
VITE_OKTA_DOMAIN=https://royalenfield.okta.com
|
||||||
|
|
||||||
|
#Development
|
||||||
|
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
# VITE_BASE_URL=https://re-workflow-nt-dev.siplsolutions.com
|
||||||
|
# VITE_API_BASE_URL=https://re-workflow-nt-dev.siplsolutions.com/api/v1
|
||||||
|
# VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
|
||||||
|
# VITE_OKTA_DOMAIN=https://royalenfield.okta.com
|
||||||
|
|
||||||
|
#Uat
|
||||||
|
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
# VITE_BASE_URL=https://reflow-uat.royalenfield.com
|
||||||
|
# VITE_API_BASE_URL=https://reflow-uat.royalenfield.com/api/v1/
|
||||||
|
# VITE_OKTA_CLIENT_ID=0oa2jgzvrpdwx2iqd0h8
|
||||||
|
# VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||||
|
|
||||||
|
#Production
|
||||||
|
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
# VITE_BASE_URL=https://reflow.royalenfield.com
|
||||||
|
# VITE_API_BASE_URL=https://reflow.royalenfield.com/api/v1
|
||||||
|
# VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
|
||||||
|
# VITE_OKTA_DOMAIN=https://royalenfield.okta.com
|
||||||
@ -10,7 +10,7 @@
|
|||||||
<meta name="theme-color" content="#2d4a3e" />
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
<!-- Preload critical fonts and icons -->
|
<!-- Preload essential fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
|
|
||||||
|
Sitemap: https://reflow.royalenfield.com/sitemap.xml
|
||||||
9
public/sitemap.xml
Normal file
9
public/sitemap.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://reflow.royalenfield.com</loc>
|
||||||
|
<lastmod>2024-03-20T12:00:00+00:00</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
259
src/App.tsx
259
src/App.tsx
@ -10,7 +10,7 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
|
|||||||
import { WorkNotes } from '@/pages/WorkNotes';
|
import { WorkNotes } from '@/pages/WorkNotes';
|
||||||
import { CreateRequest } from '@/pages/CreateRequest';
|
import { CreateRequest } from '@/pages/CreateRequest';
|
||||||
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
||||||
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
|
// import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
|
||||||
import { MyRequests } from '@/pages/MyRequests';
|
import { MyRequests } from '@/pages/MyRequests';
|
||||||
import { Requests } from '@/pages/Requests/Requests';
|
import { Requests } from '@/pages/Requests/Requests';
|
||||||
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
||||||
@ -18,21 +18,21 @@ import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
|||||||
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
|
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
|
||||||
import { Profile } from '@/pages/Profile';
|
import { Profile } from '@/pages/Profile';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { SecuritySettings } from '@/pages/Settings/SecuritySettings';
|
||||||
import { Notifications } from '@/pages/Notifications';
|
import { Notifications } from '@/pages/Notifications';
|
||||||
import { DetailedReports } from '@/pages/DetailedReports';
|
import { DetailedReports } from '@/pages/DetailedReports';
|
||||||
import { Admin } from '@/pages/Admin';
|
|
||||||
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
|
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
|
||||||
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
|
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
|
||||||
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
|
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
|
||||||
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
|
||||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
import { createClaimRequest } from '@/services/dealerClaimApi';
|
||||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
||||||
import { navigateToRequest } from '@/utils/requestNavigation';
|
import { navigateToRequest } from '@/utils/requestNavigation';
|
||||||
// import { TokenManager } from '@/utils/tokenManager';
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@ -61,8 +61,8 @@ function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: stri
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
// const userData = TokenManager.getUserData();
|
const userData = TokenManager.getUserData();
|
||||||
// // setIsDealer(userData?.jobTitle === 'Dealer');
|
setIsDealer(userData?.jobTitle === 'Dealer');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[App] Error checking dealer status:', error);
|
console.error('[App] Error checking dealer status:', error);
|
||||||
setIsDealer(false);
|
setIsDealer(false);
|
||||||
@ -84,7 +84,8 @@ function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: stri
|
|||||||
|
|
||||||
// Render dealer-specific dashboard if user is a dealer
|
// Render dealer-specific dashboard if user is a dealer
|
||||||
if (isDealer) {
|
if (isDealer) {
|
||||||
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
console.log("isDealer", isDealer)
|
||||||
|
// return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render regular dashboard for all other users
|
// Render regular dashboard for all other users
|
||||||
@ -193,7 +194,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
|
|
||||||
// Regular custom request submission (old flow without API)
|
// Regular custom request submission (old flow without API)
|
||||||
// Generate unique ID for the new custom request
|
// Generate unique ID for the new custom request
|
||||||
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-${String(dynamicRequests.length + 1).padStart(3, '0')}`;
|
||||||
|
|
||||||
// Create full custom request object
|
// Create full custom request object
|
||||||
const newCustomRequest = {
|
const newCustomRequest = {
|
||||||
@ -412,201 +413,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 (
|
||||||
@ -658,44 +464,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin Routes Group with Shared Layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Outlet />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/create-template" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/templates" element={<AdminTemplatesList />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Create Request from Admin Template (Dedicated Flow) */}
|
|
||||||
<Route
|
|
||||||
path="/create-admin-request/:templateId"
|
|
||||||
element={
|
|
||||||
<CreateAdminRequest />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Open Requests */}
|
{/* Open Requests */}
|
||||||
<Route
|
<Route
|
||||||
@ -842,6 +611,16 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Security Settings */}
|
||||||
|
<Route
|
||||||
|
path="/settings/security"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<SecuritySettings />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Route
|
<Route
|
||||||
path="/notifications"
|
path="/notifications"
|
||||||
|
|||||||
@ -12,7 +12,14 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
@ -21,12 +28,12 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllActivityTypes,
|
getAllActivityTypes,
|
||||||
createActivityType,
|
createActivityType,
|
||||||
updateActivityType,
|
updateActivityType,
|
||||||
deleteActivityType,
|
deleteActivityType,
|
||||||
ActivityType
|
ActivityType
|
||||||
} from '@/services/adminApi';
|
} from '@/services/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -88,17 +95,18 @@ export function ActivityTypeManager() {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (!formData.title.trim()) {
|
if (!formData.title.trim() || !formData.taxationType.trim() || !formData.sapRefNo.trim()) {
|
||||||
setError('Activity type title is required');
|
setError('Title, Taxation Type, and Claim Document Type (SAP Ref) are required');
|
||||||
|
toast.error('Please fill in all mandatory fields');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload: Partial<ActivityType> = {
|
const payload: Partial<ActivityType> = {
|
||||||
title: formData.title.trim(),
|
title: formData.title.trim(),
|
||||||
itemCode: formData.itemCode.trim() || null,
|
itemCode: formData.itemCode.trim() || null,
|
||||||
taxationType: formData.taxationType.trim() || null,
|
taxationType: formData.taxationType.trim(),
|
||||||
sapRefNo: formData.sapRefNo.trim() || null
|
sapRefNo: formData.sapRefNo.trim()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingActivityType) {
|
if (editingActivityType) {
|
||||||
@ -165,9 +173,9 @@ export function ActivityTypeManager() {
|
|||||||
<AlertCircle className="w-4 h-4 text-white shrink-0" />
|
<AlertCircle className="w-4 h-4 text-white shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-red-900">{error}</p>
|
<p className="text-sm font-medium text-red-900">{error}</p>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => setError(null)}
|
onClick={() => setError(null)}
|
||||||
className="ml-auto hover:bg-red-100"
|
className="ml-auto hover:bg-red-100"
|
||||||
>
|
>
|
||||||
@ -191,8 +199,8 @@ export function ActivityTypeManager() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
|
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@ -216,9 +224,9 @@ export function ActivityTypeManager() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-slate-700 font-medium text-lg">No activity types found</p>
|
<p className="text-slate-700 font-medium text-lg">No activity types found</p>
|
||||||
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
|
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleAdd}
|
onClick={handleAdd}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
|
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
@ -245,7 +253,7 @@ export function ActivityTypeManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-4">
|
<CardContent className="space-y-3 pt-4">
|
||||||
{activeActivityTypes.map(activityType => (
|
{activeActivityTypes.map(activityType => (
|
||||||
<div
|
<div
|
||||||
key={activityType.activityTypeId}
|
key={activityType.activityTypeId}
|
||||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
@ -314,7 +322,7 @@ export function ActivityTypeManager() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 pt-4">
|
<CardContent className="space-y-3 pt-4">
|
||||||
{inactiveActivityTypes.map(activityType => (
|
{inactiveActivityTypes.map(activityType => (
|
||||||
<div
|
<div
|
||||||
key={activityType.activityTypeId}
|
key={activityType.activityTypeId}
|
||||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
|
||||||
>
|
>
|
||||||
@ -397,46 +405,51 @@ export function ActivityTypeManager() {
|
|||||||
|
|
||||||
{/* Taxation Type Field */}
|
{/* Taxation Type Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
|
||||||
Taxation Type <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
Taxation Type <span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Select
|
||||||
id="taxationType"
|
|
||||||
placeholder="e.g., GST, VAT, Exempt"
|
|
||||||
value={formData.taxationType}
|
value={formData.taxationType}
|
||||||
onChange={(e) => setFormData({ ...formData, taxationType: e.target.value })}
|
onValueChange={(value) => setFormData({ ...formData, taxationType: value })}
|
||||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
>
|
||||||
/>
|
<SelectTrigger id="taxationType" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm">
|
||||||
<p className="text-xs text-slate-500">Optional taxation type for the activity</p>
|
<SelectValue placeholder="Select Taxation Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-lg">
|
||||||
|
<SelectItem value="GST" className="p-3">GST</SelectItem>
|
||||||
|
<SelectItem value="Non GST" className="p-3">Non GST</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-slate-500">Select whether the activity is GST or Non-GST</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SAP Reference Number Field */}
|
{/* SAP Reference Number Field */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900">
|
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
|
||||||
SAP Reference Number <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
Claim Document Type (SAP Ref) <span className="text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="sapRefNo"
|
id="sapRefNo"
|
||||||
placeholder="e.g., SAP-12345"
|
placeholder="e.g., ZCNS, ZRE"
|
||||||
value={formData.sapRefNo}
|
value={formData.sapRefNo}
|
||||||
onChange={(e) => setFormData({ ...formData, sapRefNo: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, sapRefNo: e.target.value })}
|
||||||
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-slate-500">Optional SAP reference number</p>
|
<p className="text-xs text-slate-500">Required SAP reference number for CSV generation</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
|
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowAddDialog(false)}
|
onClick={() => setShowAddDialog(false)}
|
||||||
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
|
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!formData.title.trim()}
|
disabled={!formData.title.trim() || !formData.taxationType || !formData.sapRefNo.trim()}
|
||||||
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
194
src/components/common/AntivirusScanStatus.tsx
Normal file
194
src/components/common/AntivirusScanStatus.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* AntivirusScanStatus Component
|
||||||
|
* Displays the antivirus scan result badge/status for uploaded files.
|
||||||
|
* Shows ClamAV scan result and XSS content scan result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface ScanResultData {
|
||||||
|
malwareScan?: {
|
||||||
|
scanned: boolean;
|
||||||
|
isInfected: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
virusNames?: string[];
|
||||||
|
scanDuration?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
contentScan?: {
|
||||||
|
scanned: boolean;
|
||||||
|
safe: boolean;
|
||||||
|
scanType: string;
|
||||||
|
severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
threats?: Array<{ description: string; severity: string }>;
|
||||||
|
patternsChecked: number;
|
||||||
|
};
|
||||||
|
scanEventId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AntivirusScanStatusProps {
|
||||||
|
scanResult?: ScanResultData;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function getStatusColor(result?: ScanResultData): string {
|
||||||
|
if (!result) return '#94a3b8'; // gray — no scan data
|
||||||
|
|
||||||
|
// Check malware first
|
||||||
|
if (result.malwareScan?.isInfected) return '#ef4444'; // red
|
||||||
|
if (result.malwareScan?.error) return '#f59e0b'; // amber
|
||||||
|
|
||||||
|
// Then XSS
|
||||||
|
if (result.contentScan && !result.contentScan.safe) {
|
||||||
|
if (result.contentScan.severity === 'CRITICAL') return '#ef4444';
|
||||||
|
if (result.contentScan.severity === 'HIGH') return '#ef4444';
|
||||||
|
if (result.contentScan.severity === 'MEDIUM') return '#f59e0b';
|
||||||
|
return '#f59e0b';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped
|
||||||
|
if (result.malwareScan?.skipped) return '#94a3b8';
|
||||||
|
|
||||||
|
return '#22c55e'; // green — all clear
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(result?: ScanResultData): string {
|
||||||
|
if (!result) return '⏳';
|
||||||
|
if (result.malwareScan?.isInfected) return '🛑';
|
||||||
|
if (result.contentScan && !result.contentScan.safe) return '⚠️';
|
||||||
|
if (result.malwareScan?.skipped) return '⏭️';
|
||||||
|
if (result.malwareScan?.error) return '❌';
|
||||||
|
if (result.malwareScan?.scanned && result.contentScan?.scanned) return '✅';
|
||||||
|
return '⏳';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(result?: ScanResultData): string {
|
||||||
|
if (!result) return 'Pending scan';
|
||||||
|
if (result.malwareScan?.isInfected) return 'Malware detected';
|
||||||
|
if (result.contentScan && !result.contentScan.safe) return 'Content threat detected';
|
||||||
|
if (result.malwareScan?.skipped) return 'Scan skipped';
|
||||||
|
if (result.malwareScan?.error) return 'Scan error';
|
||||||
|
if (result.malwareScan?.scanned && result.contentScan?.scanned) return 'Clean';
|
||||||
|
return 'Scanning…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
const AntivirusScanStatus: React.FC<AntivirusScanStatusProps> = ({
|
||||||
|
scanResult,
|
||||||
|
compact = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const color = getStatusColor(scanResult);
|
||||||
|
const icon = getStatusIcon(scanResult);
|
||||||
|
const label = getStatusLabel(scanResult);
|
||||||
|
|
||||||
|
// Compact mode: just a badge
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: `${color}15`,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '11px' }}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full mode: detailed card
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: `${color}08`,
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontSize: '16px' }}>{icon}</span>
|
||||||
|
<span style={{ fontWeight: 600, color }}>{label}</span>
|
||||||
|
{scanResult?.malwareScan?.scanDuration && (
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '11px', color: '#94a3b8' }}>
|
||||||
|
{scanResult.malwareScan.scanDuration}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
{scanResult && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{/* ClamAV Result */}
|
||||||
|
{scanResult.malwareScan?.scanned && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
|
||||||
|
<span>🦠</span>
|
||||||
|
<span>
|
||||||
|
ClamAV:{' '}
|
||||||
|
{scanResult.malwareScan.isInfected
|
||||||
|
? `Infected — ${scanResult.malwareScan.virusNames?.join(', ')}`
|
||||||
|
: 'Clean'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* XSS Result */}
|
||||||
|
{scanResult.contentScan?.scanned && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
|
||||||
|
<span>🔍</span>
|
||||||
|
<span>
|
||||||
|
Content scan ({scanResult.contentScan.scanType}):{' '}
|
||||||
|
{scanResult.contentScan.safe
|
||||||
|
? `Safe — ${scanResult.contentScan.patternsChecked} patterns checked`
|
||||||
|
: `${scanResult.contentScan.threats?.length || 0} threats found (${scanResult.contentScan.severity})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Threats list */}
|
||||||
|
{scanResult.contentScan?.threats && scanResult.contentScan.threats.length > 0 && (
|
||||||
|
<ul style={{ margin: '4px 0 0 24px', padding: 0, fontSize: '11px', color: '#ef4444' }}>
|
||||||
|
{scanResult.contentScan.threats.slice(0, 5).map((threat, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
{threat.description} ({threat.severity})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{scanResult.contentScan.threats.length > 5 && (
|
||||||
|
<li>…and {scanResult.contentScan.threats.length - 5} more</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scan event ID */}
|
||||||
|
{scanResult.scanEventId && (
|
||||||
|
<div style={{ fontSize: '10px', color: '#94a3b8', marginTop: '4px' }}>
|
||||||
|
Scan ID: {scanResult.scanEventId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AntivirusScanStatus;
|
||||||
@ -72,8 +72,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
const items = [
|
const items = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
||||||
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
||||||
{ id: 'requests', label: 'All Requests', icon: List },
|
{ id: 'requests', label: 'All Requests', icon: List, adminOnly: false },
|
||||||
{ id: 'my-requests', label: 'My Requests', icon: User, adminOnly: false },
|
{ id: 'my-requests', label: 'My Requests', icon: User }
|
||||||
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -4,9 +4,9 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui
|
|||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import {
|
import {
|
||||||
Receipt,
|
Receipt,
|
||||||
Package,
|
Package,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
@ -96,7 +96,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
|
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
|
||||||
>
|
>
|
||||||
{/* Accessibility - Hidden Title and Description */}
|
{/* Accessibility - Hidden Title and Description */}
|
||||||
@ -118,7 +118,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
|
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="text-center mb-12 max-w-3xl"
|
className="text-center mb-12 max-w-3xl"
|
||||||
@ -150,14 +150,13 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
whileHover={isDisabled ? {} : { scale: 1.03 }}
|
whileHover={isDisabled ? {} : { scale: 1.03 }}
|
||||||
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full transition-all duration-300 border-2 ${
|
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
||||||
isDisabled
|
? 'opacity-50 cursor-not-allowed border-gray-200'
|
||||||
? 'opacity-50 cursor-not-allowed border-gray-200'
|
: isSelected
|
||||||
: isSelected
|
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
||||||
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
||||||
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => handleSelect(template.id)}
|
onClick={() => handleSelect(template.id)}
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-4 pb-4">
|
<CardHeader className="space-y-4 pb-4">
|
||||||
@ -206,9 +205,9 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
{template.category}
|
{template.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
@ -244,29 +243,28 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
|
className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="px-8"
|
className="px-8"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
|
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
|
||||||
size="lg"
|
size="lg"
|
||||||
className={`gap-2 px-8 ${
|
className={`gap-2 px-8 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
||||||
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
: 'bg-gray-400 cursor-not-allowed'
|
||||||
: 'bg-gray-400 cursor-not-allowed'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Continue with Template
|
Continue with Template
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
|||||||
297
src/components/settings/ApiTokenManager.tsx
Normal file
297
src/components/settings/ApiTokenManager.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Key, Plus, Trash2, Copy, Check } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import axios from '@/services/authApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiTokenManager() {
|
||||||
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [newTokenExpiry, setNewTokenExpiry] = useState<number | ''>('');
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [tokenToRevoke, setTokenToRevoke] = useState<ApiToken | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTokens = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await axios.get('/api-tokens');
|
||||||
|
setTokens(response.data.data.tokens);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch API tokens:', error);
|
||||||
|
toast.error('Failed to load API tokens');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateToken = async () => {
|
||||||
|
if (!newTokenName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
const payload: any = { name: newTokenName };
|
||||||
|
if (newTokenExpiry) {
|
||||||
|
payload.expiresInDays = Number(newTokenExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post('/api-tokens', payload);
|
||||||
|
setGeneratedToken(response.data.data.token);
|
||||||
|
toast.success('API Token created successfully');
|
||||||
|
fetchTokens(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create token:', error);
|
||||||
|
toast.error('Failed to create API token');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeToken = (token: ApiToken) => {
|
||||||
|
setTokenToRevoke(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRevokeToken = async () => {
|
||||||
|
if (!tokenToRevoke) return;
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api-tokens/${tokenToRevoke.id}`);
|
||||||
|
toast.success('Token revoked successfully');
|
||||||
|
setTokens(tokens.filter(t => t.id !== tokenToRevoke.id));
|
||||||
|
setTokenToRevoke(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to revoke token:', error);
|
||||||
|
toast.error('Failed to revoke token');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (generatedToken) {
|
||||||
|
navigator.clipboard.writeText(generatedToken);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
toast.success('Token copied to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCreateModal = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setNewTokenName('');
|
||||||
|
setNewTokenExpiry('');
|
||||||
|
setGeneratedToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">API Tokens</h3>
|
||||||
|
<p className="text-sm text-gray-500">Manage personal access tokens for external integrations</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)} size="sm" className="bg-re-green hover:bg-re-green/90 text-white">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-4 text-gray-500">Loading tokens...</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||||
|
<Key className="w-10 h-10 text-gray-300 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 font-medium">No API tokens found</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">Generate a token to access the API programmatically</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Prefix</TableHead>
|
||||||
|
<TableHead>Last Used</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<TableRow key={token.id}>
|
||||||
|
<TableCell className="font-medium">{token.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs bg-slate-100 rounded px-2 py-1 w-fit">{token.prefix}...</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{token.lastUsedAt ? format(new Date(token.lastUsedAt), 'MMM d, yyyy') : 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{token.expiresAt ? format(new Date(token.expiresAt), 'MMM d, yyyy') : 'No Expiry'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevokeToken(token)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Token Modal */}
|
||||||
|
<Dialog open={showCreateModal} onOpenChange={(open) => !open && resetCreateModal()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generate API Token</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new token to access the API. Treat this token like a password.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!generatedToken ? (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token-name">Token Name</Label>
|
||||||
|
<Input
|
||||||
|
id="token-name"
|
||||||
|
placeholder="e.g., CI/CD Pipeline, Prometheus"
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={(e) => setNewTokenName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token-expiry">Expiration (Days)</Label>
|
||||||
|
<Input
|
||||||
|
id="token-expiry"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Leave empty for no expiry"
|
||||||
|
value={newTokenExpiry}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setNewTokenExpiry('');
|
||||||
|
} else {
|
||||||
|
const num = parseInt(val);
|
||||||
|
// Prevent negative numbers
|
||||||
|
if (!isNaN(num) && num >= 1) {
|
||||||
|
setNewTokenExpiry(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Alert className="bg-green-50 border-green-200">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertTitle className="text-green-800">Token Generated Successfully</AlertTitle>
|
||||||
|
<AlertDescription className="text-green-700">
|
||||||
|
Please copy your token now. You won't be able to see it again!
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="p-4 bg-slate-900 rounded-md font-mono text-sm text-green-400 break-all pr-10">
|
||||||
|
{generatedToken}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-1 right-1 text-gray-400 hover:text-white hover:bg-slate-800"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{!generatedToken ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={resetCreateModal}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreateToken} disabled={!newTokenName.trim() || isCreating}>
|
||||||
|
{isCreating ? 'Generating...' : 'Generate Token'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={resetCreateModal}>Done</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!tokenToRevoke} onOpenChange={(open) => !open && setTokenToRevoke(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Revoke API Token</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to revoke the token <strong>{tokenToRevoke?.name}</strong>?
|
||||||
|
This action cannot be undone and any applications using this token will lose access immediately.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmRevokeToken} className="bg-red-600 hover:bg-red-700 text-white">
|
||||||
|
Revoke Token
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -45,18 +45,18 @@ export function ApprovalWorkflowStep({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const approverCount = formData.approverCount || 1;
|
const approverCount = formData.approverCount || 1;
|
||||||
const currentApprovers = formData.approvers || [];
|
const currentApprovers = formData.approvers || [];
|
||||||
|
|
||||||
// Ensure we have the correct number of approvers
|
// Ensure we have the correct number of approvers
|
||||||
if (currentApprovers.length < approverCount) {
|
if (currentApprovers.length < approverCount) {
|
||||||
const newApprovers = [...currentApprovers];
|
const newApprovers = [...currentApprovers];
|
||||||
// Fill missing approver slots
|
// Fill missing approver slots
|
||||||
for (let i = currentApprovers.length; i < approverCount; i++) {
|
for (let i = currentApprovers.length; i < approverCount; i++) {
|
||||||
if (!newApprovers[i]) {
|
if (!newApprovers[i]) {
|
||||||
newApprovers[i] = {
|
newApprovers[i] = {
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
level: i + 1,
|
level: i + 1,
|
||||||
tat: '' as any
|
tat: '' as any
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -71,7 +71,7 @@ export function ApprovalWorkflowStep({
|
|||||||
const newApprovers = [...formData.approvers];
|
const newApprovers = [...formData.approvers];
|
||||||
const previousEmail = newApprovers[index]?.email;
|
const previousEmail = newApprovers[index]?.email;
|
||||||
const emailChanged = previousEmail !== value;
|
const emailChanged = previousEmail !== value;
|
||||||
|
|
||||||
newApprovers[index] = {
|
newApprovers[index] = {
|
||||||
...newApprovers[index],
|
...newApprovers[index],
|
||||||
email: value,
|
email: value,
|
||||||
@ -94,8 +94,8 @@ export function ApprovalWorkflowStep({
|
|||||||
try {
|
try {
|
||||||
// Check for duplicates in other approver slots (excluding current index)
|
// Check for duplicates in other approver slots (excluding current index)
|
||||||
const isDuplicateApprover = formData.approvers?.some(
|
const isDuplicateApprover = formData.approvers?.some(
|
||||||
(approver: any, idx: number) =>
|
(approver: any, idx: number) =>
|
||||||
idx !== index &&
|
idx !== index &&
|
||||||
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -196,9 +196,9 @@ export function ApprovalWorkflowStep({
|
|||||||
<div data-testid="approval-workflow-count-field">
|
<div data-testid="approval-workflow-count-field">
|
||||||
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
|
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentCount = formData.approverCount || 1;
|
const currentCount = formData.approverCount || 1;
|
||||||
@ -216,14 +216,14 @@ export function ApprovalWorkflowStep({
|
|||||||
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
|
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
|
||||||
{formData.approverCount || 1}
|
{formData.approverCount || 1}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentCount = formData.approverCount || 1;
|
const currentCount = formData.approverCount || 1;
|
||||||
const newCount = currentCount + 1;
|
const newCount = currentCount + 1;
|
||||||
|
|
||||||
// Validate against system policy
|
// Validate against system policy
|
||||||
if (newCount > systemPolicy.maxApprovalLevels) {
|
if (newCount > systemPolicy.maxApprovalLevels) {
|
||||||
onPolicyViolation([{
|
onPolicyViolation([{
|
||||||
@ -234,7 +234,7 @@ export function ApprovalWorkflowStep({
|
|||||||
}]);
|
}]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateFormData('approverCount', newCount);
|
updateFormData('approverCount', newCount);
|
||||||
}}
|
}}
|
||||||
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
|
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
|
||||||
@ -282,13 +282,13 @@ export function ApprovalWorkflowStep({
|
|||||||
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
|
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
|
||||||
const level = index + 1;
|
const level = index + 1;
|
||||||
const isLast = level === (formData.approverCount || 1);
|
const isLast = level === (formData.approverCount || 1);
|
||||||
|
|
||||||
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
|
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
|
||||||
const approver = formData.approvers[index] || {
|
const approver = formData.approvers[index] || {
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
level: level,
|
level: level,
|
||||||
tat: '' as any
|
tat: '' as any
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -296,18 +296,16 @@ export function ApprovalWorkflowStep({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-px h-6 bg-gray-300"></div>
|
<div className="w-px h-6 bg-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`p-4 rounded-lg border-2 transition-all ${
|
<div className={`p-4 rounded-lg border-2 transition-all ${approver.email
|
||||||
approver.email
|
? 'border-green-200 bg-green-50'
|
||||||
? 'border-green-200 bg-green-50'
|
: 'border-gray-200 bg-gray-50'
|
||||||
: 'border-gray-200 bg-gray-50'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${approver.email
|
||||||
approver.email
|
? 'bg-green-600'
|
||||||
? 'bg-green-600'
|
: 'bg-gray-400'
|
||||||
: 'bg-gray-400'
|
}`}>
|
||||||
}`}>
|
|
||||||
<span className="text-white font-semibold">{level}</span>
|
<span className="text-white font-semibold">{level}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -336,7 +334,7 @@ export function ApprovalWorkflowStep({
|
|||||||
<Input
|
<Input
|
||||||
id={`approver-${level}`}
|
id={`approver-${level}`}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="approver@royalenfield.com"
|
placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`}
|
||||||
value={approver.email || ''}
|
value={approver.email || ''}
|
||||||
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
|
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
|
||||||
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
|
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
|
||||||
|
|||||||
@ -107,8 +107,8 @@ export function TemplateSelectionStep({
|
|||||||
<p>No admin templates available yet.</p>
|
<p>No admin templates available yet.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayTemplates.map((template) => {
|
displayTemplates.map((template, index) => {
|
||||||
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
|
const isComingSoon = index === 1;
|
||||||
const isDisabled = isComingSoon;
|
const isDisabled = isComingSoon;
|
||||||
const isCategoryCard = template.id === 'admin-templates-category';
|
const isCategoryCard = template.id === 'admin-templates-category';
|
||||||
// const isCustomCard = template.id === 'custom';
|
// const isCustomCard = template.id === 'custom';
|
||||||
@ -124,7 +124,7 @@ export function TemplateSelectionStep({
|
|||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
||||||
? 'border-gray-200 bg-gray-50/50 opacity-85 cursor-not-allowed'
|
? 'border-gray-200 bg-gray-50/50 opacity-50 cursor-not-allowed'
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
|
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
|
||||||
: isCategoryCard
|
: isCategoryCard
|
||||||
|
|||||||
@ -73,15 +73,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
|
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
|
||||||
const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
|
const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
|
||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
|
|
||||||
if (logoutFlag === 'true' || forceLogout === 'true') {
|
if (logoutFlag === 'true' || forceLogout === 'true') {
|
||||||
// Remove flags
|
// Remove flags
|
||||||
sessionStorage.removeItem('__logout_in_progress__');
|
sessionStorage.removeItem('__logout_in_progress__');
|
||||||
sessionStorage.removeItem('__force_logout__');
|
sessionStorage.removeItem('__force_logout__');
|
||||||
|
|
||||||
// Clear all tokens one more time (aggressive)
|
// Clear all tokens one more time (aggressive)
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
|
||||||
// Also manually clear everything
|
// Also manually clear everything
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@ -89,16 +89,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing storage:', e);
|
console.error('Error clearing storage:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set unauthenticated state
|
// Set unauthenticated state
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
|
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
|
||||||
@ -127,43 +127,43 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
window.history.replaceState({}, document.title, newUrl);
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
||||||
// This is critical for production mode where we need to exchange code for tokens
|
// This is essential for production mode where we need to exchange code for tokens
|
||||||
// before we can verify session with server
|
// before we can verify session with server
|
||||||
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
|
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
|
||||||
// Don't check auth status here - let the callback handler do its job
|
// Don't check auth status here - let the callback handler do its job
|
||||||
// The callback handler will set isAuthenticated after successful token exchange
|
// The callback handler will set isAuthenticated after successful token exchange
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 4: Check authentication status
|
// PRIORITY 4: Check authentication status
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
const userData = TokenManager.getUserData();
|
const userData = TokenManager.getUserData();
|
||||||
const hasAuthData = token || refreshToken || userData;
|
const hasAuthData = token || refreshToken || userData;
|
||||||
|
|
||||||
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
// In production: Always verify with server (cookies are sent automatically)
|
// In production: Always verify with server (cookies are sent automatically)
|
||||||
// In development: Check local auth data first
|
// In development: Check local auth data first
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
// Production: Verify session with server via httpOnly cookie
|
// Prod: Verify session with server via httpOnly cookie
|
||||||
if (!isLoggingOut) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Development: If no auth data exists, user is not authenticated
|
// Dev: If no auth data exists, user is not authenticated
|
||||||
if (!hasAuthData) {
|
if (!hasAuthData) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
|
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
|
||||||
if (!isLoggingOut) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
@ -211,7 +211,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Handle callback from OAuth redirect
|
// Handle callback from OAuth redirect
|
||||||
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
|
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
|
||||||
const callbackProcessedRef = useRef(false);
|
const callbackProcessedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if already processed or not on callback page
|
// Skip if already processed or not on callback page
|
||||||
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
|
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
|
||||||
@ -220,7 +220,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
// Check if this is a logout redirect (from Tanflow post-logout redirect)
|
// Check if this is a logout redirect (from Tanflow post-logout redirect)
|
||||||
// If it has logout parameters but no code, it's a logout redirect, not a login callback
|
// If it has logout parameters but no code, it's a logout redirect, not a login callback
|
||||||
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
|
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
|
||||||
@ -236,10 +236,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
window.location.replace(redirectUrl);
|
window.location.replace(redirectUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as processed immediately to prevent duplicate calls
|
// Mark as processed immediately to prevent duplicate calls
|
||||||
callbackProcessedRef.current = true;
|
callbackProcessedRef.current = true;
|
||||||
|
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
const errorParam = urlParams.get('error');
|
const errorParam = urlParams.get('error');
|
||||||
|
|
||||||
@ -248,7 +248,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Detect provider from sessionStorage
|
// Detect provider from sessionStorage
|
||||||
const authProvider = sessionStorage.getItem('auth_provider');
|
const authProvider = sessionStorage.getItem('auth_provider');
|
||||||
|
|
||||||
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
|
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
|
||||||
if (authProvider === 'tanflow') {
|
if (authProvider === 'tanflow') {
|
||||||
// Clear the provider flag and let TanflowCallback handle it
|
// Clear the provider flag and let TanflowCallback handle it
|
||||||
@ -277,21 +277,21 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request
|
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request
|
||||||
// This is the frontend callback URL, NOT the backend URL
|
// This is the frontend callback URL, NOT the backend URL
|
||||||
// Backend will use this same URI when exchanging code with Okta
|
// Backend will use this same URI when exchanging code with Okta
|
||||||
const redirectUri = `${window.location.origin}/login/callback`;
|
const redirectUri = `${window.location.origin}/login/callback`;
|
||||||
|
|
||||||
const result = await exchangeCodeForTokens(code, redirectUri);
|
const result = await exchangeCodeForTokens(code, redirectUri);
|
||||||
|
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Clear provider flag after successful authentication
|
// Clear provider flag after successful authentication
|
||||||
sessionStorage.removeItem('auth_provider');
|
sessionStorage.removeItem('auth_provider');
|
||||||
|
|
||||||
// Clean URL after success
|
// Clean URL after success
|
||||||
window.history.replaceState({}, document.title, '/');
|
window.history.replaceState({}, document.title, '/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -317,17 +317,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// PRODUCTION MODE: Verify session via httpOnly cookie
|
// Prod MODE: Verify session via httpOnly cookie
|
||||||
// The cookie is sent automatically with the request (withCredentials: true)
|
// The cookie is sent automatically with the request (withCredentials: true)
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
const storedUser = TokenManager.getUserData();
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
// Try to get current user from server - this validates the httpOnly cookie
|
// Try to get current user from server - this validates the httpOnly cookie
|
||||||
try {
|
try {
|
||||||
const userData = await getCurrentUser();
|
const userData = await getCurrentUser();
|
||||||
@ -368,8 +368,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEVELOPMENT MODE: Check local token
|
// Dev MODE: Check local token
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const storedUser = TokenManager.getUserData();
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
@ -454,7 +454,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
// Redirect to Okta login
|
// Redirect to Okta login
|
||||||
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || 'https://dev-830839.oktapreview.com';
|
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}';
|
||||||
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
|
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
|
||||||
const redirectUri = `${window.location.origin}/login/callback`;
|
const redirectUri = `${window.location.origin}/login/callback`;
|
||||||
const responseType = 'code';
|
const responseType = 'code';
|
||||||
@ -467,14 +467,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
|
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
|
||||||
|
|
||||||
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
||||||
`client_id=${clientId}&` +
|
`client_id=${clientId}&` +
|
||||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||||
`response_type=${responseType}&` +
|
`response_type=${responseType}&` +
|
||||||
`scope=${encodeURIComponent(scope)}&` +
|
`scope=${encodeURIComponent(scope)}&` +
|
||||||
`state=${state}`;
|
`state=${state}`;
|
||||||
|
|
||||||
// Add prompt=login if coming from logout to force re-authentication
|
// Add prompt=login if coming from logout to force re-authentication
|
||||||
// This ensures Okta requires login even if a session still exists
|
// This ensures Okta requires login even if a session still exists
|
||||||
if (isAfterLogout) {
|
if (isAfterLogout) {
|
||||||
@ -490,28 +490,28 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
// CRITICAL: Get id_token from TokenManager before clearing anything
|
//: Get id_token from TokenManager before clearing anything
|
||||||
// Needed for both Okta and Tanflow logout endpoints
|
// Needed for both Okta and Tanflow logout endpoints
|
||||||
const idToken = TokenManager.getIdToken();
|
const idToken = TokenManager.getIdToken();
|
||||||
|
|
||||||
// Detect which provider was used for login (check sessionStorage or user data)
|
// Detect which provider was used for login (check sessionStorage or user data)
|
||||||
// If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern
|
// If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern
|
||||||
const authProvider = sessionStorage.getItem('auth_provider') ||
|
const authProvider = sessionStorage.getItem('auth_provider') ||
|
||||||
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
|
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
|
||||||
'okta'; // Default to OKTA if unknown
|
'okta'; // Default to OKTA if unknown
|
||||||
|
|
||||||
// Set logout flag to prevent auto-authentication after redirect
|
// Set logout flag to prevent auto-authentication after redirect
|
||||||
// This must be set BEFORE clearing storage so it survives
|
// This must be set BEFORE clearing storage so it survives
|
||||||
sessionStorage.setItem('__logout_in_progress__', 'true');
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
||||||
sessionStorage.setItem('__force_logout__', 'true');
|
sessionStorage.setItem('__force_logout__', 'true');
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
||||||
// Reset auth state FIRST to prevent any re-authentication
|
// Reset auth state FIRST to prevent any re-authentication
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
||||||
|
|
||||||
// Call backend logout API to clear server-side session and httpOnly cookies
|
// Call backend logout API to clear server-side session and httpOnly cookies
|
||||||
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
||||||
try {
|
try {
|
||||||
@ -522,17 +522,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
||||||
// Continue with logout even if API call fails
|
// Continue with logout even if API call fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
|
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
|
||||||
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
const storedAuthProvider = sessionStorage.getItem('auth_provider');
|
const storedAuthProvider = sessionStorage.getItem('auth_provider');
|
||||||
|
|
||||||
// Clear all tokens EXCEPT id_token (we need it for provider logout)
|
// Clear all tokens EXCEPT id_token (we need it for provider logout)
|
||||||
// Note: We'll clear id_token after provider logout
|
// Note: We'll clear id_token after provider logout
|
||||||
// Clear tokens (but we'll restore id_token if needed)
|
// Clear tokens (but we'll restore id_token if needed)
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
|
||||||
// Restore logout flags and id_token immediately after clearAll
|
// Restore logout flags and id_token immediately after clearAll
|
||||||
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
||||||
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
||||||
@ -542,10 +542,10 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (storedAuthProvider) {
|
if (storedAuthProvider) {
|
||||||
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
|
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to ensure sessionStorage is written before redirect
|
// Small delay to ensure sessionStorage is written before redirect
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Handle provider-specific logout
|
// Handle provider-specific logout
|
||||||
if (authProvider === 'tanflow' && idToken) {
|
if (authProvider === 'tanflow' && idToken) {
|
||||||
console.log('🚪 Initiating Tanflow logout...');
|
console.log('🚪 Initiating Tanflow logout...');
|
||||||
@ -560,7 +560,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Fall through to default logout flow
|
// Fall through to default logout flow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
|
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
|
||||||
console.log('🚪 Using OKTA logout flow or fallback');
|
console.log('🚪 Using OKTA logout flow or fallback');
|
||||||
sessionStorage.removeItem('auth_provider');
|
sessionStorage.removeItem('auth_provider');
|
||||||
@ -590,7 +590,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const getAccessTokenSilently = async (): Promise<string | null> => {
|
const getAccessTokenSilently = async (): Promise<string | null> => {
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
// In production mode, tokens are in httpOnly cookies
|
// In production mode, tokens are in httpOnly cookies
|
||||||
// We can't access them directly, but API calls will include them automatically
|
// We can't access them directly, but API calls will include them automatically
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
@ -599,7 +599,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
|
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to refresh the session
|
// Try to refresh the session
|
||||||
try {
|
try {
|
||||||
await refreshTokenSilently();
|
await refreshTokenSilently();
|
||||||
@ -608,8 +608,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development mode: tokens in localStorage
|
// Dev mode: tokens in localStorage
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
return token;
|
return token;
|
||||||
@ -626,17 +626,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const refreshTokenSilently = async (): Promise<void> => {
|
const refreshTokenSilently = async (): Promise<void> => {
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await refreshAccessToken();
|
const newToken = await refreshAccessToken();
|
||||||
|
|
||||||
// In production, refresh might not return token (it's in httpOnly cookie)
|
// In production, refresh might not return token (it's in httpOnly cookie)
|
||||||
// but if the call succeeded, the session is valid
|
// but if the call succeeded, the session is valid
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
// Session refreshed via cookies
|
// Session refreshed via cookies
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
// Token refreshed successfully (development mode)
|
// Token refreshed successfully (development mode)
|
||||||
return;
|
return;
|
||||||
@ -672,7 +672,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Auth0Provider
|
<Auth0Provider
|
||||||
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
|
domain="{{IDP_DOMAIN}}/oauth2/default/v1"
|
||||||
clientId="0oa2j8slwj5S4bG5k0h8"
|
clientId="0oa2j8slwj5S4bG5k0h8"
|
||||||
authorizationParams={{
|
authorizationParams={{
|
||||||
redirect_uri: window.location.origin + '/login/callback',
|
redirect_uri: window.location.origin + '/login/callback',
|
||||||
|
|||||||
@ -31,14 +31,14 @@ export function StandardClosedRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter: _templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange: _onTemplateTypeChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -130,8 +130,8 @@ export function StandardClosedRequestsFilters({
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{/*
|
|
||||||
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@ -31,13 +31,13 @@ export function StandardRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter: _templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onPriorityFilterChange,
|
onPriorityFilterChange,
|
||||||
// onTemplateTypeFilterChange,
|
onTemplateTypeFilterChange: _onTemplateTypeFilterChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter: _templateTypeFilter,
|
||||||
departmentFilter,
|
departmentFilter,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
initiatorFilter: _initiatorFilter,
|
initiatorFilter: _initiatorFilter,
|
||||||
@ -104,7 +104,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange: _onTemplateTypeChange,
|
||||||
onDepartmentChange,
|
onDepartmentChange,
|
||||||
onSlaComplianceChange,
|
onSlaComplianceChange,
|
||||||
onInitiatorChange: _onInitiatorChange,
|
onInitiatorChange: _onInitiatorChange,
|
||||||
@ -180,7 +180,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}>
|
||||||
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|||||||
@ -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);
|
||||||
@ -141,7 +141,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
// Create new approver only if it doesn't exist
|
// Create new approver only if it doesn't exist
|
||||||
if (step.isAuto) {
|
if (step.isAuto) {
|
||||||
// System steps
|
// System steps
|
||||||
const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com';
|
const systemEmail = step.level === 8 ? `finance@${import.meta.env.VITE_APP_DOMAIN}` : `system@${import.meta.env.VITE_APP_DOMAIN}`;
|
||||||
const systemName = step.level === 8 ? 'System/Finance' : 'System';
|
const systemName = step.level === 8 ? 'System/Finance' : 'System';
|
||||||
newApprovers.push({
|
newApprovers.push({
|
||||||
email: systemEmail,
|
email: systemEmail,
|
||||||
@ -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">
|
||||||
|
|||||||
@ -29,7 +29,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, type DealerInfo } from '@/services/dealerApi';
|
import { verifyDealerLogin, searchExternalDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
||||||
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
|
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
|
||||||
@ -68,7 +68,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
const dealerSearchTimer = useRef<any>(null);
|
const dealerSearchTimer = useRef<any>(null);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// System policy state
|
// System policy state
|
||||||
const [systemPolicy, setSystemPolicy] = useState({
|
const [systemPolicy, setSystemPolicy] = useState({
|
||||||
maxApprovalLevels: 10,
|
maxApprovalLevels: 10,
|
||||||
@ -76,7 +76,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
allowSpectators: true,
|
allowSpectators: true,
|
||||||
maxSpectators: 20
|
maxSpectators: 20
|
||||||
});
|
});
|
||||||
|
|
||||||
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
||||||
@ -140,7 +140,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
activityName: '',
|
activityName: '',
|
||||||
activityType: '',
|
activityType: '',
|
||||||
@ -175,7 +175,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
// Handle dealer search input with debouncing
|
// Handle dealer search input with debouncing
|
||||||
const handleDealerSearchInputChange = (value: string) => {
|
const handleDealerSearchInputChange = (value: string) => {
|
||||||
setDealerSearchInput(value);
|
setDealerSearchInput(value);
|
||||||
|
|
||||||
// Clear previous timer
|
// Clear previous timer
|
||||||
if (dealerSearchTimer.current) {
|
if (dealerSearchTimer.current) {
|
||||||
clearTimeout(dealerSearchTimer.current);
|
clearTimeout(dealerSearchTimer.current);
|
||||||
@ -194,10 +194,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
// Debounce search
|
// Debounce search
|
||||||
dealerSearchTimer.current = setTimeout(async () => {
|
dealerSearchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const results = await fetchDealersFromAPI(value, 10); // Limit to 10 results
|
const result = await searchExternalDealerByCode(value);
|
||||||
setDealerSearchResults(results);
|
if (result) {
|
||||||
|
// Map external API response to DealerInfo structure
|
||||||
|
const mappedDealer: DealerInfo = {
|
||||||
|
dealerId: result.dealer || result.dealer_code || value,
|
||||||
|
dealerCode: result.dealer || result.dealer_code || value,
|
||||||
|
dealerName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
|
||||||
|
displayName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
|
||||||
|
email: result['dealer email'] || '',
|
||||||
|
phone: result['dealer phone'] || '',
|
||||||
|
city: result['re city'] || result.city || '',
|
||||||
|
state: result['re state code'] || result.state || '',
|
||||||
|
isLoggedIn: true, // We'll verify this in the next step
|
||||||
|
};
|
||||||
|
setDealerSearchResults([mappedDealer]);
|
||||||
|
} else {
|
||||||
|
setDealerSearchResults([]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error searching dealers:', error);
|
console.error('Error searching external dealer:', error);
|
||||||
setDealerSearchResults([]);
|
setDealerSearchResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setDealerSearchLoading(false);
|
setDealerSearchLoading(false);
|
||||||
@ -208,7 +224,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
const updateFormData = (field: string, value: any) => {
|
const updateFormData = (field: string, value: any) => {
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const updated = { ...prev, [field]: value };
|
const updated = { ...prev, [field]: value };
|
||||||
|
|
||||||
// Validate period dates
|
// Validate period dates
|
||||||
if (field === 'periodStartDate') {
|
if (field === 'periodStartDate') {
|
||||||
// If start date is selected and end date exists, validate end date
|
// If start date is selected and end date exists, validate end date
|
||||||
@ -225,7 +241,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -233,18 +249,18 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
const isStepValid = () => {
|
const isStepValid = () => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
return formData.activityName &&
|
return formData.activityName &&
|
||||||
formData.activityType &&
|
formData.activityType &&
|
||||||
formData.dealerCode &&
|
formData.dealerCode &&
|
||||||
formData.dealerName &&
|
formData.dealerName &&
|
||||||
formData.activityDate &&
|
formData.activityDate &&
|
||||||
formData.location &&
|
formData.location &&
|
||||||
formData.requestDescription;
|
formData.requestDescription;
|
||||||
case 2:
|
case 2:
|
||||||
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
|
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
// Find step 3 approver by originalStepLevel first, then fallback to level
|
// Find step 3 approver by originalStepLevel first, then fallback to level
|
||||||
const step3Approver = approvers.find((a: any) =>
|
const step3Approver = approvers.find((a: any) =>
|
||||||
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
|
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
|
||||||
);
|
);
|
||||||
// Step 8 is now a system step, no validation needed
|
// Step 8 is now a system step, no validation needed
|
||||||
@ -263,15 +279,15 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
if (currentStep === 2) {
|
if (currentStep === 2) {
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
// Find step 3 approver by originalStepLevel first, then fallback to level
|
// Find step 3 approver by originalStepLevel first, then fallback to level
|
||||||
const step3Approver = approvers.find((a: any) =>
|
const step3Approver = approvers.find((a: any) =>
|
||||||
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
|
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
|
||||||
);
|
);
|
||||||
const missingSteps: string[] = [];
|
const missingSteps: string[] = [];
|
||||||
|
|
||||||
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
|
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
|
||||||
missingSteps.push('Department Lead Approval');
|
missingSteps.push('Department Lead Approval');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingSteps.length > 0) {
|
if (missingSteps.length > 0) {
|
||||||
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
|
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
@ -297,7 +313,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
setVerifyingDealer(true);
|
setVerifyingDealer(true);
|
||||||
try {
|
try {
|
||||||
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode);
|
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode);
|
||||||
|
|
||||||
if (!verifiedDealer.isLoggedIn) {
|
if (!verifiedDealer.isLoggedIn) {
|
||||||
toast.error(
|
toast.error(
|
||||||
`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`,
|
`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`,
|
||||||
@ -321,14 +337,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
updateFormData('dealerEmail', verifiedDealer.email || '');
|
updateFormData('dealerEmail', verifiedDealer.email || '');
|
||||||
updateFormData('dealerPhone', verifiedDealer.phone || '');
|
updateFormData('dealerPhone', verifiedDealer.phone || '');
|
||||||
updateFormData('dealerAddress', ''); // Address not available in API response
|
updateFormData('dealerAddress', ''); // Address not available in API response
|
||||||
|
|
||||||
// Clear search input and results
|
// Clear search input and results
|
||||||
setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName);
|
setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName);
|
||||||
setDealerSearchResults([]);
|
setDealerSearchResults([]);
|
||||||
|
|
||||||
toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`);
|
toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const errorMessage = 'Dealer is not mapped to the system'
|
const errorMessage = 'Dealer is not mapped to the system'
|
||||||
toast.error(errorMessage, { duration: 5000 });
|
toast.error(errorMessage, { duration: 5000 });
|
||||||
// Clear the selection
|
// Clear the selection
|
||||||
setDealerSearchInput('');
|
setDealerSearchInput('');
|
||||||
@ -353,11 +369,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
// Just sort them and prepare for submission
|
// Just sort them and prepare for submission
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
|
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
// Check for duplicate levels (should not happen, but safeguard)
|
// Check for duplicate levels (should not happen, but safeguard)
|
||||||
const levelMap = new Map<number, typeof sortedApprovers[0]>();
|
const levelMap = new Map<number, typeof sortedApprovers[0]>();
|
||||||
const duplicates: number[] = [];
|
const duplicates: number[] = [];
|
||||||
|
|
||||||
sortedApprovers.forEach((approver) => {
|
sortedApprovers.forEach((approver) => {
|
||||||
if (levelMap.has(approver.level)) {
|
if (levelMap.has(approver.level)) {
|
||||||
duplicates.push(approver.level);
|
duplicates.push(approver.level);
|
||||||
@ -365,13 +381,13 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
levelMap.set(approver.level, approver);
|
levelMap.set(approver.level, approver);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (duplicates.length > 0) {
|
if (duplicates.length > 0) {
|
||||||
toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`);
|
toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`);
|
||||||
console.error('Duplicate levels found:', duplicates, sortedApprovers);
|
console.error('Duplicate levels found:', duplicates, sortedApprovers);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare final approvers array - preserve stepName for additional approvers
|
// Prepare final approvers array - preserve stepName for additional approvers
|
||||||
// The backend will use stepName to set the levelName for approval levels
|
// The backend will use stepName to set the levelName for approval levels
|
||||||
// Also preserve originalStepLevel so backend can identify which step each approver belongs to
|
// Also preserve originalStepLevel so backend can identify which step each approver belongs to
|
||||||
@ -384,18 +400,18 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
tat: approver.tat,
|
tat: approver.tat,
|
||||||
tatType: approver.tatType,
|
tatType: approver.tatType,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preserve stepName for additional approvers
|
// Preserve stepName for additional approvers
|
||||||
if (approver.isAdditional && approver.stepName) {
|
if (approver.isAdditional && approver.stepName) {
|
||||||
result.stepName = approver.stepName;
|
result.stepName = approver.stepName;
|
||||||
result.isAdditional = true;
|
result.isAdditional = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Preserve originalStepLevel for fixed steps (so backend can identify which step this is)
|
// Preserve originalStepLevel for fixed steps (so backend can identify which step this is)
|
||||||
if (approver.originalStepLevel) {
|
if (approver.originalStepLevel) {
|
||||||
result.originalStepLevel = approver.originalStepLevel;
|
result.originalStepLevel = approver.originalStepLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -486,8 +502,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
|
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={formData.activityType}
|
value={formData.activityType}
|
||||||
onValueChange={(value) => updateFormData('activityType', value)}
|
onValueChange={(value) => updateFormData('activityType', value)}
|
||||||
disabled={loadingActivityTypes}
|
disabled={loadingActivityTypes}
|
||||||
>
|
>
|
||||||
@ -734,7 +750,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{formData.periodStartDate
|
{formData.periodStartDate
|
||||||
? 'Please select end date for the period'
|
? 'Please select end date for the period'
|
||||||
: 'Please select start date first'}
|
: 'Please select start date first'}
|
||||||
</p>
|
</p>
|
||||||
@ -754,9 +770,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
currentUserEmail={(user as any)?.email || ''}
|
currentUserEmail={(user as any)?.email || ''}
|
||||||
currentUserId={(user as any)?.userId || ''}
|
currentUserId={(user as any)?.userId || ''}
|
||||||
currentUserName={
|
currentUserName={
|
||||||
(user as any)?.displayName ||
|
(user as any)?.displayName ||
|
||||||
(user as any)?.name ||
|
(user as any)?.name ||
|
||||||
((user as any)?.firstName && (user as any)?.lastName
|
((user as any)?.firstName && (user as any)?.lastName
|
||||||
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
||||||
: (user as any)?.email || 'User')
|
: (user as any)?.email || 'User')
|
||||||
}
|
}
|
||||||
@ -857,16 +873,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
const sortedApprovers = [...(formData.approvers || [])]
|
const sortedApprovers = [...(formData.approvers || [])]
|
||||||
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@'))
|
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@'))
|
||||||
.sort((a: any, b: any) => a.level - b.level);
|
.sort((a: any, b: any) => a.level - b.level);
|
||||||
|
|
||||||
return sortedApprovers.map((approver: any) => {
|
return sortedApprovers.map((approver: any) => {
|
||||||
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;
|
||||||
|
|
||||||
// Find step name - handle additional approvers and shifted levels
|
// Find step name - handle additional approvers and shifted levels
|
||||||
let stepName = 'Unknown';
|
let stepName = 'Unknown';
|
||||||
let stepLabel = '';
|
let stepLabel = '';
|
||||||
|
|
||||||
if (approver.isAdditional) {
|
if (approver.isAdditional) {
|
||||||
// Additional approver - use stepName if available
|
// Additional approver - use stepName if available
|
||||||
stepName = approver.stepName || 'Additional Approver';
|
stepName = approver.stepName || 'Additional Approver';
|
||||||
@ -874,17 +890,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`;
|
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`;
|
||||||
} else {
|
} else {
|
||||||
// Fixed step - find by originalStepLevel first, then fallback to level
|
// Fixed step - find 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 && !s.isAuto);
|
: CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto);
|
||||||
stepName = step?.name || 'Unknown';
|
stepName = step?.name || 'Unknown';
|
||||||
stepLabel = stepName;
|
stepLabel = stepName;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${
|
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
|
||||||
approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@ -960,8 +975,8 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
|
||||||
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
|
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={formData.requestDescription || ''}
|
content={formData.requestDescription || ''}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -1032,7 +1047,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<span className="hidden sm:inline">Back to Templates</span>
|
<span className="hidden sm:inline">Back to Templates</span>
|
||||||
<span className="sm:hidden">Back</span>
|
<span className="sm:hidden">Back</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
|
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
|
||||||
@ -1048,11 +1063,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
|
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
|
||||||
<div className="flex justify-between mt-2 px-1">
|
<div className="flex justify-between mt-2 px-1">
|
||||||
{STEP_NAMES.map((_name, index) => (
|
{STEP_NAMES.map((_name, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className={`text-xs sm:text-sm ${
|
className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
||||||
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
@ -1085,11 +1099,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
{currentStep < totalSteps ? (
|
{currentStep < totalSteps ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
|
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid()
|
||||||
!isStepValid()
|
? 'opacity-50 cursor-pointer hover:opacity-60'
|
||||||
? 'opacity-50 cursor-pointer hover:opacity-60'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
|||||||
@ -5,13 +5,13 @@
|
|||||||
* Located in: src/dealer-claim/components/request-detail/
|
* Located in: src/dealer-claim/components/request-detail/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } 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 { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
|
import { DollarSign, Download, CircleCheckBig, Target, CircleAlert } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
|
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
@ -30,95 +30,87 @@ interface IOBlockedDetails {
|
|||||||
blockedDate: string;
|
blockedDate: string;
|
||||||
blockedBy: string; // User who blocked
|
blockedBy: string; // User who blocked
|
||||||
sapDocumentNumber: string;
|
sapDocumentNumber: string;
|
||||||
status: 'blocked' | 'released' | 'failed';
|
status: 'blocked' | 'released' | 'failed' | 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const requestId = apiRequest?.requestId || request?.requestId;
|
const requestId = apiRequest?.requestId || request?.requestId;
|
||||||
|
|
||||||
// Load existing IO data from apiRequest or request
|
|
||||||
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
|
|
||||||
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
|
||||||
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
|
||||||
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
|
|
||||||
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
|
||||||
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
|
|
||||||
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
|
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
|
||||||
const organizer = internalOrder?.organizer || null;
|
|
||||||
|
|
||||||
// Get estimated budget from proposal details
|
|
||||||
const proposalDetails = apiRequest?.proposalDetails || {};
|
const proposalDetails = apiRequest?.proposalDetails || {};
|
||||||
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
|
const claimDetails = apiRequest?.claimDetails || apiRequest || {};
|
||||||
|
|
||||||
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
// Calculate total base amount (needed for budget verification as requested)
|
||||||
|
// This is the taxable amount excluding GST
|
||||||
|
const totalBaseAmount = useMemo(() => {
|
||||||
|
const costBreakupRaw = proposalDetails?.costBreakup || claimDetails?.costBreakup || [];
|
||||||
|
const costBreakup = Array.isArray(costBreakupRaw)
|
||||||
|
? costBreakupRaw
|
||||||
|
: (typeof costBreakupRaw === 'string'
|
||||||
|
? JSON.parse(costBreakupRaw)
|
||||||
|
: []);
|
||||||
|
|
||||||
|
if (!Array.isArray(costBreakup) || costBreakup.length === 0) {
|
||||||
|
return Number(claimDetails?.totalProposedTaxableAmount || proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return costBreakup.reduce((sum: number, item: any) => {
|
||||||
|
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
||||||
|
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
|
||||||
|
return sum + (Number(amount) * Number(quantity));
|
||||||
|
}, 0);
|
||||||
|
}, [proposalDetails?.costBreakup, claimDetails?.costBreakup, claimDetails?.totalProposedTaxableAmount, proposalDetails?.totalEstimatedBudget]);
|
||||||
|
|
||||||
|
// Use base amount as the target budget for blocking
|
||||||
|
const estimatedBudget = totalBaseAmount;
|
||||||
|
|
||||||
|
// Budget status for signaling (Scenario 2)
|
||||||
|
// Use apiRequest as the primary source of truth, fall back to request
|
||||||
|
const budgetTracking = apiRequest?.budgetTracking || request?.budgetTracking || {};
|
||||||
|
const budgetStatus = budgetTracking?.budgetStatus || budgetTracking?.budget_status || '';
|
||||||
|
const internalOrdersList = apiRequest?.internalOrders || apiRequest?.internal_orders || request?.internalOrders || [];
|
||||||
|
const isAdditionalBlockingNeeded = budgetStatus === 'PROPOSED' && internalOrdersList.length > 0;
|
||||||
|
|
||||||
|
const [ioNumber, setIoNumber] = useState('');
|
||||||
const [fetchingAmount, setFetchingAmount] = useState(false);
|
const [fetchingAmount, setFetchingAmount] = useState(false);
|
||||||
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
||||||
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
||||||
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
|
||||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
const [blockingBudget, setBlockingBudget] = useState(false);
|
||||||
|
|
||||||
// Load existing IO block details from apiRequest
|
// Load existing IO blocks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (internalOrder && existingIONumber) {
|
if (internalOrdersList.length > 0) {
|
||||||
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
|
const formattedIOs = internalOrdersList.map((io: any) => {
|
||||||
// We should NOT add blockedAmount to it - that would cause double deduction
|
const org = io.organizer || null;
|
||||||
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
|
const blockedByName = org?.displayName ||
|
||||||
const availableBeforeBlock = Number(existingAvailableBalance) || 0;
|
org?.display_name ||
|
||||||
|
org?.name ||
|
||||||
// Get blocked by user name from organizer association (who blocked the amount)
|
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) ||
|
||||||
// When amount is blocked, organizedBy stores the user who blocked it
|
org?.email ||
|
||||||
const blockedByName = organizer?.displayName ||
|
'Unknown User';
|
||||||
organizer?.display_name ||
|
return {
|
||||||
organizer?.name ||
|
ioNumber: io.ioNumber || io.io_number,
|
||||||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
|
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
|
||||||
organizer?.email ||
|
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
|
||||||
'Unknown User';
|
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
|
||||||
|
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
|
||||||
// Set IO number from existing data
|
|
||||||
setIoNumber(existingIONumber);
|
|
||||||
|
|
||||||
// Only set blocked details if amount is blocked
|
|
||||||
if (existingBlockedAmount > 0) {
|
|
||||||
const blockedAmt = Number(existingBlockedAmount) || 0;
|
|
||||||
const backendRemaining = Number(existingRemainingBalance) || 0;
|
|
||||||
|
|
||||||
// Calculate expected remaining balance for validation/debugging
|
|
||||||
// Formula: remaining = availableBeforeBlock - blockedAmount
|
|
||||||
const expectedRemaining = availableBeforeBlock - blockedAmt;
|
|
||||||
|
|
||||||
// Loading existing IO block
|
|
||||||
|
|
||||||
// Warn if remaining balance calculation seems incorrect (for backend debugging)
|
|
||||||
if (Math.abs(backendRemaining - expectedRemaining) > 0.01) {
|
|
||||||
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
|
|
||||||
availableBalance: availableBeforeBlock,
|
|
||||||
blockedAmount: blockedAmt,
|
|
||||||
expectedRemaining,
|
|
||||||
backendRemaining,
|
|
||||||
difference: backendRemaining - expectedRemaining,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setBlockedDetails({
|
|
||||||
ioNumber: existingIONumber,
|
|
||||||
blockedAmount: blockedAmt,
|
|
||||||
availableBalance: availableBeforeBlock, // Available amount before block
|
|
||||||
remainingBalance: backendRemaining, // Use backend calculated value
|
|
||||||
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
|
||||||
blockedBy: blockedByName,
|
blockedBy: blockedByName,
|
||||||
sapDocumentNumber: sapDocNumber,
|
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
|
||||||
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
status: (io.status === 'BLOCKED' ? 'blocked' :
|
||||||
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
io.status === 'RELEASED' ? 'released' :
|
||||||
});
|
io.status === 'PENDING' ? 'pending' : 'blocked') as any,
|
||||||
|
};
|
||||||
// Set fetched amount if available balance exists
|
});
|
||||||
if (availableBeforeBlock > 0) {
|
setBlockedIOs(formattedIOs);
|
||||||
setFetchedAmount(availableBeforeBlock);
|
|
||||||
}
|
// If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience
|
||||||
|
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
|
||||||
|
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available budget from SAP
|
* Fetch available budget from SAP
|
||||||
@ -140,15 +132,25 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
try {
|
try {
|
||||||
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
|
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
|
||||||
const ioData = await validateIO(requestId, ioNumber.trim());
|
const ioData = await validateIO(requestId, ioNumber.trim());
|
||||||
|
|
||||||
if (ioData.isValid && ioData.availableBalance > 0) {
|
if (ioData.isValid && ioData.availableBalance > 0) {
|
||||||
setFetchedAmount(ioData.availableBalance);
|
setFetchedAmount(ioData.availableBalance);
|
||||||
// Pre-fill amount to block with estimated budget (if available), otherwise use available balance
|
|
||||||
if (estimatedBudget > 0) {
|
// Calculate total already blocked amount
|
||||||
setAmountToBlock(String(estimatedBudget));
|
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
|
||||||
|
|
||||||
|
// Calculate remaining budget to block
|
||||||
|
const remainingToBlock = Math.max(0, estimatedBudget - totalAlreadyBlocked);
|
||||||
|
|
||||||
|
// Pre-fill amount to block with remaining budget, otherwise use available balance
|
||||||
|
if (remainingToBlock > 0) {
|
||||||
|
setAmountToBlock(String(remainingToBlock.toFixed(2)));
|
||||||
|
} else if (estimatedBudget > 0 && totalAlreadyBlocked === 0) {
|
||||||
|
setAmountToBlock(String(estimatedBudget.toFixed(2)));
|
||||||
} else {
|
} else {
|
||||||
setAmountToBlock(String(ioData.availableBalance));
|
setAmountToBlock(String(ioData.availableBalance.toFixed(2)));
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Invalid IO number or no available balance found');
|
toast.error('Invalid IO number or no available balance found');
|
||||||
@ -184,26 +186,33 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blockAmountRaw = parseFloat(amountToBlock);
|
const blockAmountRaw = parseFloat(amountToBlock);
|
||||||
|
|
||||||
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
|
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
|
||||||
toast.error('Please enter a valid amount to block');
|
toast.error('Please enter a valid amount to block');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round to exactly 2 decimal places to avoid floating point precision issues
|
// Round to exactly 2 decimal places to avoid floating point precision issues
|
||||||
// Use parseFloat with toFixed to ensure exact 2 decimal precision
|
// Use parseFloat with toFixed to ensure exact 2 decimal precision
|
||||||
const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
|
const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
|
||||||
|
|
||||||
if (blockAmount > fetchedAmount) {
|
if (blockAmount > fetchedAmount) {
|
||||||
toast.error('Amount to block exceeds available IO budget');
|
toast.error('Amount to block exceeds available IO budget');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that amount to block must exactly match estimated budget
|
|
||||||
|
// Calculate total already blocked
|
||||||
|
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
|
||||||
|
const totalPlanned = totalAlreadyBlocked + blockAmount;
|
||||||
|
|
||||||
|
// Validate that total planned must exactly match estimated budget
|
||||||
if (estimatedBudget > 0) {
|
if (estimatedBudget > 0) {
|
||||||
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
|
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
|
||||||
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
|
const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(2));
|
||||||
toast.error(`Amount to block must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })})`);
|
|
||||||
|
if (Math.abs(roundedTotalPlanned - roundedEstimatedBudget) > 0.01) {
|
||||||
|
toast.error(`Total blocked amount (₹${roundedTotalPlanned.toLocaleString('en-IN')}) must be exactly equal to the estimated budget (₹${roundedEstimatedBudget.toLocaleString('en-IN')})`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -224,29 +233,29 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
ioBlockedAmount: blockAmount,
|
ioBlockedAmount: blockAmount,
|
||||||
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
|
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sending to backend
|
// Sending to backend
|
||||||
|
|
||||||
await updateIODetails(requestId, payload);
|
await updateIODetails(requestId, payload);
|
||||||
|
|
||||||
// Fetch updated claim details to get the blocked IO data
|
// Fetch updated claim details to get the blocked IO data
|
||||||
const claimData = await getClaimDetails(requestId);
|
const claimData = await getClaimDetails(requestId);
|
||||||
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
||||||
|
|
||||||
if (updatedInternalOrder) {
|
if (updatedInternalOrder) {
|
||||||
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
|
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
|
||||||
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
|
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
|
||||||
|
|
||||||
// Calculate expected remaining balance for validation/debugging
|
// Calculate expected remaining balance for validation/debugging
|
||||||
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
|
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
|
||||||
|
|
||||||
// Blocking result processed
|
// Blocking result processed
|
||||||
|
|
||||||
// Warn if the saved amount differs from what we sent
|
// Warn if the saved amount differs from what we sent
|
||||||
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
|
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
|
||||||
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
|
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warn if remaining balance calculation seems incorrect (for backend debugging)
|
// Warn if remaining balance calculation seems incorrect (for backend debugging)
|
||||||
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
|
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
|
||||||
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
|
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
|
||||||
@ -257,17 +266,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
difference: savedRemainingBalance - expectedRemainingBalance,
|
difference: savedRemainingBalance - expectedRemainingBalance,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentUser = user as any;
|
const currentUser = user as any;
|
||||||
// When blocking, always use the current user who is performing the block action
|
// When blocking, always use the current user who is performing the block action
|
||||||
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
||||||
const blockedByName = currentUser?.displayName ||
|
const blockedByName = currentUser?.displayName ||
|
||||||
currentUser?.display_name ||
|
currentUser?.display_name ||
|
||||||
currentUser?.name ||
|
currentUser?.name ||
|
||||||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
|
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
|
||||||
currentUser?.email ||
|
currentUser?.email ||
|
||||||
'Current User';
|
'Current User';
|
||||||
|
|
||||||
const blocked: IOBlockedDetails = {
|
const blocked: IOBlockedDetails = {
|
||||||
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
||||||
blockedAmount: savedBlockedAmount,
|
blockedAmount: savedBlockedAmount,
|
||||||
@ -278,11 +287,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
setBlockedDetails(blocked);
|
setBlockedIOs(prev => [...prev, blocked]);
|
||||||
setAmountToBlock(''); // Clear the input
|
setAmountToBlock(''); // Clear the input
|
||||||
|
setFetchedAmount(null); // Reset fetched state
|
||||||
toast.success('IO budget blocked successfully in SAP');
|
toast.success('IO budget blocked successfully in SAP');
|
||||||
|
|
||||||
// Refresh request details
|
// Refresh request details
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
} else {
|
} else {
|
||||||
@ -321,12 +331,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
placeholder="Enter IO number (e.g., IO-2024-12345)"
|
placeholder="Enter IO number (e.g., IO-2024-12345)"
|
||||||
value={ioNumber}
|
value={ioNumber}
|
||||||
onChange={(e) => setIoNumber(e.target.value)}
|
onChange={(e) => setIoNumber(e.target.value)}
|
||||||
disabled={fetchingAmount || !!blockedDetails}
|
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFetchAmount}
|
onClick={handleFetchAmount}
|
||||||
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
|
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
|
||||||
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
|
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
@ -336,7 +346,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Instructions when IO number is entered but not fetched */}
|
{/* Instructions when IO number is entered but not fetched */}
|
||||||
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
|
{!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && (
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
<p className="text-sm text-blue-800">
|
<p className="text-sm text-blue-800">
|
||||||
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
|
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
|
||||||
@ -345,7 +355,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fetched Amount Display */}
|
{/* Fetched Amount Display */}
|
||||||
{fetchedAmount !== null && !blockedDetails && (
|
{fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && (
|
||||||
<>
|
<>
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
|
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -392,11 +402,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleBlockBudget}
|
onClick={handleBlockBudget}
|
||||||
disabled={
|
disabled={
|
||||||
blockingBudget ||
|
blockingBudget ||
|
||||||
!amountToBlock ||
|
!amountToBlock ||
|
||||||
parseFloat(amountToBlock) <= 0 ||
|
parseFloat(amountToBlock) <= 0 ||
|
||||||
parseFloat(amountToBlock) > fetchedAmount ||
|
parseFloat(amountToBlock) > fetchedAmount ||
|
||||||
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
|
(estimatedBudget > 0 && Math.abs((blockedIOs.reduce((s, i) => s + i.blockedAmount, 0) + parseFloat(amountToBlock)) - estimatedBudget) > 0.01)
|
||||||
}
|
}
|
||||||
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
||||||
>
|
>
|
||||||
@ -420,71 +430,57 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{blockedDetails ? (
|
{blockedIOs.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{/* Success Banner */}
|
{isAdditionalBlockingNeeded && (
|
||||||
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
|
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</p>
|
||||||
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
|
<p className="text-sm text-amber-700 mt-1">Actual expenses exceed the previously blocked amount. Please block an additional ₹{(estimatedBudget - blockedIOs.reduce((s, i) => s + i.blockedAmount, 0)).toLocaleString('en-IN', { minimumFractionDigits: 2 })}.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Blocked Details */}
|
{blockedIOs.slice().reverse().map((io, idx) => (
|
||||||
<div className="border rounded-lg divide-y">
|
<div key={idx} className="border rounded-lg overflow-hidden">
|
||||||
<div className="p-4">
|
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
|
<span className="font-semibold text-sm">IO: {io.ioNumber}</span>
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
|
<Badge className={
|
||||||
</div>
|
io.status === 'blocked' ? 'bg-green-100 text-green-800' :
|
||||||
<div className="p-4">
|
io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
'bg-blue-100 text-blue-800'
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
}>
|
||||||
</div>
|
{io.status === 'blocked' ? 'Blocked' :
|
||||||
<div className="p-4 bg-green-50">
|
io.status === 'pending' ? 'Provisioned' : 'Released'}
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
</Badge>
|
||||||
<p className="text-xl font-bold text-green-700">
|
</div>
|
||||||
₹{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
<div className="grid grid-cols-2 divide-x divide-y">
|
||||||
</p>
|
<div className="p-3">
|
||||||
</div>
|
<p className="text-[10px] text-gray-500 uppercase">Amount</p>
|
||||||
<div className="p-4">
|
<p className="text-sm font-bold text-green-700">₹{io.blockedAmount.toLocaleString('en-IN')}</p>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<div className="p-3">
|
||||||
₹{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p>
|
||||||
</p>
|
<p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-blue-50">
|
<div className="p-3">
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
|
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p>
|
||||||
<p className="text-sm font-bold text-blue-700">
|
<p className="text-xs">{io.blockedBy}</p>
|
||||||
₹{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
</div>
|
||||||
</p>
|
<div className="p-3">
|
||||||
</div>
|
<p className="text-[10px] text-gray-500 uppercase">Date</p>
|
||||||
<div className="p-4">
|
<p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900">{blockedDetails.blockedBy}</p>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked At</p>
|
|
||||||
<p className="text-sm font-medium text-gray-900">
|
|
||||||
{new Date(blockedDetails.blockedDate).toLocaleString('en-IN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: 'numeric',
|
|
||||||
hour12: true
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-gray-50">
|
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Status</p>
|
|
||||||
<Badge className="bg-green-100 text-green-800 border-green-200">
|
|
||||||
<CircleCheckBig className="w-3 h-3 mr-1" />
|
|
||||||
Blocked
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-[#2d4a3e] text-white rounded-lg flex justify-between items-center">
|
||||||
|
<span className="font-bold">Total Blocked:</span>
|
||||||
|
<span className="text-xl font-bold">₹{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -18,11 +18,11 @@ interface ActivityInformationCardProps {
|
|||||||
updatedAt?: string | Date;
|
updatedAt?: string | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityInformationCard({
|
export function ActivityInformationCard({
|
||||||
activityInfo,
|
activityInfo,
|
||||||
className,
|
className,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
}: ActivityInformationCardProps) {
|
}: ActivityInformationCardProps) {
|
||||||
// Defensive check: Ensure activityInfo exists
|
// Defensive check: Ensure activityInfo exists
|
||||||
if (!activityInfo) {
|
if (!activityInfo) {
|
||||||
@ -109,7 +109,7 @@ export function ActivityInformationCard({
|
|||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
||||||
<DollarSign className="w-4 h-4 text-green-600" />
|
<DollarSign className="w-4 h-4 text-green-600" />
|
||||||
{activityInfo.estimatedBudget
|
{activityInfo.estimatedBudget !== undefined && activityInfo.estimatedBudget !== null
|
||||||
? formatCurrency(activityInfo.estimatedBudget)
|
? formatCurrency(activityInfo.estimatedBudget)
|
||||||
: 'TBD'}
|
: 'TBD'}
|
||||||
</p>
|
</p>
|
||||||
@ -123,7 +123,11 @@ export function ActivityInformationCard({
|
|||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
||||||
<Receipt className="w-4 h-4 text-blue-600" />
|
<Receipt className="w-4 h-4 text-blue-600" />
|
||||||
{formatCurrency(activityInfo.closedExpenses)}
|
{formatCurrency(
|
||||||
|
activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0
|
||||||
|
? activityInfo.closedExpensesBreakdown.reduce((sum, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
|
||||||
|
: activityInfo.closedExpenses
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -147,23 +151,40 @@ export function ActivityInformationCard({
|
|||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
|
||||||
Closed Expenses Breakdown
|
Closed Expenses Breakdown
|
||||||
</label>
|
</label>
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg overflow-hidden">
|
||||||
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
|
<table className="w-full text-xs sm:text-sm">
|
||||||
<div key={index} className="flex justify-between items-center text-sm">
|
<thead className="bg-blue-100/50">
|
||||||
<span className="text-gray-700">{item.description}</span>
|
<tr>
|
||||||
<span className="font-medium text-gray-900">
|
<th className="px-3 py-2 text-left font-semibold text-blue-900">Description</th>
|
||||||
{formatCurrency(item.amount)}
|
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">Base</th>
|
||||||
</span>
|
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">GST</th>
|
||||||
</div>
|
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-28">Total</th>
|
||||||
))}
|
</tr>
|
||||||
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
|
</thead>
|
||||||
<span className="font-semibold text-gray-900">Total</span>
|
<tbody className="divide-y divide-blue-200/50">
|
||||||
<span className="font-bold text-blue-600">
|
{activityInfo.closedExpensesBreakdown.map((item: any, index: number) => (
|
||||||
{formatCurrency(
|
<tr key={index} className="hover:bg-blue-100/30">
|
||||||
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
|
<td className="px-3 py-2 text-gray-700">
|
||||||
)}
|
{item.description}
|
||||||
</span>
|
{item.gstRate ? <span className="text-[10px] text-gray-400 block">{item.gstRate}% GST</span> : null}
|
||||||
</div>
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.amount)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.gstAmt || 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium text-gray-900">
|
||||||
|
{formatCurrency(item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0)))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="bg-blue-100/50 font-bold">
|
||||||
|
<td colSpan={3} className="px-3 py-2 text-blue-900">Final Claim Amount</td>
|
||||||
|
<td className="px-3 py-2 text-right text-blue-700">
|
||||||
|
{formatCurrency(
|
||||||
|
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -175,8 +196,8 @@ export function ActivityInformationCard({
|
|||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
|
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={activityInfo.description || ''}
|
content={activityInfo.description || ''}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -26,6 +26,11 @@ interface DMSDetails {
|
|||||||
remarks?: string;
|
remarks?: string;
|
||||||
createdByName?: string;
|
createdByName?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
// PWC fields
|
||||||
|
irn?: string;
|
||||||
|
ackNo?: string;
|
||||||
|
ackDate?: string;
|
||||||
|
signedInvoiceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimAmountDetails {
|
interface ClaimAmountDetails {
|
||||||
@ -37,6 +42,8 @@ interface ClaimAmountDetails {
|
|||||||
interface CostBreakdownItem {
|
interface CostBreakdownItem {
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
totalAmt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleBasedVisibility {
|
interface RoleBasedVisibility {
|
||||||
@ -85,7 +92,7 @@ export function ProcessDetailsCard({
|
|||||||
|
|
||||||
const calculateTotal = (items?: CostBreakdownItem[]) => {
|
const calculateTotal = (items?: CostBreakdownItem[]) => {
|
||||||
if (!items || items.length === 0) return 0;
|
if (!items || items.length === 0) return 0;
|
||||||
return items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
|
return items.reduce((sum, item) => sum + (item.totalAmt ?? (item.amount + (item.gstAmt ?? 0))), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render if nothing to show
|
// Don't render if nothing to show
|
||||||
@ -120,7 +127,7 @@ export function ProcessDetailsCard({
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
|
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
|
||||||
|
|
||||||
{ioDetails.remarks && (
|
{ioDetails.remarks && (
|
||||||
<div className="pt-2 border-t border-blue-100">
|
<div className="pt-2 border-t border-blue-100">
|
||||||
<p className="text-xs text-gray-600 mb-1">Remark:</p>
|
<p className="text-xs text-gray-600 mb-1">Remark:</p>
|
||||||
@ -165,27 +172,57 @@ 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 Number
|
E-Invoice Details
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-2">
|
||||||
|
|
||||||
|
{dmsDetails.ackNo && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase">Ack No</p>
|
||||||
|
<p className="font-bold text-sm text-purple-700">{dmsDetails.ackNo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dmsDetails.irn && (
|
||||||
|
<div className="mb-2 p-2 bg-purple-50 rounded border border-purple-100">
|
||||||
|
<p className="text-[10px] text-purple-600 uppercase font-semibold">IRN</p>
|
||||||
|
<p className="text-[10px] font-mono break-all text-gray-700 leading-tight">
|
||||||
|
{dmsDetails.irn}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dmsDetails.signedInvoiceUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-8 text-xs gap-2 mb-2 border-purple-200 text-purple-700 hover:bg-purple-50"
|
||||||
|
onClick={() => window.open(dmsDetails.signedInvoiceUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<Receipt className="w-3.5 h-3.5" />
|
||||||
|
View E-Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{dmsDetails.remarks && (
|
{dmsDetails.remarks && (
|
||||||
<div className="pt-2 border-t border-purple-100">
|
<div className="pt-2 border-t border-purple-100">
|
||||||
<p className="text-xs text-gray-600 mb-1">Remarks:</p>
|
<p className="text-[10px] text-gray-500 uppercase mb-1">Remarks</p>
|
||||||
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
|
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t border-purple-100 mt-2">
|
<div className="pt-2 border-t border-purple-100 mt-2">
|
||||||
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
|
<p className="text-[10px] text-gray-500">By {dmsDetails.createdByName}</p>
|
||||||
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
|
<p className="text-[10px] text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -241,10 +278,10 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{estimatedBudgetBreakdown.map((item, index) => (
|
{estimatedBudgetBreakdown.map((item, index) => (
|
||||||
<div key={index} className="flex justify-between items-center text-xs">
|
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
|
||||||
<span className="text-gray-700">{item.description}</span>
|
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900 whitespace-nowrap">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -269,10 +306,10 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{closedExpensesBreakdown.map((item, index) => (
|
{closedExpensesBreakdown.map((item, index) => (
|
||||||
<div key={index} className="flex justify-between items-center text-xs">
|
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
|
||||||
<span className="text-gray-700">{item.description}</span>
|
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900 whitespace-nowrap">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -11,11 +11,19 @@ import { format } from 'date-fns';
|
|||||||
interface ProposalCostItem {
|
interface ProposalCostItem {
|
||||||
description: string;
|
description: string;
|
||||||
amount?: number | null;
|
amount?: number | null;
|
||||||
|
gstRate?: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
cgstAmt?: number;
|
||||||
|
sgstAmt?: number;
|
||||||
|
igstAmt?: number;
|
||||||
|
quantity?: number;
|
||||||
|
totalAmt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@ -29,19 +37,22 @@ 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
|
||||||
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
|
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
|
||||||
const total = proposalDetails.costBreakup.reduce((sum, item) => {
|
const total = proposalDetails.costBreakup.reduce((sum, item) => {
|
||||||
const amount = item.amount || 0;
|
const amount = item.amount || 0;
|
||||||
return sum + (Number.isNaN(amount) ? 0 : amount);
|
const gst = item.gstAmt || 0;
|
||||||
|
const lineTotal = item.totalAmt || (Number(amount) + Number(gst));
|
||||||
|
return sum + (Number.isNaN(lineTotal) ? 0 : lineTotal);
|
||||||
}, 0);
|
}, 0);
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -99,7 +110,13 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
|
|||||||
Item Description
|
Item Description
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
Amount
|
Base Amount
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
GST
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
Total
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -107,16 +124,27 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
|
|||||||
{(proposalDetails.costBreakup || []).map((item, index) => (
|
{(proposalDetails.costBreakup || []).map((item, index) => (
|
||||||
<tr key={index} className="hover:bg-gray-50">
|
<tr key={index} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
{item.description}
|
<div>{item.description}</div>
|
||||||
|
{item.gstRate ? (
|
||||||
|
<div className="text-[10px] text-gray-400">
|
||||||
|
{item.cgstAmt ? `CGST: ${item.gstRate / 2}%, SGST: ${item.gstRate / 2}%` : `IGST: ${item.gstRate}%`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 text-right">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 text-right">
|
||||||
|
{formatCurrency(item.gstAmt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
|
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.totalAmt || (Number(item.amount || 0) + Number(item.gstAmt || 0)))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="bg-green-50 font-semibold">
|
<tr className="bg-green-50 font-semibold">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
<td colSpan={3} className="px-4 py-3 text-sm text-gray-900">
|
||||||
Estimated Budget (Total)
|
Estimated Budget (Total Inclusive of GST)
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-green-700 text-right">
|
<td className="px-4 py-3 text-sm text-green-700 text-right">
|
||||||
{formatCurrency(estimatedTotal)}
|
{formatCurrency(estimatedTotal)}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ interface CreditNoteSAPModalProps {
|
|||||||
requestNumber?: string;
|
requestNumber?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
|
taxationType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreditNoteSAPModal({
|
export function CreditNoteSAPModal({
|
||||||
@ -53,13 +54,16 @@ export function CreditNoteSAPModal({
|
|||||||
requestNumber,
|
requestNumber,
|
||||||
requestId: _requestId,
|
requestId: _requestId,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
taxationType,
|
||||||
}: CreditNoteSAPModalProps) {
|
}: CreditNoteSAPModalProps) {
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||||
|
|
||||||
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
|
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
|
||||||
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
|
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
|
||||||
const creditNoteDate = creditNoteData?.creditNoteDate
|
const creditNoteDate = creditNoteData?.creditNoteDate
|
||||||
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
|
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
|
||||||
: '';
|
: '';
|
||||||
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
|
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
|
||||||
@ -69,7 +73,7 @@ export function CreditNoteSAPModal({
|
|||||||
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
|
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
|
||||||
const activity = activityName || 'Activity';
|
const activity = activityName || 'Activity';
|
||||||
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
|
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
|
||||||
const dueDateDisplay = dueDate
|
const dueDateDisplay = dueDate
|
||||||
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
|
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
|
||||||
: 'Jan 4, 2026';
|
: 'Jan 4, 2026';
|
||||||
|
|
||||||
@ -118,9 +122,16 @@ export function CreditNoteSAPModal({
|
|||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
|
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap">
|
||||||
<Receipt className="w-6 h-6 text-[--re-green]" />
|
<div className="flex items-center gap-2">
|
||||||
Credit Note from SAP
|
<Receipt className="w-6 h-6 text-[--re-green]" />
|
||||||
|
Credit Note from SAP
|
||||||
|
</div>
|
||||||
|
{taxationType && (
|
||||||
|
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
||||||
|
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-base">
|
<DialogDescription className="text-base">
|
||||||
Review and send credit note to dealer
|
Review and send credit note to dealer
|
||||||
|
|||||||
@ -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%;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,7 @@
|
|||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.dealer-completion-documents-modal {
|
.dealer-completion-documents-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 1000px !important;
|
max-width: 1200px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +33,7 @@
|
|||||||
@media (min-width: 1536px) {
|
@media (min-width: 1536px) {
|
||||||
.dealer-completion-documents-modal {
|
.dealer-completion-documents-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 1000px !important;
|
max-width: 1200px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,5 +64,4 @@
|
|||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -25,7 +25,7 @@
|
|||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.dealer-proposal-modal {
|
.dealer-proposal-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 1000px !important;
|
max-width: 1200px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,7 +33,7 @@
|
|||||||
@media (min-width: 1536px) {
|
@media (min-width: 1536px) {
|
||||||
.dealer-proposal-modal {
|
.dealer-proposal-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 1000px !important;
|
max-width: 1200px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,5 +64,4 @@
|
|||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -37,6 +37,7 @@ interface DeptLeadIOApprovalModalProps {
|
|||||||
preFilledIONumber?: string;
|
preFilledIONumber?: string;
|
||||||
preFilledBlockedAmount?: number;
|
preFilledBlockedAmount?: number;
|
||||||
preFilledRemainingBalance?: number;
|
preFilledRemainingBalance?: number;
|
||||||
|
taxationType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeptLeadIOApprovalModal({
|
export function DeptLeadIOApprovalModal({
|
||||||
@ -49,11 +50,16 @@ export function DeptLeadIOApprovalModal({
|
|||||||
preFilledIONumber,
|
preFilledIONumber,
|
||||||
preFilledBlockedAmount,
|
preFilledBlockedAmount,
|
||||||
preFilledRemainingBalance,
|
preFilledRemainingBalance,
|
||||||
|
taxationType,
|
||||||
}: DeptLeadIOApprovalModalProps) {
|
}: DeptLeadIOApprovalModalProps) {
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const isNonGst = useMemo(() => {
|
||||||
|
return taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||||
|
}, [taxationType]);
|
||||||
|
|
||||||
// Get IO number from props (read-only, from IO table)
|
// Get IO number from props (read-only, from IO table)
|
||||||
const ioNumber = preFilledIONumber || '';
|
const ioNumber = preFilledIONumber || '';
|
||||||
|
|
||||||
@ -97,7 +103,7 @@ export function DeptLeadIOApprovalModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
if (actionType === 'approve') {
|
if (actionType === 'approve') {
|
||||||
await onApprove({
|
await onApprove({
|
||||||
ioNumber: ioNumber.trim(),
|
ioNumber: ioNumber.trim(),
|
||||||
@ -106,7 +112,7 @@ export function DeptLeadIOApprovalModal({
|
|||||||
} else {
|
} else {
|
||||||
await onReject(comments.trim());
|
await onReject(comments.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReset();
|
handleReset();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -138,8 +144,13 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
|
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<DialogTitle className="font-semibold text-lg lg:text-xl">
|
<DialogTitle className="font-semibold text-lg lg:text-xl flex items-center gap-2 flex-wrap">
|
||||||
Review and Approve
|
Review and Approve
|
||||||
|
{taxationType && (
|
||||||
|
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
||||||
|
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs lg:text-sm mt-1">
|
<DialogDescription className="text-xs lg:text-sm mt-1">
|
||||||
Review IO details and provide your approval comments
|
Review IO details and provide your approval comments
|
||||||
@ -174,11 +185,10 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActionType('approve')}
|
onClick={() => setActionType('approve')}
|
||||||
className={`flex-1 text-sm lg:text-base ${
|
className={`flex-1 text-sm lg:text-base ${actionType === 'approve'
|
||||||
actionType === 'approve'
|
? 'bg-green-600 text-white shadow-sm'
|
||||||
? 'bg-green-600 text-white shadow-sm'
|
: 'text-gray-700 hover:bg-gray-200'
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
}`}
|
||||||
}`}
|
|
||||||
variant={actionType === 'approve' ? 'default' : 'ghost'}
|
variant={actionType === 'approve' ? 'default' : 'ghost'}
|
||||||
>
|
>
|
||||||
<CircleCheckBig className="w-4 h-4 mr-1" />
|
<CircleCheckBig className="w-4 h-4 mr-1" />
|
||||||
@ -187,11 +197,10 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActionType('reject')}
|
onClick={() => setActionType('reject')}
|
||||||
className={`flex-1 text-sm lg:text-base ${
|
className={`flex-1 text-sm lg:text-base ${actionType === 'reject'
|
||||||
actionType === 'reject'
|
? 'bg-red-600 text-white shadow-sm'
|
||||||
? 'bg-red-600 text-white shadow-sm'
|
: 'text-gray-700 hover:bg-gray-200'
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
}`}
|
||||||
}`}
|
|
||||||
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
|
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
|
||||||
>
|
>
|
||||||
<CircleX className="w-4 h-4 mr-1" />
|
<CircleX className="w-4 h-4 mr-1" />
|
||||||
@ -208,7 +217,7 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
|
||||||
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
|
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IO Number - Read-only from IO table */}
|
{/* IO Number - Read-only from IO table */}
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
@ -309,11 +318,10 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isFormValid || submitting}
|
disabled={!isFormValid || submitting}
|
||||||
className={`text-sm lg:text-base ${
|
className={`text-sm lg:text-base ${actionType === 'approve'
|
||||||
actionType === 'approve'
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
: 'bg-red-600 hover:bg-red-700'
|
} text-white`}
|
||||||
} text-white`}
|
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
|
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export function EmailNotificationTemplateModal({
|
|||||||
stepNumber,
|
stepNumber,
|
||||||
stepName,
|
stepName,
|
||||||
requestNumber = 'RE-REQ-2024-CM-101',
|
requestNumber = 'RE-REQ-2024-CM-101',
|
||||||
recipientEmail = 'system@royalenfield.com',
|
recipientEmail = `system@${import.meta.env.VITE_EMAIL_DOMAIN}`,
|
||||||
subject,
|
subject,
|
||||||
emailBody,
|
emailBody,
|
||||||
}: EmailNotificationTemplateModalProps) {
|
}: EmailNotificationTemplateModalProps) {
|
||||||
|
|||||||
@ -16,12 +16,12 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
FileText,
|
FileText,
|
||||||
IndianRupee,
|
IndianRupee,
|
||||||
Calendar,
|
Calendar,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
@ -39,6 +39,7 @@ interface CostItem {
|
|||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
quantity?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProposalData {
|
interface ProposalData {
|
||||||
@ -70,6 +71,7 @@ interface InitiatorProposalApprovalModalProps {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
request?: any; // Request object to check IO blocking status
|
request?: any; // Request object to check IO blocking status
|
||||||
previousProposalData?: any;
|
previousProposalData?: any;
|
||||||
|
taxationType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InitiatorProposalApprovalModal({
|
export function InitiatorProposalApprovalModal({
|
||||||
@ -84,16 +86,83 @@ export function InitiatorProposalApprovalModal({
|
|||||||
requestId: _requestId, // Prefix with _ to indicate intentionally unused
|
requestId: _requestId, // Prefix with _ to indicate intentionally unused
|
||||||
request,
|
request,
|
||||||
previousProposalData,
|
previousProposalData,
|
||||||
|
taxationType,
|
||||||
}: InitiatorProposalApprovalModalProps) {
|
}: InitiatorProposalApprovalModalProps) {
|
||||||
|
const isNonGst = useMemo(() => {
|
||||||
|
return taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||||
|
}, [taxationType]);
|
||||||
|
|
||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
|
const [actionType, setActionType] = useState<'approve' | 'reject' | 'revision' | null>(null);
|
||||||
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
|
const [showPreviousProposal, setShowPreviousProposal] = useState(false);
|
||||||
|
|
||||||
|
// Calculate total budget (needed for display)
|
||||||
|
const totalBudget = useMemo(() => {
|
||||||
|
if (!proposalData?.costBreakup) return 0;
|
||||||
|
|
||||||
|
// Ensure costBreakup is an array
|
||||||
|
const costBreakup = Array.isArray(proposalData.costBreakup)
|
||||||
|
? proposalData.costBreakup
|
||||||
|
: (typeof proposalData.costBreakup === 'string'
|
||||||
|
? JSON.parse(proposalData.costBreakup)
|
||||||
|
: []);
|
||||||
|
|
||||||
|
if (!Array.isArray(costBreakup)) return 0;
|
||||||
|
|
||||||
|
return costBreakup.reduce((sum: number, item: any) => {
|
||||||
|
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
||||||
|
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
|
||||||
|
const baseTotal = amount * quantity;
|
||||||
|
const gst = typeof item === 'object' ? (item.gstAmt || 0) : 0;
|
||||||
|
const total = item.totalAmt || (baseTotal + gst);
|
||||||
|
return sum + (Number(total) || 0);
|
||||||
|
}, 0);
|
||||||
|
}, [proposalData]);
|
||||||
|
|
||||||
|
// Calculate total base amount (needed for budget verification as requested)
|
||||||
|
// This is the taxable amount excluding GST
|
||||||
|
const totalBaseAmount = useMemo(() => {
|
||||||
|
if (!proposalData?.costBreakup) return 0;
|
||||||
|
|
||||||
|
const costBreakup = Array.isArray(proposalData.costBreakup)
|
||||||
|
? proposalData.costBreakup
|
||||||
|
: (typeof proposalData.costBreakup === 'string'
|
||||||
|
? JSON.parse(proposalData.costBreakup)
|
||||||
|
: []);
|
||||||
|
|
||||||
|
if (!Array.isArray(costBreakup)) return 0;
|
||||||
|
|
||||||
|
return costBreakup.reduce((sum: number, item: any) => {
|
||||||
|
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
||||||
|
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
|
||||||
|
return sum + (Number(amount) * Number(quantity));
|
||||||
|
}, 0);
|
||||||
|
}, [proposalData]);
|
||||||
|
|
||||||
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
|
// Check if IO is blocked (IO blocking moved to Requestor Evaluation level)
|
||||||
const internalOrder = request?.internalOrder || request?.internal_order;
|
// Sum up all successful blocks from internalOrders array
|
||||||
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
const totalBlockedAmount = useMemo(() => {
|
||||||
const isIOBlocked = ioBlockedAmount > 0;
|
const internalOrders = request?.internalOrders || request?.internal_orders || [];
|
||||||
|
|
||||||
|
// If we have an array, sum the blocked amounts
|
||||||
|
if (Array.isArray(internalOrders) && internalOrders.length > 0) {
|
||||||
|
return internalOrders.reduce((sum: number, io: any) => {
|
||||||
|
const amt = Number(io.ioBlockedAmount || io.io_blocked_amount || 0);
|
||||||
|
return sum + amt;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to single internalOrder object for backward compatibility
|
||||||
|
const singleIO = request?.internalOrder || request?.internal_order;
|
||||||
|
return Number(singleIO?.ioBlockedAmount || singleIO?.io_blocked_amount || 0);
|
||||||
|
}, [request?.internalOrders, request?.internal_orders, request?.internalOrder, request?.internal_order]);
|
||||||
|
|
||||||
|
// Budget is considered blocked only if the total blocked amount matches or exceeds the proposed base amount
|
||||||
|
// Allow a small margin for floating point comparison if needed, but here simple >= should suffice
|
||||||
|
const isIOBlocked = totalBlockedAmount >= (totalBaseAmount - 0.01);
|
||||||
|
const remainingBaseToBlock = Math.max(0, totalBaseAmount - totalBlockedAmount);
|
||||||
|
|
||||||
const [previewDoc, setPreviewDoc] = useState<{
|
const [previewDoc, setPreviewDoc] = useState<{
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
@ -102,25 +171,6 @@ export function InitiatorProposalApprovalModal({
|
|||||||
id?: string;
|
id?: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Calculate total budget
|
|
||||||
const totalBudget = useMemo(() => {
|
|
||||||
if (!proposalData?.costBreakup) return 0;
|
|
||||||
|
|
||||||
// Ensure costBreakup is an array
|
|
||||||
const costBreakup = Array.isArray(proposalData.costBreakup)
|
|
||||||
? proposalData.costBreakup
|
|
||||||
: (typeof proposalData.costBreakup === 'string'
|
|
||||||
? JSON.parse(proposalData.costBreakup)
|
|
||||||
: []);
|
|
||||||
|
|
||||||
if (!Array.isArray(costBreakup)) return 0;
|
|
||||||
|
|
||||||
return costBreakup.reduce((sum: number, item: any) => {
|
|
||||||
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
|
||||||
return sum + (Number(amount) || 0);
|
|
||||||
}, 0);
|
|
||||||
}, [proposalData]);
|
|
||||||
|
|
||||||
// Format date
|
// Format date
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
if (!dateString) return '—';
|
if (!dateString) return '—';
|
||||||
@ -141,11 +191,11 @@ export function InitiatorProposalApprovalModal({
|
|||||||
if (!doc.name) return false;
|
if (!doc.name) return false;
|
||||||
const name = doc.name.toLowerCase();
|
const name = doc.name.toLowerCase();
|
||||||
return name.endsWith('.pdf') ||
|
return name.endsWith('.pdf') ||
|
||||||
name.endsWith('.jpg') ||
|
name.endsWith('.jpg') ||
|
||||||
name.endsWith('.jpeg') ||
|
name.endsWith('.jpeg') ||
|
||||||
name.endsWith('.png') ||
|
name.endsWith('.png') ||
|
||||||
name.endsWith('.gif') ||
|
name.endsWith('.gif') ||
|
||||||
name.endsWith('.webp');
|
name.endsWith('.webp');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle document preview - leverage FilePreview's internal fetching
|
// Handle document preview - leverage FilePreview's internal fetching
|
||||||
@ -273,9 +323,16 @@ export function InitiatorProposalApprovalModal({
|
|||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
|
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col">
|
||||||
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
|
<DialogHeader className="flex-shrink-0 pb-3 lg:pb-4 px-6 pt-4 lg:pt-6 border-b">
|
||||||
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
|
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl flex-wrap">
|
||||||
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
|
<div className="flex items-center gap-2">
|
||||||
Requestor Evaluation & Confirmation
|
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
|
||||||
|
Requestor Evaluation & Confirmation
|
||||||
|
</div>
|
||||||
|
{taxationType && (
|
||||||
|
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
||||||
|
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-xs lg:text-sm">
|
<DialogDescription className="text-xs lg:text-sm">
|
||||||
Step 2: Review dealer proposal and make a decision
|
Step 2: Review dealer proposal and make a decision
|
||||||
@ -296,11 +353,11 @@ export function InitiatorProposalApprovalModal({
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden min-h-0 py-3 lg:py-4 px-6">
|
||||||
|
|
||||||
{/* Previous Proposal Reference Section */}
|
{/* Previous Proposal Reference Section */}
|
||||||
{previousProposalData && (
|
{previousProposalData && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div
|
<div
|
||||||
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
|
className="bg-amber-50 border border-amber-200 rounded-lg overflow-hidden cursor-pointer hover:bg-amber-100/50 transition-colors"
|
||||||
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
|
onClick={() => setShowPreviousProposal(!showPreviousProposal)}
|
||||||
>
|
>
|
||||||
@ -316,48 +373,48 @@ export function InitiatorProposalApprovalModal({
|
|||||||
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
{showPreviousProposal ? <Minus className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showPreviousProposal && (
|
{showPreviousProposal && (
|
||||||
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
|
<div className="px-4 pb-4 border-t border-amber-200 space-y-4 bg-white/50">
|
||||||
{/* Header Info: Date & Document */}
|
{/* Header Info: Date & Document */}
|
||||||
<div className="flex flex-wrap gap-4 text-xs mt-3">
|
<div className="flex flex-wrap gap-4 text-xs mt-3">
|
||||||
{previousProposalData.expectedCompletionDate && (
|
{previousProposalData.expectedCompletionDate && (
|
||||||
<div className="flex items-center gap-1.5 text-gray-700">
|
<div className="flex items-center gap-1.5 text-gray-700">
|
||||||
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
<Calendar className="w-3.5 h-3.5 text-gray-500" />
|
||||||
<span className="font-medium">Expected Completion:</span>
|
<span className="font-medium">Expected Completion:</span>
|
||||||
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
|
<span>{new Date(previousProposalData.expectedCompletionDate).toLocaleDateString('en-IN')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{previousProposalData.documentUrl && (
|
{previousProposalData.documentUrl && (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
|
{canPreviewDocument({ name: previousProposalData.documentUrl }) ? (
|
||||||
<>
|
<>
|
||||||
<Eye className="w-3.5 h-3.5 text-blue-500" />
|
<Eye className="w-3.5 h-3.5 text-blue-500" />
|
||||||
<a
|
<a
|
||||||
href={previousProposalData.documentUrl}
|
href={previousProposalData.documentUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
||||||
>
|
>
|
||||||
View Previous Document
|
View Previous Document
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Download className="w-3.5 h-3.5 text-blue-500" />
|
<Download className="w-3.5 h-3.5 text-blue-500" />
|
||||||
<a
|
<a
|
||||||
href={previousProposalData.documentUrl}
|
href={previousProposalData.documentUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
className="text-blue-600 hover:underline font-medium flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Download Previous Document
|
Download Previous Document
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost Breakdown */}
|
{/* Cost Breakdown */}
|
||||||
@ -395,43 +452,43 @@ export function InitiatorProposalApprovalModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Additional/Supporting Documents */}
|
{/* Additional/Supporting Documents */}
|
||||||
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
|
{previousProposalData.otherDocuments && previousProposalData.otherDocuments.length > 0 && (
|
||||||
<div className="w-full pt-2 border-t border-amber-200/50">
|
<div className="w-full pt-2 border-t border-amber-200/50">
|
||||||
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
|
<p className="text-[10px] font-semibold text-gray-700 mb-1.5 flex items-center gap-1">
|
||||||
<FileText className="w-3 h-3" />
|
<FileText className="w-3 h-3" />
|
||||||
Supporting Documents
|
Supporting Documents
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||||
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
|
{previousProposalData.otherDocuments.map((doc: any, idx: number) => (
|
||||||
<DocumentCard
|
<DocumentCard
|
||||||
key={idx}
|
key={idx}
|
||||||
document={{
|
document={{
|
||||||
documentId: doc.documentId || doc.id || '',
|
documentId: doc.documentId || doc.id || '',
|
||||||
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
|
name: doc.originalFileName || doc.fileName || doc.name || 'Supporting Document',
|
||||||
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
|
fileType: (doc.originalFileName || doc.fileName || doc.name || '').split('.').pop() || 'file',
|
||||||
uploadedAt: doc.uploadedAt || new Date().toISOString()
|
uploadedAt: doc.uploadedAt || new Date().toISOString()
|
||||||
}}
|
}}
|
||||||
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
|
onPreview={canPreviewDocument({ name: doc.originalFileName || doc.fileName || doc.name || '' }) ? () => handlePreviewDocument(doc) : undefined}
|
||||||
onDownload={async (id) => {
|
onDownload={async (id) => {
|
||||||
if (id) {
|
if (id) {
|
||||||
await downloadDocument(id);
|
await downloadDocument(id);
|
||||||
} else {
|
} else {
|
||||||
let downloadUrl = doc.storageUrl || doc.documentUrl;
|
let downloadUrl = doc.storageUrl || doc.documentUrl;
|
||||||
if (downloadUrl && !downloadUrl.startsWith('http')) {
|
if (downloadUrl && !downloadUrl.startsWith('http')) {
|
||||||
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
const baseUrl = import.meta.env.VITE_BASE_URL || '';
|
||||||
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
|
||||||
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
|
||||||
}
|
}
|
||||||
if (downloadUrl) window.open(downloadUrl, '_blank');
|
if (downloadUrl) window.open(downloadUrl, '_blank');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
@ -453,247 +510,273 @@ export function InitiatorProposalApprovalModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
|
<div className="space-y-4 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6 lg:items-start lg:content-start">
|
||||||
{/* Left Column - Documents */}
|
{/* Left Column - Documents */}
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
||||||
{/* Proposal Document Section */}
|
{/* Proposal Document Section */}
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-blue-600" />
|
|
||||||
Proposal Document
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{proposalData?.proposalDocument ? (
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
|
|
||||||
{proposalData.proposalDocument.name}
|
|
||||||
</p>
|
|
||||||
{proposalData?.submittedAt && (
|
|
||||||
<p className="text-xs text-gray-500 truncate">
|
|
||||||
Submitted on {formatDate(proposalData.submittedAt)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
|
||||||
{proposalData.proposalDocument.id && (
|
|
||||||
<>
|
|
||||||
{canPreviewDocument(proposalData.proposalDocument) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
<Eye className="w-5 h-5 text-blue-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (proposalData.proposalDocument?.id) {
|
|
||||||
await downloadDocument(proposalData.proposalDocument.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-500 italic">No proposal document available</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other Supporting Documents */}
|
|
||||||
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4 text-gray-600" />
|
|
||||||
Other Supporting Documents
|
|
||||||
</h3>
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
{proposalData.otherDocuments.length} file(s)
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
|
||||||
{proposalData.otherDocuments.map((doc, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
|
||||||
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
|
|
||||||
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
|
|
||||||
{doc.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{doc.id && (
|
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
{canPreviewDocument(doc) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handlePreviewDocument(doc)}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Preview document"
|
|
||||||
>
|
|
||||||
<Eye className="w-5 h-5 text-blue-600" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
if (doc.id) {
|
|
||||||
await downloadDocument(doc.id);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to download document:', error);
|
|
||||||
toast.error('Failed to download document');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<Download className="w-5 h-5 text-gray-600" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Column - Planning & Details */}
|
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
|
||||||
{/* Cost Breakup Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<IndianRupee className="w-4 h-4 text-green-600" />
|
|
||||||
Cost Breakup
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
// Ensure costBreakup is an array
|
|
||||||
const costBreakup = proposalData?.costBreakup
|
|
||||||
? (Array.isArray(proposalData.costBreakup)
|
|
||||||
? proposalData.costBreakup
|
|
||||||
: (typeof proposalData.costBreakup === 'string'
|
|
||||||
? JSON.parse(proposalData.costBreakup)
|
|
||||||
: []))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
|
|
||||||
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
|
|
||||||
<div>Item Description</div>
|
|
||||||
<div className="text-right">Amount</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y">
|
|
||||||
{costBreakup.map((item: any, index: number) => (
|
|
||||||
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
|
|
||||||
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
|
|
||||||
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
|
|
||||||
₹{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<IndianRupee className="w-4 h-4 text-[--re-green]" />
|
|
||||||
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
|
|
||||||
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timeline Section */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-purple-600" />
|
|
||||||
Expected Completion Date
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
|
|
||||||
<p className="text-sm lg:text-base font-semibold text-gray-900">
|
|
||||||
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments Section - Side by Side */}
|
|
||||||
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
|
||||||
{/* Dealer Comments */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
||||||
<MessageSquare className="w-4 h-4 text-blue-600" />
|
<FileText className="w-4 h-4 text-blue-600" />
|
||||||
Dealer Comments
|
Proposal Document
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
{proposalData?.proposalDocument ? (
|
||||||
<p className="text-xs text-gray-700 whitespace-pre-wrap">
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
||||||
{proposalData?.dealerComments || 'No comments provided'}
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
||||||
|
<FileText className="w-5 h-5 lg:w-6 lg:h-6 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
|
||||||
|
{proposalData.proposalDocument.name}
|
||||||
|
</p>
|
||||||
|
{proposalData?.submittedAt && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">
|
||||||
|
Submitted on {formatDate(proposalData.submittedAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{proposalData.proposalDocument.id && (
|
||||||
|
<>
|
||||||
|
{canPreviewDocument(proposalData.proposalDocument) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreviewDocument(proposalData.proposalDocument!)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
title="Preview document"
|
||||||
|
>
|
||||||
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (proposalData.proposalDocument?.id) {
|
||||||
|
await downloadDocument(proposalData.proposalDocument.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download document:', error);
|
||||||
|
toast.error('Failed to download document');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 italic">No proposal document available</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Other Supporting Documents */}
|
||||||
|
{proposalData?.otherDocuments && proposalData.otherDocuments.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-gray-600" />
|
||||||
|
Other Supporting Documents
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{proposalData.otherDocuments.length} file(s)
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
||||||
|
{proposalData.otherDocuments.map((doc, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="border rounded-lg p-2 lg:p-3 bg-gray-50 flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 lg:gap-3 min-w-0 flex-1">
|
||||||
|
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
|
||||||
|
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
|
||||||
|
{doc.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{doc.id && (
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{canPreviewDocument(doc) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePreviewDocument(doc)}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
title="Preview document"
|
||||||
|
>
|
||||||
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (doc.id) {
|
||||||
|
await downloadDocument(doc.id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to download document:', error);
|
||||||
|
toast.error('Failed to download document');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
title="Download document"
|
||||||
|
>
|
||||||
|
<Download className="w-5 h-5 text-gray-600" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Planning & Details */}
|
||||||
|
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
||||||
|
{/* Cost Breakup Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
||||||
|
<IndianRupee className="w-4 h-4 text-green-600" />
|
||||||
|
Cost Breakup
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
{(() => {
|
||||||
|
// Ensure costBreakup is an array
|
||||||
|
const costBreakup = proposalData?.costBreakup
|
||||||
|
? (Array.isArray(proposalData.costBreakup)
|
||||||
|
? proposalData.costBreakup
|
||||||
|
: (typeof proposalData.costBreakup === 'string'
|
||||||
|
? JSON.parse(proposalData.costBreakup)
|
||||||
|
: []))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="border rounded-lg overflow-hidden max-h-[200px] lg:max-h-[180px] overflow-y-auto">
|
||||||
|
<div className="bg-gray-50 px-3 lg:px-4 py-2 border-b sticky top-0">
|
||||||
|
<div className={`grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4 text-xs lg:text-sm font-semibold text-gray-700`}>
|
||||||
|
<div className="col-span-1">Item Description</div>
|
||||||
|
<div className="text-right">Base</div>
|
||||||
|
{!isNonGst && <div className="text-right">GST</div>}
|
||||||
|
<div className="text-right">Total</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{costBreakup.map((item: any, index: number) => (
|
||||||
|
<div key={item?.id || item?.description || index} className={`px-3 lg:px-4 py-2 lg:py-3 grid ${isNonGst ? 'grid-cols-3' : 'grid-cols-4'} gap-4`}>
|
||||||
|
<div className="col-span-1 text-xs lg:text-sm text-gray-700">
|
||||||
|
<div className="flex items-center gap-1.5 mb-0.5">
|
||||||
|
<span className="font-medium">
|
||||||
|
{item?.description?.startsWith('[ADDITIONAL]')
|
||||||
|
? item.description.replace('[ADDITIONAL]', '').trim()
|
||||||
|
: (item?.description || 'N/A')}
|
||||||
|
</span>
|
||||||
|
{costBreakup.some((i: any) => i?.description?.startsWith('[ADDITIONAL]')) && (
|
||||||
|
item?.description?.startsWith('[ADDITIONAL]') ? (
|
||||||
|
<Badge className="text-[9px] h-3.5 px-1 bg-amber-100 text-amber-700 hover:bg-amber-100 border-none leading-none">ADDITIONAL</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge className="text-[9px] h-3.5 px-1 bg-gray-100 text-gray-600 hover:bg-gray-100 border-none leading-none">ORIGINAL</Badge>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!isNonGst && item?.gstRate ? <span className="block text-[10px] text-gray-400">{item.gstRate}% GST</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs lg:text-sm text-gray-900 text-right">
|
||||||
|
₹{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
{!isNonGst && (
|
||||||
|
<div className="text-xs lg:text-sm text-gray-900 text-right">
|
||||||
|
₹{(Number(item?.gstAmt) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
|
||||||
|
₹{(Number(item?.totalAmt || ((item?.amount || 0) * (item?.quantity || 1) + (item?.gstAmt || 0))) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IndianRupee className="w-4 h-4 text-[--re-green]" />
|
||||||
|
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
|
||||||
|
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4 text-purple-600" />
|
||||||
|
Expected Completion Date
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
|
||||||
|
<p className="text-sm lg:text-base font-semibold text-gray-900">
|
||||||
|
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Your Decision & Comments */}
|
{/* Comments Section - Side by Side */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
|
||||||
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
||||||
<Textarea
|
{/* Dealer Comments */}
|
||||||
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
|
<div className="space-y-2">
|
||||||
value={comments}
|
<div className="flex items-center gap-2">
|
||||||
onChange={(e) => setComments(e.target.value)}
|
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
||||||
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
|
<MessageSquare className="w-4 h-4 text-blue-600" />
|
||||||
/>
|
Dealer Comments
|
||||||
<p className="text-xs text-gray-500">{comments.length} characters</p>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[150px] lg:max-h-[140px] overflow-y-auto">
|
||||||
|
<p className="text-xs text-gray-700 whitespace-pre-wrap">
|
||||||
|
{proposalData?.dealerComments || 'No comments provided'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Your Decision & Comments */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
|
||||||
|
value={comments}
|
||||||
|
onChange={(e) => setComments(e.target.value)}
|
||||||
|
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">{comments.length} characters</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warning for missing comments */}
|
{/* Warning for missing comments */}
|
||||||
{!comments.trim() && (
|
{!comments.trim() && (
|
||||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2 lg:col-span-2">
|
||||||
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
|
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-amber-800">
|
<p className="text-xs text-amber-800">
|
||||||
Please provide comments before making a decision. Comments are required and will be visible to all participants.
|
Please provide comments before making a decision. Comments are required and will be visible to all participants.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -756,8 +839,10 @@ export function InitiatorProposalApprovalModal({
|
|||||||
</div>
|
</div>
|
||||||
{/* Warning for IO not blocked - shown below Approve button */}
|
{/* Warning for IO not blocked - shown below Approve button */}
|
||||||
{!isIOBlocked && (
|
{!isIOBlocked && (
|
||||||
<p className="text-xs text-red-600 text-center sm:text-left">
|
<p className="text-xs text-red-600 text-center sm:text-left font-medium">
|
||||||
Please block IO budget in the IO Tab before approving
|
{totalBlockedAmount > 0
|
||||||
|
? `Pending block: ₹${remainingBaseToBlock.toLocaleString('en-IN', { minimumFractionDigits: 2 })} more needs to be blocked in the IO Tab.`
|
||||||
|
: "Please block IO budget in the IO Tab before approving."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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,10 +153,14 @@ 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
|
// Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin)
|
||||||
const showIOTab = isInitiator;
|
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer';
|
||||||
|
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
|
||||||
|
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
|
||||||
|
request?.templateType === 'claim-management';
|
||||||
|
const showIOTab = isClaimManagement && !isDealer;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mergedMessages,
|
mergedMessages,
|
||||||
@ -177,7 +181,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 +224,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 +339,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 +380,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 +431,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 +464,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 +602,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 +677,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)}
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { handleSecurityError } from '@/utils/securityToast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Hook: useDocumentUpload
|
* Custom Hook: useDocumentUpload
|
||||||
@ -26,7 +27,7 @@ export function useDocumentUpload(
|
|||||||
) {
|
) {
|
||||||
// State: Indicates if document is currently being uploaded
|
// State: Indicates if document is currently being uploaded
|
||||||
const [uploadingDocument, setUploadingDocument] = useState(false);
|
const [uploadingDocument, setUploadingDocument] = useState(false);
|
||||||
|
|
||||||
// State: Stores document for preview modal
|
// State: Stores document for preview modal
|
||||||
const [previewDocument, setPreviewDocument] = useState<{
|
const [previewDocument, setPreviewDocument] = useState<{
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -101,7 +102,7 @@ export function useDocumentUpload(
|
|||||||
// Check file extension
|
// Check file extension
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -130,12 +131,12 @@ export function useDocumentUpload(
|
|||||||
*/
|
*/
|
||||||
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
|
|
||||||
// Validate: Check if file is selected
|
// Validate: Check if file is selected
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
// Validate all files against document policy
|
// Validate all files against document policy
|
||||||
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
@ -169,11 +170,11 @@ export function useDocumentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUploadingDocument(true);
|
setUploadingDocument(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload only the first valid file (backend currently supports single file)
|
// Upload only the first valid file (backend currently supports single file)
|
||||||
const file = validFiles[0];
|
const file = validFiles[0];
|
||||||
|
|
||||||
// Validate: Ensure request ID is available
|
// Validate: Ensure request ID is available
|
||||||
// Note: Backend requires UUID, not request number
|
// Note: Backend requires UUID, not request number
|
||||||
const requestId = apiRequest?.requestId;
|
const requestId = apiRequest?.requestId;
|
||||||
@ -181,17 +182,17 @@ export function useDocumentUpload(
|
|||||||
toast.error('Request ID not found');
|
toast.error('Request ID not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Call: Upload document to backend
|
// API Call: Upload document to backend
|
||||||
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
|
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
|
||||||
if (file) {
|
if (file) {
|
||||||
await uploadDocument(file, requestId, 'SUPPORTING');
|
await uploadDocument(file, requestId, 'SUPPORTING');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh: Reload request details to show newly uploaded document
|
// Refresh: Reload request details to show newly uploaded document
|
||||||
// This also updates the activity timeline
|
// This also updates the activity timeline
|
||||||
await refreshDetails();
|
await refreshDetails();
|
||||||
|
|
||||||
// Success feedback
|
// Success feedback
|
||||||
if (validFiles.length < fileArray.length) {
|
if (validFiles.length < fileArray.length) {
|
||||||
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
||||||
@ -200,12 +201,14 @@ export function useDocumentUpload(
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[useDocumentUpload] Upload error:', error);
|
console.error('[useDocumentUpload] Upload error:', error);
|
||||||
|
|
||||||
// Error feedback with backend error message if available
|
// Show security-specific red toast for scan errors, or generic error toast
|
||||||
toast.error(error?.response?.data?.error || 'Failed to upload document');
|
if (!handleSecurityError(error)) {
|
||||||
|
toast.error(error?.response?.data?.message || 'Failed to upload document');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDocument(false);
|
setUploadingDocument(false);
|
||||||
|
|
||||||
// Cleanup: Clear the file input to allow re-uploading same file
|
// Cleanup: Clear the file input to allow re-uploading same file
|
||||||
if (event.target) {
|
if (event.target) {
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
||||||
import apiClient from '@/services/authApi';
|
import apiClient from '@/services/authApi';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
|
||||||
import { getSocket } from '@/utils/socket';
|
import { getSocket } from '@/utils/socket';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,19 +28,19 @@ export function useRequestDetails(
|
|||||||
) {
|
) {
|
||||||
// State: Stores the fetched and transformed request data
|
// State: Stores the fetched and transformed request data
|
||||||
const [apiRequest, setApiRequest] = useState<any | null>(null);
|
const [apiRequest, setApiRequest] = useState<any | null>(null);
|
||||||
|
|
||||||
// State: Indicates if data is currently being fetched
|
// State: Indicates if data is currently being fetched
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
// State: Loading state for initial fetch
|
// State: Loading state for initial fetch
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// State: Access denied information
|
// State: Access denied information
|
||||||
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
|
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
// State: Stores the current approval level for the logged-in user
|
// State: Stores the current approval level for the logged-in user
|
||||||
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
|
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
|
||||||
|
|
||||||
// State: Indicates if the current user is a spectator (view-only access)
|
// State: Indicates if the current user is a spectator (view-only access)
|
||||||
const [isSpectator, setIsSpectator] = useState(false);
|
const [isSpectator, setIsSpectator] = useState(false);
|
||||||
|
|
||||||
@ -103,14 +101,14 @@ export function useRequestDetails(
|
|||||||
const documents = Array.isArray(details.documents) ? details.documents : [];
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
// Debug: Log TAT alerts for monitoring
|
// Debug: Log TAT alerts for monitoring
|
||||||
if (tatAlerts.length > 0) {
|
if (tatAlerts.length > 0) {
|
||||||
// TAT alerts loaded - logging removed
|
// TAT alerts loaded - logging removed
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform: Map approval levels to UI format with TAT alerts
|
* Transform: Map approval levels to UI format with TAT alerts
|
||||||
* Each approval level includes:
|
* Each approval level includes:
|
||||||
@ -123,10 +121,10 @@ export function useRequestDetails(
|
|||||||
const levelNumber = a.levelNumber || 0;
|
const levelNumber = a.levelNumber || 0;
|
||||||
const levelStatus = (a.status || '').toString().toUpperCase();
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
const levelId = a.levelId || a.level_id;
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
// Determine display status based on workflow progress
|
// Determine display status based on workflow progress
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
// Future levels that haven't been reached yet show as "waiting"
|
// Future levels that haven't been reached yet show as "waiting"
|
||||||
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
||||||
displayStatus = 'waiting';
|
displayStatus = 'waiting';
|
||||||
@ -135,10 +133,10 @@ export function useRequestDetails(
|
|||||||
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
||||||
displayStatus = 'pending';
|
displayStatus = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter: Get TAT alerts that belong to this specific approval level
|
// Filter: Get TAT alerts that belong to this specific approval level
|
||||||
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: levelNumber,
|
step: levelNumber,
|
||||||
levelId,
|
levelId,
|
||||||
@ -152,8 +150,8 @@ export function useRequestDetails(
|
|||||||
remainingHours: Number(a.remainingHours || 0),
|
remainingHours: Number(a.remainingHours || 0),
|
||||||
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
// Calculate actual hours taken if level is completed
|
// Calculate actual hours taken if level is completed
|
||||||
actualHours: a.levelEndTime && a.levelStartTime
|
actualHours: a.levelEndTime && a.levelStartTime
|
||||||
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
|
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
|
||||||
: undefined,
|
: undefined,
|
||||||
comment: a.comments || undefined,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
@ -211,11 +209,11 @@ export function useRequestDetails(
|
|||||||
* Filter: Remove TAT breach activities from audit trail
|
* Filter: Remove TAT breach activities from audit trail
|
||||||
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
|
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
|
||||||
*/
|
*/
|
||||||
const filteredActivities = Array.isArray(details.activities)
|
const filteredActivities = Array.isArray(details.activities)
|
||||||
? details.activities.filter((activity: any) => {
|
? details.activities.filter((activity: any) => {
|
||||||
const activityType = (activity.type || '').toLowerCase();
|
const activityType = (activity.type || '').toLowerCase();
|
||||||
return activityType !== 'sla_warning';
|
return activityType !== 'sla_warning';
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -224,7 +222,7 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
const isPaused = (wf as any).isPaused || false;
|
const isPaused = (wf as any).isPaused || false;
|
||||||
|
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
try {
|
try {
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
@ -240,24 +238,26 @@ export function useRequestDetails(
|
|||||||
let proposalDetails = null;
|
let proposalDetails = null;
|
||||||
let completionDetails = null;
|
let completionDetails = null;
|
||||||
let internalOrder = null;
|
let internalOrder = null;
|
||||||
|
let internalOrders = [];
|
||||||
|
|
||||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
||||||
try {
|
try {
|
||||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
||||||
|
|
||||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
const claimData = claimResponse.data?.data || claimResponse.data;
|
||||||
|
|
||||||
if (claimData) {
|
if (claimData) {
|
||||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
claimDetails = claimData.claimDetails || claimData.claim_details;
|
||||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
||||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
completionDetails = claimData.completionDetails || claimData.completion_details;
|
||||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
||||||
|
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
|
||||||
// New normalized tables
|
// New normalized tables
|
||||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
||||||
const invoice = claimData.invoice || null;
|
const invoice = claimData.invoice || null;
|
||||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
||||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
||||||
|
|
||||||
// Store new fields in claimDetails for backward compatibility and easy access
|
// Store new fields in claimDetails for backward compatibility and easy access
|
||||||
if (claimDetails) {
|
if (claimDetails) {
|
||||||
(claimDetails as any).budgetTracking = budgetTracking;
|
(claimDetails as any).budgetTracking = budgetTracking;
|
||||||
@ -265,7 +265,7 @@ export function useRequestDetails(
|
|||||||
(claimDetails as any).creditNote = creditNote;
|
(claimDetails as any).creditNote = creditNote;
|
||||||
(claimDetails as any).completionExpenses = completionExpenses;
|
(claimDetails as any).completionExpenses = completionExpenses;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extracted details processed
|
// Extracted details processed
|
||||||
} else {
|
} else {
|
||||||
console.warn('[useRequestDetails] No claimData found in response');
|
console.warn('[useRequestDetails] No claimData found in response');
|
||||||
@ -328,13 +328,14 @@ export function useRequestDetails(
|
|||||||
proposalDetails: proposalDetails || null,
|
proposalDetails: proposalDetails || null,
|
||||||
completionDetails: completionDetails || null,
|
completionDetails: completionDetails || null,
|
||||||
internalOrder: internalOrder || null,
|
internalOrder: internalOrder || null,
|
||||||
|
internalOrders: internalOrders || [],
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||||
invoice: (claimDetails as any)?.invoice || null,
|
invoice: (claimDetails as any)?.invoice || null,
|
||||||
creditNote: (claimDetails as any)?.creditNote || null,
|
creditNote: (claimDetails as any)?.creditNote || null,
|
||||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(updatedRequest);
|
setApiRequest(updatedRequest);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -352,8 +353,8 @@ export function useRequestDetails(
|
|||||||
const approvalLevelNumber = a.levelNumber || 0;
|
const approvalLevelNumber = a.levelNumber || 0;
|
||||||
// Only show buttons if user is assigned to the CURRENT active level
|
// Only show buttons if user is assigned to the CURRENT active level
|
||||||
// Include PAUSED status - paused level is still the current level
|
// Include PAUSED status - paused level is still the current level
|
||||||
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
|
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
|
||||||
&& approverEmail === userEmail
|
&& approverEmail === userEmail
|
||||||
&& approvalLevelNumber === currentLevel;
|
&& approvalLevelNumber === currentLevel;
|
||||||
});
|
});
|
||||||
setCurrentApprovalLevel(newCurrentLevel || null);
|
setCurrentApprovalLevel(newCurrentLevel || null);
|
||||||
@ -364,8 +365,8 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
const viewerId = (user as any)?.userId;
|
const viewerId = (user as any)?.userId;
|
||||||
if (viewerId) {
|
if (viewerId) {
|
||||||
const isSpec = participants.some((p: any) =>
|
const isSpec = participants.some((p: any) =>
|
||||||
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
|
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
|
||||||
(p.userId || p.user_id) === viewerId
|
(p.userId || p.user_id) === viewerId
|
||||||
);
|
);
|
||||||
setIsSpectator(isSpec);
|
setIsSpectator(isSpec);
|
||||||
@ -389,11 +390,11 @@ export function useRequestDetails(
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setAccessDenied(null);
|
setAccessDenied(null);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
@ -401,7 +402,7 @@ export function useRequestDetails(
|
|||||||
if (mounted) setLoading(false);
|
if (mounted) setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the same transformation logic as refreshDetails
|
// Use the same transformation logic as refreshDetails
|
||||||
const wf = details.workflow || {};
|
const wf = details.workflow || {};
|
||||||
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
||||||
@ -409,7 +410,7 @@ export function useRequestDetails(
|
|||||||
const documents = Array.isArray(details.documents) ? details.documents : [];
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
// TAT alerts received - logging removed
|
// TAT alerts received - logging removed
|
||||||
|
|
||||||
const priority = (wf.priority || '').toString().toLowerCase();
|
const priority = (wf.priority || '').toString().toLowerCase();
|
||||||
@ -420,9 +421,9 @@ export function useRequestDetails(
|
|||||||
const levelNumber = a.levelNumber || 0;
|
const levelNumber = a.levelNumber || 0;
|
||||||
const levelStatus = (a.status || '').toString().toUpperCase();
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
const levelId = a.levelId || a.level_id;
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
// If paused, show paused status (don't change it)
|
// If paused, show paused status (don't change it)
|
||||||
if (levelStatus === 'PAUSED') {
|
if (levelStatus === 'PAUSED') {
|
||||||
displayStatus = 'paused';
|
displayStatus = 'paused';
|
||||||
@ -431,9 +432,9 @@ export function useRequestDetails(
|
|||||||
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
|
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
|
||||||
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
|
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: levelNumber,
|
step: levelNumber,
|
||||||
levelId,
|
levelId,
|
||||||
@ -448,8 +449,8 @@ export function useRequestDetails(
|
|||||||
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
// Use backend-calculated elapsedHours (working hours) for completed approvals
|
// Use backend-calculated elapsedHours (working hours) for completed approvals
|
||||||
// Backend already calculates this correctly using calculateElapsedWorkingHours
|
// Backend already calculates this correctly using calculateElapsedWorkingHours
|
||||||
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
|
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
|
||||||
? Number(a.elapsedHours)
|
? Number(a.elapsedHours)
|
||||||
: undefined,
|
: undefined,
|
||||||
comment: a.comments || undefined,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
@ -457,7 +458,7 @@ export function useRequestDetails(
|
|||||||
tatAlerts: levelAlerts,
|
tatAlerts: levelAlerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map spectators
|
// Map spectators
|
||||||
const spectators = participants
|
const spectators = participants
|
||||||
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
|
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
|
||||||
@ -492,18 +493,18 @@ export function useRequestDetails(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Filter out TAT warnings from activities
|
// Filter out TAT warnings from activities
|
||||||
const filteredActivities = Array.isArray(details.activities)
|
const filteredActivities = Array.isArray(details.activities)
|
||||||
? details.activities.filter((activity: any) => {
|
? details.activities.filter((activity: any) => {
|
||||||
const activityType = (activity.type || '').toLowerCase();
|
const activityType = (activity.type || '').toLowerCase();
|
||||||
return activityType !== 'sla_warning';
|
return activityType !== 'sla_warning';
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Fetch pause details only if request is actually paused
|
// Fetch pause details only if request is actually paused
|
||||||
// Use request-level isPaused field from workflow response
|
// Use request-level isPaused field from workflow response
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
const isPaused = (wf as any).isPaused || false;
|
const isPaused = (wf as any).isPaused || false;
|
||||||
|
|
||||||
if (isPaused) {
|
if (isPaused) {
|
||||||
try {
|
try {
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
@ -519,23 +520,25 @@ export function useRequestDetails(
|
|||||||
let proposalDetails = null;
|
let proposalDetails = null;
|
||||||
let completionDetails = null;
|
let completionDetails = null;
|
||||||
let internalOrder = null;
|
let internalOrder = null;
|
||||||
|
let internalOrders = [];
|
||||||
|
|
||||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
||||||
try {
|
try {
|
||||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
||||||
|
|
||||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
const claimData = claimResponse.data?.data || claimResponse.data;
|
||||||
if (claimData) {
|
if (claimData) {
|
||||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
claimDetails = claimData.claimDetails || claimData.claim_details;
|
||||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
||||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
completionDetails = claimData.completionDetails || claimData.completion_details;
|
||||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
||||||
|
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
|
||||||
// New normalized tables
|
// New normalized tables
|
||||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
||||||
const invoice = claimData.invoice || null;
|
const invoice = claimData.invoice || null;
|
||||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
||||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
||||||
|
|
||||||
// Store new fields in claimDetails for backward compatibility and easy access
|
// Store new fields in claimDetails for backward compatibility and easy access
|
||||||
if (claimDetails) {
|
if (claimDetails) {
|
||||||
(claimDetails as any).budgetTracking = budgetTracking;
|
(claimDetails as any).budgetTracking = budgetTracking;
|
||||||
@ -543,7 +546,7 @@ export function useRequestDetails(
|
|||||||
(claimDetails as any).creditNote = creditNote;
|
(claimDetails as any).creditNote = creditNote;
|
||||||
(claimDetails as any).completionExpenses = completionExpenses;
|
(claimDetails as any).completionExpenses = completionExpenses;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initial load - Extracted details processed
|
// Initial load - Extracted details processed
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -593,15 +596,16 @@ export function useRequestDetails(
|
|||||||
proposalDetails: proposalDetails || null,
|
proposalDetails: proposalDetails || null,
|
||||||
completionDetails: completionDetails || null,
|
completionDetails: completionDetails || null,
|
||||||
internalOrder: internalOrder || null,
|
internalOrder: internalOrder || null,
|
||||||
|
internalOrders: internalOrders || [],
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||||
invoice: (claimDetails as any)?.invoice || null,
|
invoice: (claimDetails as any)?.invoice || null,
|
||||||
creditNote: (claimDetails as any)?.creditNote || null,
|
creditNote: (claimDetails as any)?.creditNote || null,
|
||||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(mapped);
|
setApiRequest(mapped);
|
||||||
|
|
||||||
// Find current user's approval level
|
// Find current user's approval level
|
||||||
// Only show approve/reject buttons if user is the CURRENT active approver
|
// Only show approve/reject buttons if user is the CURRENT active approver
|
||||||
// Include PAUSED status - when paused, the paused level is still the current level
|
// Include PAUSED status - when paused, the paused level is still the current level
|
||||||
@ -612,8 +616,8 @@ export function useRequestDetails(
|
|||||||
const approvalLevelNumber = a.levelNumber || 0;
|
const approvalLevelNumber = a.levelNumber || 0;
|
||||||
// Only show buttons if user is assigned to the CURRENT active level
|
// Only show buttons if user is assigned to the CURRENT active level
|
||||||
// Include PAUSED status - paused level is still the current level
|
// Include PAUSED status - paused level is still the current level
|
||||||
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
|
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
|
||||||
&& approverEmail === userEmail
|
&& approverEmail === userEmail
|
||||||
&& approvalLevelNumber === currentLevel;
|
&& approvalLevelNumber === currentLevel;
|
||||||
});
|
});
|
||||||
setCurrentApprovalLevel(userCurrentLevel || null);
|
setCurrentApprovalLevel(userCurrentLevel || null);
|
||||||
@ -621,7 +625,7 @@ export function useRequestDetails(
|
|||||||
// Check spectator status
|
// Check spectator status
|
||||||
const viewerId = (user as any)?.userId;
|
const viewerId = (user as any)?.userId;
|
||||||
if (viewerId) {
|
if (viewerId) {
|
||||||
const isSpec = participants.some((p: any) =>
|
const isSpec = participants.some((p: any) =>
|
||||||
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
|
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
|
||||||
);
|
);
|
||||||
setIsSpectator(isSpec);
|
setIsSpectator(isSpec);
|
||||||
@ -633,7 +637,7 @@ export function useRequestDetails(
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Check for 403 Forbidden (Access Denied)
|
// Check for 403 Forbidden (Access Denied)
|
||||||
if (error?.response?.status === 403) {
|
if (error?.response?.status === 403) {
|
||||||
const message = error?.response?.data?.message ||
|
const message = error?.response?.data?.message ||
|
||||||
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
|
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
|
||||||
setAccessDenied({ denied: true, message });
|
setAccessDenied({ denied: true, message });
|
||||||
}
|
}
|
||||||
@ -645,34 +649,26 @@ export function useRequestDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [requestIdentifier, user]);
|
}, [requestIdentifier, user]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed: Get final request object with fallback to static databases
|
* Computed: Get final request object with fallback to static databases
|
||||||
* Priority: API data → Custom DB → Claim DB → Dynamic props → null
|
* Priority: API data → Custom Database → Claim Database → Dynamic props → null
|
||||||
*/
|
*/
|
||||||
const request = useMemo(() => {
|
const request = useMemo(() => {
|
||||||
// Primary source: API data
|
// Primary source: API data
|
||||||
if (apiRequest) return apiRequest;
|
if (apiRequest) return apiRequest;
|
||||||
|
|
||||||
// Fallback 1: Static custom request database
|
// Fallback: Dynamic requests passed as props
|
||||||
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
|
const dynamicRequest = dynamicRequests.find((req: any) =>
|
||||||
if (customRequest) return customRequest;
|
req.id === requestIdentifier ||
|
||||||
|
|
||||||
// Fallback 2: Static claim management database
|
|
||||||
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
|
|
||||||
if (claimRequest) return claimRequest;
|
|
||||||
|
|
||||||
// Fallback 3: Dynamic requests passed as props
|
|
||||||
const dynamicRequest = dynamicRequests.find((req: any) =>
|
|
||||||
req.id === requestIdentifier ||
|
|
||||||
req.requestNumber === requestIdentifier ||
|
req.requestNumber === requestIdentifier ||
|
||||||
req.request_number === requestIdentifier
|
req.request_number === requestIdentifier
|
||||||
);
|
);
|
||||||
if (dynamicRequest) return dynamicRequest;
|
if (dynamicRequest) return dynamicRequest;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
||||||
|
|
||||||
@ -693,9 +689,9 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
const existingParticipants = useMemo(() => {
|
const existingParticipants = useMemo(() => {
|
||||||
if (!request) return [];
|
if (!request) return [];
|
||||||
|
|
||||||
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
|
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
|
||||||
|
|
||||||
// Add initiator
|
// Add initiator
|
||||||
if (request.initiator?.email) {
|
if (request.initiator?.email) {
|
||||||
participants.push({
|
participants.push({
|
||||||
@ -704,7 +700,7 @@ export function useRequestDetails(
|
|||||||
name: request.initiator.name
|
name: request.initiator.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add approvers from approval flow
|
// Add approvers from approval flow
|
||||||
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
|
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
|
||||||
request.approvalFlow.forEach((approval: any) => {
|
request.approvalFlow.forEach((approval: any) => {
|
||||||
@ -717,7 +713,7 @@ export function useRequestDetails(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add spectators
|
// Add spectators
|
||||||
if (request.spectators && Array.isArray(request.spectators)) {
|
if (request.spectators && Array.isArray(request.spectators)) {
|
||||||
request.spectators.forEach((spectator: any) => {
|
request.spectators.forEach((spectator: any) => {
|
||||||
@ -730,20 +726,20 @@ export function useRequestDetails(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add from participants array
|
// Add from participants array
|
||||||
if (request.participants && Array.isArray(request.participants)) {
|
if (request.participants && Array.isArray(request.participants)) {
|
||||||
request.participants.forEach((p: any) => {
|
request.participants.forEach((p: any) => {
|
||||||
const email = (p.userEmail || p.email || '').toLowerCase();
|
const email = (p.userEmail || p.email || '').toLowerCase();
|
||||||
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
|
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
|
||||||
const name = p.userName || p.user_name || p.name;
|
const name = p.userName || p.user_name || p.name;
|
||||||
|
|
||||||
if (email && participantType && !participants.find(x => x.email === email)) {
|
if (email && participantType && !participants.find(x => x.email === email)) {
|
||||||
participants.push({ email, participantType, name });
|
participants.push({ email, participantType, name });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return participants;
|
return participants;
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
@ -762,12 +758,12 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestIdentifier || !apiRequest) return;
|
if (!requestIdentifier || !apiRequest) return;
|
||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler: Request updated by another user
|
* Handler: Request updated by another user
|
||||||
* Silently refresh to show latest changes
|
* Silently refresh to show latest changes
|
||||||
@ -779,10 +775,10 @@ export function useRequestDetails(
|
|||||||
refreshDetails();
|
refreshDetails();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register listener
|
// Register listener
|
||||||
socket.on('request:updated', handleRequestUpdated);
|
socket.on('request:updated', handleRequestUpdated);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('request:updated', handleRequestUpdated);
|
socket.off('request:updated', handleRequestUpdated);
|
||||||
|
|||||||
@ -41,21 +41,21 @@ export function Auth() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* const handleTanflowLogin = () => {
|
// const handleTanflowLogin = () => {
|
||||||
// Clear any existing session data
|
// // Clear any existing session data
|
||||||
localStorage.clear();
|
// localStorage.clear();
|
||||||
sessionStorage.clear();
|
// sessionStorage.clear();
|
||||||
|
|
||||||
setTanflowLoading(true);
|
// setTanflowLoading(true);
|
||||||
try {
|
// try {
|
||||||
initiateTanflowLogin();
|
// initiateTanflowLogin();
|
||||||
} catch (loginError) {
|
// } catch (loginError) {
|
||||||
console.error('========================================');
|
// console.error('========================================');
|
||||||
console.error('TANFLOW LOGIN ERROR');
|
// console.error('TANFLOW LOGIN ERROR');
|
||||||
console.error('Error details:', loginError);
|
// console.error('Error details:', loginError);
|
||||||
setTanflowLoading(false);
|
// setTanflowLoading(false);
|
||||||
}
|
// }
|
||||||
}; */
|
// };
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Auth Error in Auth Component:', {
|
console.error('Auth Error in Auth Component:', {
|
||||||
@ -123,8 +123,8 @@ export function Auth() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{/*
|
|
||||||
<div className="relative">
|
{/* <div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t border-gray-700"></span>
|
<span className="w-full border-t border-gray-700"></span>
|
||||||
</div>
|
</div>
|
||||||
@ -141,8 +141,8 @@ export function Auth() {
|
|||||||
>
|
>
|
||||||
{tanflowLoading ? (
|
{tanflowLoading ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
/>
|
/>
|
||||||
Redirecting...
|
Redirecting...
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -29,14 +29,14 @@ export function ClosedRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -129,7 +129,7 @@ export function ClosedRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -138,7 +138,7 @@ export function ClosedRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
||||||
|
|||||||
@ -22,11 +22,11 @@ export function MyRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter: _templateTypeFilter,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange: _onTemplateTypeChange,
|
||||||
}: MyRequestsFiltersProps) {
|
}: MyRequestsFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200" data-testid="my-requests-filters">
|
<Card className="border-gray-200" data-testid="my-requests-filters">
|
||||||
@ -76,7 +76,7 @@ export function MyRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
{/* <Select value={_templateTypeFilter} onValueChange={_onTemplateTypeChange}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||||
data-testid="template-type-filter"
|
data-testid="template-type-filter"
|
||||||
|
|||||||
@ -64,10 +64,10 @@ export function OverviewTab({
|
|||||||
const isPaused = pauseInfo?.isPaused || false;
|
const isPaused = pauseInfo?.isPaused || false;
|
||||||
const pausedByUserId = pauseInfo?.pausedBy?.userId;
|
const pausedByUserId = pauseInfo?.pausedBy?.userId;
|
||||||
const currentUserId = (user as any)?.userId || '';
|
const currentUserId = (user as any)?.userId || '';
|
||||||
|
|
||||||
// Resume: Can be done by both initiator and approver
|
// Resume: Can be done by both initiator and approver
|
||||||
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
|
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
|
||||||
|
|
||||||
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
||||||
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
||||||
|
|
||||||
@ -122,8 +122,8 @@ export function OverviewTab({
|
|||||||
<div>
|
<div>
|
||||||
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
|
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
|
||||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
|
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={request.description || ''}
|
content={request.description || ''}
|
||||||
className="text-xs sm:text-sm"
|
className="text-xs sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -184,17 +184,20 @@ export function OverviewTab({
|
|||||||
{pauseInfo.pauseReason && (
|
{pauseInfo.pauseReason && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
|
||||||
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
|
<FormattedDescription
|
||||||
|
content={pauseInfo.pauseReason}
|
||||||
|
className="text-sm text-gray-900 mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pauseInfo.pausedBy && (
|
{pauseInfo.pausedBy && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
|
||||||
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
|
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pauseInfo.pauseResumeDate && (
|
{pauseInfo.pauseResumeDate && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
|
||||||
@ -208,7 +211,7 @@ export function OverviewTab({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pauseInfo.pausedAt && (
|
{pauseInfo.pausedAt && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
|
||||||
@ -289,8 +292,8 @@ export function OverviewTab({
|
|||||||
<div className="pt-4 border-t border-gray-300">
|
<div className="pt-4 border-t border-gray-300">
|
||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
|
||||||
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
|
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={request.claimDetails.requestDescription}
|
content={request.claimDetails.requestDescription}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -312,8 +315,8 @@ export function OverviewTab({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={request.conclusionRemark || ''}
|
content={request.conclusionRemark || ''}
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -331,23 +334,20 @@ export function OverviewTab({
|
|||||||
{/* Conclusion Remark Section */}
|
{/* Conclusion Remark Section */}
|
||||||
{needsClosure && (
|
{needsClosure && (
|
||||||
<Card data-testid="conclusion-remark-card">
|
<Card data-testid="conclusion-remark-card">
|
||||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
<CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected'
|
||||||
request.status === 'rejected'
|
? 'from-red-50 to-rose-50 border-red-200'
|
||||||
? 'from-red-50 to-rose-50 border-red-200'
|
|
||||||
: 'from-green-50 to-emerald-50 border-green-200'
|
: 'from-green-50 to-emerald-50 border-green-200'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||||
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
}`}>
|
||||||
}`}>
|
<CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||||
<CheckCircle className={`w-5 h-5 ${
|
}`} />
|
||||||
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
|
||||||
}`} />
|
|
||||||
Conclusion Remark - Final Step
|
Conclusion Remark - Final Step
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-1 text-xs sm:text-sm">
|
<CardDescription className="mt-1 text-xs sm:text-sm">
|
||||||
{request.status === 'rejected'
|
{request.status === 'rejected'
|
||||||
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
|
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
|
||||||
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
|
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@ -365,7 +365,7 @@ export function OverviewTab({
|
|||||||
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
|
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
|
||||||
</Button>
|
</Button>
|
||||||
{aiGenerated && !maxAttemptsReached && !generationFailed && (
|
{aiGenerated && !maxAttemptsReached && !generationFailed && (
|
||||||
<span className="text-[10px] text-gray-500 font-medium px-1">
|
<span className="text-[10px] text-gray-500 font-medium px-1">
|
||||||
{2 - generationAttempts} attempts remaining
|
{2 - generationAttempts} attempts remaining
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -92,8 +92,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
</div>
|
</div>
|
||||||
{summary.description && (
|
{summary.description && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={summary.description}
|
content={summary.description}
|
||||||
className="text-gray-700"
|
className="text-gray-700"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -163,7 +163,14 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||||
<p className="text-sm text-gray-700">{approver.remarks || '—'}</p>
|
{approver.remarks ? (
|
||||||
|
<FormattedDescription
|
||||||
|
content={approver.remarks}
|
||||||
|
className="text-sm text-gray-700"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-700">—</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -199,8 +206,8 @@ export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTa
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
<p className="text-xs text-gray-500 mb-1">Remarks</p>
|
||||||
{summary.closingRemarks ? (
|
{summary.closingRemarks ? (
|
||||||
<FormattedDescription
|
<FormattedDescription
|
||||||
content={summary.closingRemarks}
|
content={summary.closingRemarks}
|
||||||
className="text-sm text-gray-700"
|
className="text-sm text-gray-700"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { useAppSelector } from '@/redux/hooks';
|
|||||||
import { Pagination } from '@/components/common/Pagination';
|
import { Pagination } from '@/components/common/Pagination';
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
import dashboardService from '@/services/dashboard.service';
|
import dashboardService from '@/services/dashboard.service';
|
||||||
import userApi from '@/services/userApi';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { RequestsHeader } from './components/RequestsHeader';
|
import { RequestsHeader } from './components/RequestsHeader';
|
||||||
@ -70,7 +69,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
|
const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
|
||||||
const [departments, setDepartments] = useState<string[]>([]);
|
const [departments, setDepartments] = useState<string[]>([]);
|
||||||
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
|
||||||
|
|
||||||
// Pagination (currentPage now in Redux)
|
// Pagination (currentPage now in Redux)
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@ -79,15 +77,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
// User search hooks
|
// User search hooks
|
||||||
const initiatorSearch = useUserSearch({
|
const initiatorSearch = useUserSearch({
|
||||||
allUsers,
|
|
||||||
filterValue: filters.initiatorFilter,
|
filterValue: filters.initiatorFilter,
|
||||||
onFilterChange: filters.setInitiatorFilter
|
onFilterChange: filters.setInitiatorFilter,
|
||||||
|
source: 'local'
|
||||||
});
|
});
|
||||||
|
|
||||||
const approverSearch = useUserSearch({
|
const approverSearch = useUserSearch({
|
||||||
allUsers,
|
|
||||||
filterValue: filters.approverFilter,
|
filterValue: filters.approverFilter,
|
||||||
onFilterChange: filters.setApproverFilter
|
onFilterChange: filters.setApproverFilter,
|
||||||
|
source: 'local'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch backend stats
|
// Fetch backend stats
|
||||||
@ -100,6 +98,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
statsEndDate?: Date,
|
statsEndDate?: Date,
|
||||||
filtersWithoutStatus?: {
|
filtersWithoutStatus?: {
|
||||||
priority?: string;
|
priority?: string;
|
||||||
|
templateType?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
initiator?: string;
|
initiator?: string;
|
||||||
approver?: string;
|
approver?: string;
|
||||||
@ -185,7 +184,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
statsEndDate ? statsEndDate.toISOString() : undefined,
|
statsEndDate ? statsEndDate.toISOString() : undefined,
|
||||||
undefined, // status - All Requests stats show all statuses, not filtered by status
|
undefined, // status - All Requests stats show all statuses, not filtered by status
|
||||||
filtersWithoutStatus?.priority,
|
filtersWithoutStatus?.priority,
|
||||||
undefined, // templateType
|
filtersWithoutStatus?.templateType,
|
||||||
filtersWithoutStatus?.department,
|
filtersWithoutStatus?.department,
|
||||||
filtersWithoutStatus?.initiator,
|
filtersWithoutStatus?.initiator,
|
||||||
filtersWithoutStatus?.approver,
|
filtersWithoutStatus?.approver,
|
||||||
@ -226,20 +225,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch users
|
|
||||||
const fetchUsers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const usersData = await userApi.getAllUsers();
|
|
||||||
const usersList = usersData.map((user: any) => ({
|
|
||||||
userId: user.userId,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.displayName || user.email
|
|
||||||
}));
|
|
||||||
setAllUsers(usersList);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch users:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Use refs to store stable callbacks to prevent infinite loops
|
// Use refs to store stable callbacks to prevent infinite loops
|
||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
@ -332,8 +317,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDepartments();
|
fetchDepartments();
|
||||||
fetchUsers();
|
}, [fetchDepartments]);
|
||||||
}, [fetchDepartments, fetchUsers]);
|
|
||||||
|
|
||||||
// Fetch backend stats when filters change (excluding status)
|
// Fetch backend stats when filters change (excluding status)
|
||||||
// Stats should reflect priority, department, initiator, approver, search, and date range filters
|
// Stats should reflect priority, department, initiator, approver, search, and date range filters
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
|||||||
import { Pagination } from '@/components/common/Pagination';
|
import { Pagination } from '@/components/common/Pagination';
|
||||||
import dashboardService from '@/services/dashboard.service';
|
import dashboardService from '@/services/dashboard.service';
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
import userApi from '@/services/userApi';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { RequestsHeader } from './components/RequestsHeader';
|
import { RequestsHeader } from './components/RequestsHeader';
|
||||||
@ -58,7 +57,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
// Determine once - use this throughout instead of checking repeatedly
|
// Determine once - use this throughout instead of checking repeatedly
|
||||||
const isDealer = userFilterType === 'DEALER';
|
const isDealer = userFilterType === 'DEALER';
|
||||||
|
|
||||||
// Helper to get filters for API - excludes dealer-restricted filters
|
// Helper to get filters for API - excludes dealer-restricted filters
|
||||||
// Since we know user type initially, this helper uses that knowledge
|
// Since we know user type initially, this helper uses that knowledge
|
||||||
const getFiltersForApi = useCallback(() => {
|
const getFiltersForApi = useCallback(() => {
|
||||||
@ -70,7 +69,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
}
|
}
|
||||||
return filterOptions;
|
return filterOptions;
|
||||||
}, [filters, isDealer]);
|
}, [filters, isDealer]);
|
||||||
|
|
||||||
// Helper to calculate active filters count based on user type
|
// Helper to calculate active filters count based on user type
|
||||||
const calculateActiveFiltersCount = useCallback(() => {
|
const calculateActiveFiltersCount = useCallback(() => {
|
||||||
if (isDealer) {
|
if (isDealer) {
|
||||||
@ -96,7 +95,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
|
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
|
||||||
const [departments, setDepartments] = useState<string[]>([]);
|
const [departments, setDepartments] = useState<string[]>([]);
|
||||||
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
|
||||||
|
|
||||||
// Pagination (currentPage now in Redux)
|
// Pagination (currentPage now in Redux)
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
@ -105,31 +103,31 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
// User search hooks
|
// User search hooks
|
||||||
const initiatorSearch = useUserSearch({
|
const initiatorSearch = useUserSearch({
|
||||||
allUsers,
|
|
||||||
filterValue: filters.initiatorFilter,
|
filterValue: filters.initiatorFilter,
|
||||||
onFilterChange: filters.setInitiatorFilter
|
onFilterChange: filters.setInitiatorFilter,
|
||||||
|
source: 'local'
|
||||||
});
|
});
|
||||||
|
|
||||||
const approverSearch = useUserSearch({
|
const approverSearch = useUserSearch({
|
||||||
allUsers,
|
|
||||||
filterValue: filters.approverFilter,
|
filterValue: filters.approverFilter,
|
||||||
onFilterChange: filters.setApproverFilter
|
onFilterChange: filters.setApproverFilter,
|
||||||
|
source: 'local'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch backend stats using dashboard API
|
// Fetch backend stats using dashboard API
|
||||||
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||||
// Stats reflect all filters EXCEPT status - total stays stable when only status changes
|
// Stats reflect all filters EXCEPT status - total stays stable when only status changes
|
||||||
const fetchBackendStats = useCallback(async (
|
const fetchBackendStats = useCallback(async (
|
||||||
statsDateRange?: DateRange,
|
statsDateRange?: DateRange,
|
||||||
statsStartDate?: Date,
|
statsStartDate?: Date,
|
||||||
statsEndDate?: Date,
|
statsEndDate?: Date,
|
||||||
filtersWithoutStatus?: {
|
filtersWithoutStatus?: {
|
||||||
priority?: string;
|
priority?: string;
|
||||||
templateType?: string;
|
templateType?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
initiator?: string;
|
initiator?: string;
|
||||||
approver?: string;
|
approver?: string;
|
||||||
approverType?: 'current' | 'any';
|
approverType?: 'current' | 'any';
|
||||||
search?: string;
|
search?: string;
|
||||||
slaCompliance?: string;
|
slaCompliance?: string;
|
||||||
}
|
}
|
||||||
@ -180,26 +178,12 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch users
|
|
||||||
const fetchUsers = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const usersData = await userApi.getAllUsers();
|
|
||||||
const usersList = usersData.map((user: any) => ({
|
|
||||||
userId: user.userId,
|
|
||||||
email: user.email,
|
|
||||||
displayName: user.displayName || user.email
|
|
||||||
}));
|
|
||||||
setAllUsers(usersList);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch users:', error);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Use refs to store stable callbacks to prevent infinite loops
|
// Use refs to store stable callbacks to prevent infinite loops
|
||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
const fetchBackendStatsRef = useRef(fetchBackendStats);
|
const fetchBackendStatsRef = useRef(fetchBackendStats);
|
||||||
const getFiltersForApiRef = useRef(getFiltersForApi);
|
const getFiltersForApiRef = useRef(getFiltersForApi);
|
||||||
|
|
||||||
// Update refs on each render
|
// Update refs on each render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
@ -253,8 +237,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDepartments();
|
fetchDepartments();
|
||||||
fetchUsers();
|
}, [fetchDepartments]);
|
||||||
}, [fetchDepartments, fetchUsers]);
|
|
||||||
|
|
||||||
// Fetch backend stats when filters change (except status filter)
|
// Fetch backend stats when filters change (except status filter)
|
||||||
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||||
@ -275,7 +258,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include priority, templateType, department, and slaCompliance if user is not a dealer
|
// Only include priority, templateType, department, and slaCompliance if user is not a dealer
|
||||||
if (!isDealer) {
|
if (!isDealer) {
|
||||||
if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter;
|
if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter;
|
||||||
@ -283,13 +266,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter;
|
if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter;
|
||||||
if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter;
|
if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
|
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
|
||||||
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
|
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
|
||||||
|
|
||||||
fetchBackendStatsRef.current(
|
fetchBackendStatsRef.current(
|
||||||
statsDateRange,
|
statsDateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate,
|
filters.customEndDate,
|
||||||
filtersWithoutStatus
|
filtersWithoutStatus
|
||||||
);
|
);
|
||||||
@ -329,7 +312,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
customEndDate: filters.customEndDate,
|
customEndDate: filters.customEndDate,
|
||||||
});
|
});
|
||||||
const hasInitialFetchRun = useRef(false);
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
// Initial fetch on mount - use stored page from Redux
|
// Initial fetch on mount - use stored page from Redux
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedPage = filters.currentPage || 1;
|
const storedPage = filters.currentPage || 1;
|
||||||
@ -337,13 +320,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
hasInitialFetchRun.current = true;
|
hasInitialFetchRun.current = true;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // Only on mount
|
}, []); // Only on mount
|
||||||
|
|
||||||
// Fetch when filters change
|
// Fetch when filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasInitialFetchRun.current) return;
|
if (!hasInitialFetchRun.current) return;
|
||||||
|
|
||||||
const prev = prevFiltersRef.current;
|
const prev = prevFiltersRef.current;
|
||||||
const hasChanged =
|
const hasChanged =
|
||||||
prev.searchTerm !== filters.searchTerm ||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
prev.statusFilter !== filters.statusFilter ||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
prev.priorityFilter !== filters.priorityFilter ||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
@ -356,13 +339,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
prev.dateRange !== filters.dateRange ||
|
prev.dateRange !== filters.dateRange ||
|
||||||
prev.customStartDate !== filters.customStartDate ||
|
prev.customStartDate !== filters.customStartDate ||
|
||||||
prev.customEndDate !== filters.customEndDate;
|
prev.customEndDate !== filters.customEndDate;
|
||||||
|
|
||||||
if (!hasChanged) return;
|
if (!hasChanged) return;
|
||||||
|
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
filters.setCurrentPage(1);
|
filters.setCurrentPage(1);
|
||||||
fetchRequests(1);
|
fetchRequests(1);
|
||||||
|
|
||||||
prevFiltersRef.current = {
|
prevFiltersRef.current = {
|
||||||
searchTerm: filters.searchTerm,
|
searchTerm: filters.searchTerm,
|
||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
@ -406,7 +389,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
// Transform requests
|
// Transform requests
|
||||||
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
||||||
|
|
||||||
// Calculate stats - Use backend stats API (OPTIMIZED)
|
// Calculate stats - Use backend stats API (OPTIMIZED)
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
// Use backend stats if available
|
// Use backend stats if available
|
||||||
@ -421,38 +404,38 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
closed: backendStats.closed || 0
|
closed: backendStats.closed || 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: calculate from current page (less accurate, but works during initial load)
|
// Fallback: calculate from current page (less accurate, but works during initial load)
|
||||||
const pending = convertedRequests.filter((r: any) => {
|
const pending = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'pending' || status === 'in-progress';
|
return status === 'pending' || status === 'in-progress';
|
||||||
}).length;
|
}).length;
|
||||||
const paused = convertedRequests.filter((r: any) => {
|
const paused = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'paused';
|
return status === 'paused';
|
||||||
}).length;
|
}).length;
|
||||||
const approved = convertedRequests.filter((r: any) => {
|
const approved = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'approved';
|
return status === 'approved';
|
||||||
}).length;
|
}).length;
|
||||||
const rejected = convertedRequests.filter((r: any) => {
|
const rejected = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'rejected';
|
return status === 'rejected';
|
||||||
}).length;
|
}).length;
|
||||||
const closed = convertedRequests.filter((r: any) => {
|
const closed = convertedRequests.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
return status === 'closed';
|
return status === 'closed';
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
|
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
|
||||||
pending,
|
pending,
|
||||||
paused,
|
paused,
|
||||||
approved,
|
approved,
|
||||||
rejected,
|
rejected,
|
||||||
draft: 0,
|
draft: 0,
|
||||||
closed
|
closed
|
||||||
};
|
};
|
||||||
}, [backendStats, totalRecords, convertedRequests]);
|
}, [backendStats, totalRecords, convertedRequests]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -467,8 +450,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<RequestsStats
|
<RequestsStats
|
||||||
stats={stats}
|
stats={stats}
|
||||||
onStatusFilter={(status) => {
|
onStatusFilter={(status) => {
|
||||||
filters.setStatusFilter(status);
|
filters.setStatusFilter(status);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -4,30 +4,44 @@
|
|||||||
|
|
||||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import type { User } from '../types/requests.types';
|
import type { User } from '../types/requests.types';
|
||||||
|
import { userApi } from '@/services/userApi';
|
||||||
|
|
||||||
interface UseUserSearchOptions {
|
interface UseUserSearchOptions {
|
||||||
allUsers: User[];
|
|
||||||
filterValue: string;
|
filterValue: string;
|
||||||
onFilterChange: (userId: string) => void;
|
onFilterChange: (userId: string) => void;
|
||||||
|
source?: 'local' | 'okta' | 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUserSearchOptions) {
|
export function useUserSearch({ filterValue, onFilterChange, source = 'default' }: UseUserSearchOptions) {
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<User[]>([]);
|
const [searchResults, setSearchResults] = useState<User[]>([]);
|
||||||
const [showResults, setShowResults] = useState(false);
|
const [showResults, setShowResults] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
const searchTimer = useRef<NodeJS.Timeout | null>(null);
|
const searchTimer = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Initialize selected user from filter value
|
// Initialize selected user details if we only have the ID (filterValue)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (filterValue !== 'all' && allUsers.length > 0) {
|
async function fetchUserDetail() {
|
||||||
const user = allUsers.find(u => u.userId === filterValue);
|
if (filterValue !== 'all' && !selectedUser) {
|
||||||
if (user) {
|
try {
|
||||||
setSelectedUser(user);
|
// Fetch specific user details by ID
|
||||||
setSearchQuery(user.displayName || user.email);
|
const user = await userApi.getUserById(filterValue);
|
||||||
|
if (user) {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setSearchQuery(user.displayName || user.email);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch user detail for search:', err);
|
||||||
|
}
|
||||||
|
} else if (filterValue === 'all') {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSearchQuery('');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [filterValue, allUsers]);
|
|
||||||
|
fetchUserDetail();
|
||||||
|
}, [filterValue]);
|
||||||
|
|
||||||
// Cleanup timer
|
// Cleanup timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -51,17 +65,22 @@ export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUser
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimer.current = setTimeout(() => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
const searchLower = query.toLowerCase().trim();
|
setSearching(true);
|
||||||
const filtered = allUsers.filter((user) => {
|
try {
|
||||||
const email = (user.email || '').toLowerCase();
|
const response = await userApi.searchUsers(query.trim(), 10, source);
|
||||||
const displayName = (user.displayName || '').toLowerCase();
|
const users = response.data?.data || [];
|
||||||
return email.includes(searchLower) || displayName.includes(searchLower);
|
setSearchResults(users);
|
||||||
});
|
setShowResults(users.length > 0);
|
||||||
setSearchResults(filtered.slice(0, 10));
|
} catch (err) {
|
||||||
setShowResults(filtered.length > 0);
|
console.error('Search API failed:', err);
|
||||||
}, 300);
|
setSearchResults([]);
|
||||||
}, [allUsers]);
|
setShowResults(false);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 400); // Slightly longer debounce for API calls
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
const handleSelect = useCallback((user: User) => {
|
const handleSelect = useCallback((user: User) => {
|
||||||
setSelectedUser(user);
|
setSelectedUser(user);
|
||||||
@ -84,6 +103,7 @@ export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUser
|
|||||||
searchResults,
|
searchResults,
|
||||||
showResults,
|
showResults,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
|
searching,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
handleClear,
|
handleClear,
|
||||||
|
|||||||
67
src/pages/Settings/SecuritySettings.tsx
Normal file
67
src/pages/Settings/SecuritySettings.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { ArrowLeft, Lock, Key } from 'lucide-react';
|
||||||
|
import { ApiTokenManager } from '@/components/settings/ApiTokenManager';
|
||||||
|
|
||||||
|
export function SecuritySettings() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto space-y-6 pb-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Button variant="ghost" size="icon" onClick={() => navigate('/settings')}>
|
||||||
|
<ArrowLeft className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Security Settings</h1>
|
||||||
|
<p className="text-gray-500">Manage your account security and access tokens</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6">
|
||||||
|
{/* Password Section */}
|
||||||
|
<Card className="shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-md">
|
||||||
|
<Lock className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>Password</CardTitle>
|
||||||
|
<CardDescription>Manage your sign-in password</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="p-4 bg-gray-50 rounded-md border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Your password is managed through your organization's Single Sign-On (SSO) provider.
|
||||||
|
Please contact your IT administrator to reset or change your password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* API Tokens Section */}
|
||||||
|
<Card className="shadow-md">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-purple-100 rounded-md">
|
||||||
|
<Key className="w-5 h-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle>API Tokens</CardTitle>
|
||||||
|
<CardDescription>Manage personal access tokens for external integrations</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ApiTokenManager />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -19,10 +19,13 @@ import { UserRoleManager } from '@/components/admin/UserRoleManager';
|
|||||||
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
|
import { ActivityTypeManager } from '@/components/admin/ActivityTypeManager';
|
||||||
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
|
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
|
||||||
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
|
import { NotificationPreferencesModal } from '@/components/settings/NotificationPreferencesModal';
|
||||||
|
// import { ApiTokenManager } from '@/components/settings/ApiTokenManager'; // Removed: Moved to dedicated page
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { getUserSubscriptions } from '@/services/notificationApi';
|
import { getUserSubscriptions } from '@/services/notificationApi';
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = checkIsAdmin(user);
|
const isAdmin = checkIsAdmin(user);
|
||||||
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
||||||
@ -197,8 +200,8 @@ export function Settings() {
|
|||||||
<span className="hidden sm:inline">Holidays</span>
|
<span className="hidden sm:inline">Holidays</span>
|
||||||
<span className="sm:hidden">Holidays</span>
|
<span className="sm:hidden">Holidays</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{/* <TabsTrigger
|
{/* <TabsTrigger
|
||||||
value="templates"
|
value="templates"
|
||||||
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
|
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
@ -271,9 +274,18 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
|
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
|
||||||
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Manage your password, API tokens, and other security preferences.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/settings/security')}
|
||||||
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4 mr-2" />
|
||||||
|
Manage Security
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -324,6 +336,9 @@ export function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Additional Settings if needed */}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -491,9 +506,18 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
|
<div className="p-4 bg-gradient-to-br from-gray-50 to-gray-100 rounded-md border border-gray-200">
|
||||||
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Manage your password, API tokens, and other security preferences.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/settings/security')}
|
||||||
|
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
|
||||||
|
>
|
||||||
|
<Lock className="w-4 h-4 mr-2" />
|
||||||
|
Manage Security
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -545,6 +569,8 @@ export function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Additional sections if needed */}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -135,7 +135,7 @@ export const bulkImportHolidays = async (holidays: Partial<Holiday>[]): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all activity types (public endpoint - no auth required)
|
* Get all active activity types (requires authentication)
|
||||||
*/
|
*/
|
||||||
export const getActivityTypes = async (): Promise<ActivityType[]> => {
|
export const getActivityTypes = async (): Promise<ActivityType[]> => {
|
||||||
const response = await apiClient.get('/config/activity-types');
|
const response = await apiClient.get('/config/activity-types');
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
import axios, { AxiosInstance } from 'axios';
|
import axios, { AxiosInstance } from 'axios';
|
||||||
import { TokenManager } from '../utils/tokenManager';
|
import { TokenManager } from '../utils/tokenManager';
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
|
||||||
// Create axios instance with default config
|
// Create axios instance with default config
|
||||||
const apiClient: AxiosInstance = axios.create({
|
const apiClient: AxiosInstance = axios.create({
|
||||||
@ -25,16 +25,16 @@ apiClient.interceptors.request.use(
|
|||||||
// In production, cookies are sent automatically with withCredentials: true
|
// In production, cookies are sent automatically with withCredentials: true
|
||||||
// No need to set Authorization header
|
// No need to set Authorization header
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
// Development: Get token from localStorage and add to header
|
// Dev: Get token from localStorage and add to header
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
// Prod: Cookies handle authentication automatically
|
||||||
// Production: Cookies handle authentication automatically
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@ -51,7 +51,7 @@ apiClient.interceptors.response.use(
|
|||||||
// Handle connection errors gracefully in development
|
// Handle connection errors gracefully in development
|
||||||
if (error.code === 'ERR_NETWORK' || error.code === 'ECONNREFUSED' || error.message?.includes('ERR_CONNECTION_REFUSED')) {
|
if (error.code === 'ERR_NETWORK' || error.code === 'ECONNREFUSED' || error.message?.includes('ERR_CONNECTION_REFUSED')) {
|
||||||
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development';
|
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development';
|
||||||
|
|
||||||
if (isDevelopment) {
|
if (isDevelopment) {
|
||||||
// In development, log a helpful message instead of spamming console
|
// In development, log a helpful message instead of spamming console
|
||||||
console.warn(`[API] Backend not reachable at ${API_BASE_URL}. Make sure the backend server is running on port 5000.`);
|
console.warn(`[API] Backend not reachable at ${API_BASE_URL}. Make sure the backend server is running on port 5000.`);
|
||||||
@ -67,7 +67,7 @@ apiClient.interceptors.response.use(
|
|||||||
// If error is 401 and we haven't retried yet
|
// If error is 401 and we haven't retried yet
|
||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -75,7 +75,7 @@ apiClient.interceptors.response.use(
|
|||||||
// In production: Cookie is sent automatically via withCredentials
|
// In production: Cookie is sent automatically via withCredentials
|
||||||
// In development: Send refresh token from localStorage
|
// In development: Send refresh token from localStorage
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
|
|
||||||
// In production, refreshToken will be null but cookie will be sent
|
// In production, refreshToken will be null but cookie will be sent
|
||||||
// In development, we need the token in body
|
// In development, we need the token in body
|
||||||
if (!isProduction && !refreshToken) {
|
if (!isProduction && !refreshToken) {
|
||||||
@ -90,14 +90,14 @@ apiClient.interceptors.response.use(
|
|||||||
|
|
||||||
const responseData = response.data.data || response.data;
|
const responseData = response.data.data || response.data;
|
||||||
const accessToken = responseData.accessToken;
|
const accessToken = responseData.accessToken;
|
||||||
|
|
||||||
// In production: Backend sets new httpOnly cookie, no token in response
|
// In production: Backend sets new httpOnly cookie, no token in response
|
||||||
// In development: Token is in response, store it and add to header
|
// In development: Token is in response, store it and add to header
|
||||||
if (!isProduction && accessToken) {
|
if (!isProduction && accessToken) {
|
||||||
TokenManager.setAccessToken(accessToken);
|
TokenManager.setAccessToken(accessToken);
|
||||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry the original request
|
// Retry the original request
|
||||||
// In production: Cookie will be sent automatically
|
// In production: Cookie will be sent automatically
|
||||||
return apiClient(originalRequest);
|
return apiClient(originalRequest);
|
||||||
@ -156,7 +156,7 @@ export async function exchangeCodeForTokens(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if response is an array (buffer issue)
|
// Check if response is an array (buffer issue)
|
||||||
if (Array.isArray(response.data)) {
|
if (Array.isArray(response.data)) {
|
||||||
console.error('❌ Response is an array (buffer issue):', {
|
console.error('❌ Response is an array (buffer issue):', {
|
||||||
@ -166,28 +166,28 @@ export async function exchangeCodeForTokens(
|
|||||||
});
|
});
|
||||||
throw new Error('Invalid response format: received array instead of JSON. Check Content-Type header.');
|
throw new Error('Invalid response format: received array instead of JSON. Check Content-Type header.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = response.data as any;
|
const data = response.data as any;
|
||||||
const result = data.data || data;
|
const result = data.data || data;
|
||||||
|
|
||||||
// Store user data (always available)
|
// Store user data (always available)
|
||||||
if (result.user) {
|
if (result.user) {
|
||||||
TokenManager.setUserData(result.user);
|
TokenManager.setUserData(result.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store ID token if available (needed for Okta logout)
|
// Store ID token if available (needed for Okta logout)
|
||||||
if (result.idToken) {
|
if (result.idToken) {
|
||||||
TokenManager.setIdToken(result.idToken);
|
TokenManager.setIdToken(result.idToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body)
|
// SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body)
|
||||||
// In development, backend returns tokens for cross-port setup
|
// In development, backend returns tokens for cross-port setup
|
||||||
if (result.accessToken && result.refreshToken) {
|
if (result.accessToken && result.refreshToken) {
|
||||||
// Development mode: Backend returned tokens, store them
|
// Dev mode: Backend returned tokens, store them
|
||||||
TokenManager.setAccessToken(result.accessToken);
|
TokenManager.setAccessToken(result.accessToken);
|
||||||
TokenManager.setRefreshToken(result.refreshToken);
|
TokenManager.setRefreshToken(result.refreshToken);
|
||||||
}
|
}
|
||||||
// Production mode: No tokens in response - they're in httpOnly cookies
|
// Prod mode: No tokens in response - they're in httpOnly cookies
|
||||||
// TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway
|
// TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -211,15 +211,15 @@ export async function exchangeCodeForTokens(
|
|||||||
*/
|
*/
|
||||||
export async function refreshAccessToken(): Promise<string> {
|
export async function refreshAccessToken(): Promise<string> {
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
// In development, check for refresh token in localStorage
|
// In development, check for refresh token in localStorage
|
||||||
if (!isProduction) {
|
if (!isProduction) {
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// In production, httpOnly cookie with refresh token will be sent automatically
|
// In production, httpOnly cookie with refresh token will be sent automatically
|
||||||
// In development, we send the refresh token in the body
|
// In development, we send the refresh token in the body
|
||||||
const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() };
|
const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() };
|
||||||
@ -234,7 +234,7 @@ export async function refreshAccessToken(): Promise<string> {
|
|||||||
TokenManager.setAccessToken(accessToken);
|
TokenManager.setAccessToken(accessToken);
|
||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In production mode, token is set via httpOnly cookie by the backend
|
// In production mode, token is set via httpOnly cookie by the backend
|
||||||
// Return a placeholder to indicate success
|
// Return a placeholder to indicate success
|
||||||
if (isProduction && (data.success !== false)) {
|
if (isProduction && (data.success !== false)) {
|
||||||
@ -255,7 +255,7 @@ export async function getCurrentUser() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Logout user
|
* Logout user
|
||||||
* CRITICAL: This endpoint MUST clear httpOnly cookies set by backend
|
* IMPORTANT: This endpoint MUST clear httpOnly cookies set by backend
|
||||||
* Note: TokenManager.clearAll() is called in AuthContext.logout()
|
* Note: TokenManager.clearAll() is called in AuthContext.logout()
|
||||||
* We don't call it here to avoid double clearing
|
* We don't call it here to avoid double clearing
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -105,3 +105,16 @@ export async function verifyDealerLogin(dealerCode: string): Promise<DealerInfo>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search dealer by code from external Royal Enfield API
|
||||||
|
* @param dealerCode - The code to search for
|
||||||
|
*/
|
||||||
|
export async function searchExternalDealerByCode(dealerCode: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get(`/dealers-external/search/${dealerCode}`);
|
||||||
|
return res.data?.data || res.data || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[DealerAPI] Error searching external dealer:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -78,7 +78,22 @@ export async function submitProposal(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
proposalData: {
|
proposalData: {
|
||||||
proposalDocument?: File;
|
proposalDocument?: File;
|
||||||
costBreakup?: Array<{ description: string; amount: number }>;
|
costBreakup?: Array<{
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
hsnCode?: string;
|
||||||
|
isService?: boolean;
|
||||||
|
quantity?: number;
|
||||||
|
gstRate?: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
cgstRate?: number;
|
||||||
|
cgstAmt?: number;
|
||||||
|
sgstRate?: number;
|
||||||
|
sgstAmt?: number;
|
||||||
|
igstRate?: number;
|
||||||
|
igstAmt?: number;
|
||||||
|
totalAmt?: number;
|
||||||
|
}>;
|
||||||
totalEstimatedBudget?: number;
|
totalEstimatedBudget?: number;
|
||||||
timelineMode?: 'date' | 'days';
|
timelineMode?: 'date' | 'days';
|
||||||
expectedCompletionDate?: string;
|
expectedCompletionDate?: string;
|
||||||
@ -88,31 +103,31 @@ export async function submitProposal(
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
if (proposalData.proposalDocument) {
|
if (proposalData.proposalDocument) {
|
||||||
formData.append('proposalDocument', proposalData.proposalDocument);
|
formData.append('proposalDocument', proposalData.proposalDocument);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalData.costBreakup) {
|
if (proposalData.costBreakup) {
|
||||||
formData.append('costBreakup', JSON.stringify(proposalData.costBreakup));
|
formData.append('costBreakup', JSON.stringify(proposalData.costBreakup));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalData.totalEstimatedBudget !== undefined) {
|
if (proposalData.totalEstimatedBudget !== undefined) {
|
||||||
formData.append('totalEstimatedBudget', proposalData.totalEstimatedBudget.toString());
|
formData.append('totalEstimatedBudget', proposalData.totalEstimatedBudget.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalData.timelineMode) {
|
if (proposalData.timelineMode) {
|
||||||
formData.append('timelineMode', proposalData.timelineMode);
|
formData.append('timelineMode', proposalData.timelineMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalData.expectedCompletionDate) {
|
if (proposalData.expectedCompletionDate) {
|
||||||
formData.append('expectedCompletionDate', proposalData.expectedCompletionDate);
|
formData.append('expectedCompletionDate', proposalData.expectedCompletionDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalData.expectedCompletionDays !== undefined) {
|
if (proposalData.expectedCompletionDays !== undefined) {
|
||||||
formData.append('expectedCompletionDays', proposalData.expectedCompletionDays.toString());
|
formData.append('expectedCompletionDays', proposalData.expectedCompletionDays.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proposalData.dealerComments) {
|
if (proposalData.dealerComments) {
|
||||||
formData.append('dealerComments', proposalData.dealerComments);
|
formData.append('dealerComments', proposalData.dealerComments);
|
||||||
}
|
}
|
||||||
@ -122,7 +137,7 @@ export async function submitProposal(
|
|||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data?.data || response.data;
|
return response.data?.data || response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[DealerClaimAPI] Error submitting proposal:', error);
|
console.error('[DealerClaimAPI] Error submitting proposal:', error);
|
||||||
@ -139,7 +154,16 @@ export async function submitCompletion(
|
|||||||
completionData: {
|
completionData: {
|
||||||
activityCompletionDate: string; // ISO date string
|
activityCompletionDate: string; // ISO date string
|
||||||
numberOfParticipants?: number;
|
numberOfParticipants?: number;
|
||||||
closedExpenses?: Array<{ description: string; amount: number }>;
|
closedExpenses?: Array<{
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
hsnCode?: string;
|
||||||
|
isService?: boolean;
|
||||||
|
quantity?: number;
|
||||||
|
gstRate?: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
totalAmt?: number;
|
||||||
|
}>;
|
||||||
totalClosedExpenses?: number;
|
totalClosedExpenses?: number;
|
||||||
completionDocuments?: File[];
|
completionDocuments?: File[];
|
||||||
activityPhotos?: File[];
|
activityPhotos?: File[];
|
||||||
@ -148,31 +172,31 @@ export async function submitCompletion(
|
|||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
formData.append('activityCompletionDate', completionData.activityCompletionDate);
|
formData.append('activityCompletionDate', completionData.activityCompletionDate);
|
||||||
|
|
||||||
if (completionData.numberOfParticipants !== undefined) {
|
if (completionData.numberOfParticipants !== undefined) {
|
||||||
formData.append('numberOfParticipants', completionData.numberOfParticipants.toString());
|
formData.append('numberOfParticipants', completionData.numberOfParticipants.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionData.closedExpenses) {
|
if (completionData.closedExpenses) {
|
||||||
formData.append('closedExpenses', JSON.stringify(completionData.closedExpenses));
|
formData.append('closedExpenses', JSON.stringify(completionData.closedExpenses));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionData.totalClosedExpenses !== undefined) {
|
if (completionData.totalClosedExpenses !== undefined) {
|
||||||
formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString());
|
formData.append('totalClosedExpenses', completionData.totalClosedExpenses.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionData.completionDescription) {
|
if (completionData.completionDescription) {
|
||||||
formData.append('completionDescription', completionData.completionDescription);
|
formData.append('completionDescription', completionData.completionDescription);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionData.completionDocuments) {
|
if (completionData.completionDocuments) {
|
||||||
completionData.completionDocuments.forEach((file) => {
|
completionData.completionDocuments.forEach((file) => {
|
||||||
formData.append('completionDocuments', file);
|
formData.append('completionDocuments', file);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (completionData.activityPhotos) {
|
if (completionData.activityPhotos) {
|
||||||
completionData.activityPhotos.forEach((file) => {
|
completionData.activityPhotos.forEach((file) => {
|
||||||
formData.append('activityPhotos', file);
|
formData.append('activityPhotos', file);
|
||||||
@ -184,7 +208,7 @@ export async function submitCompletion(
|
|||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.data?.data || response.data;
|
return response.data?.data || response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[DealerClaimAPI] Error submitting completion:', error);
|
console.error('[DealerClaimAPI] Error submitting completion:', error);
|
||||||
@ -240,7 +264,7 @@ export async function updateIODetails(
|
|||||||
ioNumber: ioData.ioNumber,
|
ioNumber: ioData.ioNumber,
|
||||||
ioRemark: ioData.ioRemark || '',
|
ioRemark: ioData.ioRemark || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only include balance fields if explicitly provided
|
// Only include balance fields if explicitly provided
|
||||||
if (ioData.ioAvailableBalance !== undefined) {
|
if (ioData.ioAvailableBalance !== undefined) {
|
||||||
payload.availableBalance = ioData.ioAvailableBalance;
|
payload.availableBalance = ioData.ioAvailableBalance;
|
||||||
@ -251,7 +275,7 @@ export async function updateIODetails(
|
|||||||
if (ioData.ioRemainingBalance !== undefined) {
|
if (ioData.ioRemainingBalance !== undefined) {
|
||||||
payload.remainingBalance = ioData.ioRemainingBalance;
|
payload.remainingBalance = ioData.ioRemainingBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.put(`/dealer-claims/${requestId}/io`, payload);
|
const response = await apiClient.put(`/dealer-claims/${requestId}/io`, payload);
|
||||||
return response.data?.data || response.data;
|
return response.data?.data || response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -6,8 +6,8 @@
|
|||||||
import { TokenManager } from '../utils/tokenManager';
|
import { TokenManager } from '../utils/tokenManager';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const TANFLOW_BASE_URL = import.meta.env.VITE_TANFLOW_BASE_URL || 'https://ssodev.rebridge.co.in/realms/RE';
|
const TANFLOW_BASE_URL = import.meta.env.VITE_TANFLOW_BASE_URL || '';
|
||||||
const TANFLOW_CLIENT_ID = import.meta.env.VITE_TANFLOW_CLIENT_ID || 'REFLOW';
|
const TANFLOW_CLIENT_ID = import.meta.env.VITE_TANFLOW_CLIENT_ID || '';
|
||||||
const TANFLOW_REDIRECT_URI = `${window.location.origin}/login/callback`;
|
const TANFLOW_REDIRECT_URI = `${window.location.origin}/login/callback`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,7 +18,7 @@ export function initiateTanflowLogin(): void {
|
|||||||
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isAfterLogout = urlParams.has('logout') || urlParams.has('tanflow_logged_out');
|
const isAfterLogout = urlParams.has('logout') || urlParams.has('tanflow_logged_out');
|
||||||
|
|
||||||
// Clear any previous logout flags before starting new login
|
// Clear any previous logout flags before starting new login
|
||||||
if (isAfterLogout) {
|
if (isAfterLogout) {
|
||||||
sessionStorage.removeItem('tanflow_logged_out');
|
sessionStorage.removeItem('tanflow_logged_out');
|
||||||
@ -26,26 +26,26 @@ export function initiateTanflowLogin(): void {
|
|||||||
sessionStorage.removeItem('__force_logout__');
|
sessionStorage.removeItem('__force_logout__');
|
||||||
console.log('🚪 Cleared logout flags before initiating Tanflow login');
|
console.log('🚪 Cleared logout flags before initiating Tanflow login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Math.random().toString(36).substring(7);
|
const state = Math.random().toString(36).substring(7);
|
||||||
// Store provider type and state to identify Tanflow callback
|
// Store provider type and state to identify Tanflow callback
|
||||||
sessionStorage.setItem('auth_provider', 'tanflow');
|
sessionStorage.setItem('auth_provider', 'tanflow');
|
||||||
sessionStorage.setItem('tanflow_auth_state', state);
|
sessionStorage.setItem('tanflow_auth_state', state);
|
||||||
|
|
||||||
let authUrl = `${TANFLOW_BASE_URL}/protocol/openid-connect/auth?` +
|
let authUrl = `${TANFLOW_BASE_URL}/protocol/openid-connect/auth?` +
|
||||||
`client_id=${TANFLOW_CLIENT_ID}&` +
|
`client_id=${TANFLOW_CLIENT_ID}&` +
|
||||||
`response_type=code&` +
|
`response_type=code&` +
|
||||||
`scope=openid&` +
|
`scope=openid&` +
|
||||||
`redirect_uri=${encodeURIComponent(TANFLOW_REDIRECT_URI)}&` +
|
`redirect_uri=${encodeURIComponent(TANFLOW_REDIRECT_URI)}&` +
|
||||||
`state=${state}`;
|
`state=${state}`;
|
||||||
|
|
||||||
// Add prompt=login if coming from logout to force re-authentication
|
// Add prompt=login if coming from logout to force re-authentication
|
||||||
// This ensures Tanflow requires login even if a session still exists
|
// This ensures Tanflow requires login even if a session still exists
|
||||||
if (isAfterLogout) {
|
if (isAfterLogout) {
|
||||||
authUrl += `&prompt=login`;
|
authUrl += `&prompt=login`;
|
||||||
console.log('🚪 Adding prompt=login to force re-authentication after logout');
|
console.log('🚪 Adding prompt=login to force re-authentication after logout');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🚪 Initiating Tanflow login', { isAfterLogout, hasPrompt: isAfterLogout });
|
console.log('🚪 Initiating Tanflow login', { isAfterLogout, hasPrompt: isAfterLogout });
|
||||||
window.location.href = authUrl;
|
window.location.href = authUrl;
|
||||||
}
|
}
|
||||||
@ -63,8 +63,8 @@ export async function exchangeTanflowCodeForTokens(
|
|||||||
idToken: string;
|
idToken: string;
|
||||||
user: any;
|
user: any;
|
||||||
}> {
|
}> {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_BASE_URL}/auth/tanflow/token-exchange`,
|
`${API_BASE_URL}/auth/tanflow/token-exchange`,
|
||||||
@ -80,9 +80,9 @@ export async function exchangeTanflowCodeForTokens(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data?.data || response.data;
|
const data = response.data?.data || response.data;
|
||||||
|
|
||||||
// Store tokens
|
// Store tokens
|
||||||
if (data.accessToken) {
|
if (data.accessToken) {
|
||||||
TokenManager.setAccessToken(data.accessToken);
|
TokenManager.setAccessToken(data.accessToken);
|
||||||
@ -96,7 +96,7 @@ export async function exchangeTanflowCodeForTokens(
|
|||||||
if (data.user) {
|
if (data.user) {
|
||||||
TokenManager.setUserData(data.user);
|
TokenManager.setUserData(data.user);
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Tanflow token exchange failed:', {
|
console.error('❌ Tanflow token exchange failed:', {
|
||||||
@ -112,13 +112,13 @@ export async function exchangeTanflowCodeForTokens(
|
|||||||
* Refresh access token using refresh token
|
* Refresh access token using refresh token
|
||||||
*/
|
*/
|
||||||
export async function refreshTanflowToken(): Promise<string> {
|
export async function refreshTanflowToken(): Promise<string> {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_BASE_URL}/auth/tanflow/refresh`,
|
`${API_BASE_URL}/auth/tanflow/refresh`,
|
||||||
@ -130,15 +130,15 @@ export async function refreshTanflowToken(): Promise<string> {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = response.data?.data || response.data;
|
const data = response.data?.data || response.data;
|
||||||
const accessToken = data.accessToken;
|
const accessToken = data.accessToken;
|
||||||
|
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
TokenManager.setAccessToken(accessToken);
|
TokenManager.setAccessToken(accessToken);
|
||||||
return accessToken;
|
return accessToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('Failed to refresh token');
|
throw new Error('Failed to refresh token');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('❌ Tanflow token refresh failed:', error);
|
console.error('❌ Tanflow token refresh failed:', error);
|
||||||
@ -160,23 +160,23 @@ export function tanflowLogout(idToken: string): void {
|
|||||||
window.location.replace(homeUrl);
|
window.location.replace(homeUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build Tanflow logout URL with redirect back to login callback
|
// Build Tanflow logout URL with redirect back to login callback
|
||||||
// IMPORTANT: Use the base redirect URI (without query params) to match registered URIs
|
// IMPORTANT: Use the base redirect URI (without query params) to match registered URIs
|
||||||
// Tanflow requires exact match with registered "Valid Post Logout Redirect URIs"
|
// Tanflow requires exact match with registered "Valid Post Logout Redirect URIs"
|
||||||
// The same URI used for login should be registered for logout
|
// The same URI used for login should be registered for logout
|
||||||
// Using the base URI ensures it matches what's registered in Tanflow client config
|
// Using the base URI ensures it matches what's registered in Tanflow client config
|
||||||
const postLogoutRedirectUri = TANFLOW_REDIRECT_URI; // Use base URI without query params
|
const postLogoutRedirectUri = TANFLOW_REDIRECT_URI; // Use base URI without query params
|
||||||
|
|
||||||
// Construct logout URL - ensure all parameters are properly encoded
|
// Construct logout URL - ensure all parameters are properly encoded
|
||||||
// Keycloak/Tanflow expects: client_id, id_token_hint, post_logout_redirect_uri
|
// Keycloak/Tanflow expects: client_id, id_token_hint, post_logout_redirect_uri
|
||||||
const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`);
|
const logoutUrl = new URL(`${TANFLOW_BASE_URL}/protocol/openid-connect/logout`);
|
||||||
logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID);
|
logoutUrl.searchParams.set('client_id', TANFLOW_CLIENT_ID);
|
||||||
logoutUrl.searchParams.set('id_token_hint', idToken);
|
logoutUrl.searchParams.set('id_token_hint', idToken);
|
||||||
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
logoutUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
||||||
|
|
||||||
const finalLogoutUrl = logoutUrl.toString();
|
const finalLogoutUrl = logoutUrl.toString();
|
||||||
|
|
||||||
console.log('🚪 Tanflow logout initiated', {
|
console.log('🚪 Tanflow logout initiated', {
|
||||||
hasIdToken: !!idToken,
|
hasIdToken: !!idToken,
|
||||||
idTokenPrefix: idToken ? idToken.substring(0, 20) + '...' : 'none',
|
idTokenPrefix: idToken ? idToken.substring(0, 20) + '...' : 'none',
|
||||||
@ -184,14 +184,14 @@ export function tanflowLogout(idToken: string): void {
|
|||||||
logoutUrlBase: `${TANFLOW_BASE_URL}/protocol/openid-connect/logout`,
|
logoutUrlBase: `${TANFLOW_BASE_URL}/protocol/openid-connect/logout`,
|
||||||
finalLogoutUrl: finalLogoutUrl.replace(idToken.substring(0, 20), '***'),
|
finalLogoutUrl: finalLogoutUrl.replace(idToken.substring(0, 20), '***'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// DO NOT clear auth_provider here - we need it to detect Tanflow callback
|
// DO NOT clear auth_provider here - we need it to detect Tanflow callback
|
||||||
// The logout flags should already be set by AuthContext
|
// The logout flags should already be set by AuthContext
|
||||||
// Just ensure they're there
|
// Just ensure they're there
|
||||||
sessionStorage.setItem('__logout_in_progress__', 'true');
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
||||||
sessionStorage.setItem('__force_logout__', 'true');
|
sessionStorage.setItem('__force_logout__', 'true');
|
||||||
// Don't set tanflow_logged_out here - it will be set when Tanflow redirects back
|
// Don't set tanflow_logged_out here - it will be set when Tanflow redirects back
|
||||||
|
|
||||||
// Redirect to Tanflow logout endpoint
|
// Redirect to Tanflow logout endpoint
|
||||||
// Tanflow will clear the session and redirect back to post_logout_redirect_uri
|
// Tanflow will clear the session and redirect back to post_logout_redirect_uri
|
||||||
// The redirect will include tanflow_logged_out=true in the query params
|
// The redirect will include tanflow_logged_out=true in the query params
|
||||||
|
|||||||
@ -24,8 +24,8 @@ export interface UserSummary {
|
|||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchUsers(query: string, limit: number = 10) {
|
export async function searchUsers(query: string, limit: number = 10, source: 'local' | 'okta' | 'default' = 'default') {
|
||||||
const res = await apiClient.get('/users/search', { params: { q: query, limit } });
|
const res = await apiClient.get('/users/search', { params: { q: query, limit, source } });
|
||||||
// ResponseHandler.success returns { success: true, data: array }
|
// ResponseHandler.success returns { success: true, data: array }
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
@ -66,11 +66,11 @@ export async function ensureUserExists(userData: {
|
|||||||
* @param role - Role to assign
|
* @param role - Role to assign
|
||||||
*/
|
*/
|
||||||
export async function assignRole(
|
export async function assignRole(
|
||||||
email: string,
|
email: string,
|
||||||
role: 'USER' | 'MANAGEMENT' | 'ADMIN'
|
role: 'USER' | 'MANAGEMENT' | 'ADMIN'
|
||||||
) {
|
) {
|
||||||
return await apiClient.post('/admin/users/assign-role', {
|
return await apiClient.post('/admin/users/assign-role', {
|
||||||
email,
|
email,
|
||||||
role
|
role
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -90,8 +90,8 @@ export async function getUsersByRole(
|
|||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 10
|
limit: number = 10
|
||||||
) {
|
) {
|
||||||
return await apiClient.get('/admin/users/by-role', {
|
return await apiClient.get('/admin/users/by-role', {
|
||||||
params: { role: role || 'ELEVATED', page, limit }
|
params: { role: role || 'ELEVATED', page, limit }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,6 +102,14 @@ export async function getRoleStatistics() {
|
|||||||
return await apiClient.get('/admin/users/role-statistics');
|
return await apiClient.get('/admin/users/role-statistics');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user by ID
|
||||||
|
*/
|
||||||
|
export async function getUserById(userId: string) {
|
||||||
|
const res = await apiClient.get(`/users/${userId}`);
|
||||||
|
return res.data?.data || res.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all users from database (for filtering purposes)
|
* Get all users from database (for filtering purposes)
|
||||||
*/
|
*/
|
||||||
@ -111,8 +119,9 @@ export async function getAllUsers() {
|
|||||||
return res.data?.data?.users || [];
|
return res.data?.data?.users || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const userApi = {
|
export const userApi = {
|
||||||
searchUsers,
|
searchUsers,
|
||||||
|
getUserById,
|
||||||
ensureUserExists,
|
ensureUserExists,
|
||||||
assignRole,
|
assignRole,
|
||||||
updateUserRole,
|
updateUserRole,
|
||||||
|
|||||||
@ -362,12 +362,12 @@ export async function getPauseDetails(requestId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
|
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
|
||||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const baseURL = import.meta.env.VITE_BASE_URL || '';
|
||||||
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
|
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDocumentPreviewUrl(documentId: string): string {
|
export function getDocumentPreviewUrl(documentId: string): string {
|
||||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const baseURL = import.meta.env.VITE_BASE_URL || '';
|
||||||
return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`;
|
return `${baseURL}/api/v1/workflows/documents/${documentId}/preview`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -404,7 +404,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadDocument(documentId: string): Promise<void> {
|
export async function downloadDocument(documentId: string): Promise<void> {
|
||||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const baseURL = import.meta.env.VITE_BASE_URL || '';
|
||||||
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
|
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
@ -449,7 +449,7 @@ export async function downloadDocument(documentId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
|
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
|
||||||
const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const downloadBaseURL = import.meta.env.VITE_BASE_URL || '';
|
||||||
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
|
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
|
||||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,17 @@ export interface ClaimManagementRequest {
|
|||||||
};
|
};
|
||||||
estimatedBudget?: number;
|
estimatedBudget?: number;
|
||||||
closedExpenses?: number;
|
closedExpenses?: number;
|
||||||
closedExpensesBreakdown?: Array<{ description: string; amount: number }>;
|
defaultGstRate?: number;
|
||||||
|
closedExpensesBreakdown?: Array<{
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
gstRate?: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
cgstAmt?: number;
|
||||||
|
sgstAmt?: number;
|
||||||
|
igstAmt?: number;
|
||||||
|
totalAmt?: number;
|
||||||
|
}>;
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -42,7 +52,16 @@ export interface ClaimManagementRequest {
|
|||||||
// Proposal Details (Step 1)
|
// Proposal Details (Step 1)
|
||||||
proposalDetails?: {
|
proposalDetails?: {
|
||||||
proposalDocumentUrl?: string;
|
proposalDocumentUrl?: string;
|
||||||
costBreakup: Array<{ description: string; amount: number }>;
|
costBreakup: Array<{
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
gstRate?: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
cgstAmt?: number;
|
||||||
|
sgstAmt?: number;
|
||||||
|
igstAmt?: number;
|
||||||
|
totalAmt?: number;
|
||||||
|
}>;
|
||||||
totalEstimatedBudget: number;
|
totalEstimatedBudget: number;
|
||||||
timelineMode?: 'date' | 'days';
|
timelineMode?: 'date' | 'days';
|
||||||
expectedCompletionDate?: string;
|
expectedCompletionDate?: string;
|
||||||
@ -70,6 +89,12 @@ export interface ClaimManagementRequest {
|
|||||||
creditNoteNumber?: string;
|
creditNoteNumber?: string;
|
||||||
creditNoteDate?: string;
|
creditNoteDate?: string;
|
||||||
creditNoteAmount?: number;
|
creditNoteAmount?: number;
|
||||||
|
// PWC Fields
|
||||||
|
irn?: string;
|
||||||
|
ackNo?: string;
|
||||||
|
ackDate?: string;
|
||||||
|
signedInvoiceUrl?: string;
|
||||||
|
taxBreakdown?: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Claim Amount
|
// Claim Amount
|
||||||
@ -108,7 +133,7 @@ export function mapToClaimManagementRequest(
|
|||||||
const proposalDetails = apiRequest.proposalDetails || {};
|
const proposalDetails = apiRequest.proposalDetails || {};
|
||||||
const completionDetails = apiRequest.completionDetails || {};
|
const completionDetails = apiRequest.completionDetails || {};
|
||||||
const internalOrder = apiRequest.internalOrder || apiRequest.internal_order || {};
|
const internalOrder = apiRequest.internalOrder || apiRequest.internal_order || {};
|
||||||
|
|
||||||
// Extract new normalized tables
|
// Extract new normalized tables
|
||||||
const budgetTracking = apiRequest.budgetTracking || apiRequest.budget_tracking || {};
|
const budgetTracking = apiRequest.budgetTracking || apiRequest.budget_tracking || {};
|
||||||
const invoice = apiRequest.invoice || {};
|
const invoice = apiRequest.invoice || {};
|
||||||
@ -121,47 +146,54 @@ export function mapToClaimManagementRequest(
|
|||||||
// Handle both camelCase and snake_case field names from Sequelize
|
// Handle both camelCase and snake_case field names from Sequelize
|
||||||
const periodStartDate = claimDetails.periodStartDate || claimDetails.period_start_date;
|
const periodStartDate = claimDetails.periodStartDate || claimDetails.period_start_date;
|
||||||
const periodEndDate = claimDetails.periodEndDate || claimDetails.period_end_date;
|
const periodEndDate = claimDetails.periodEndDate || claimDetails.period_end_date;
|
||||||
|
|
||||||
const activityName = claimDetails.activityName || claimDetails.activity_name || '';
|
const activityName = claimDetails.activityName || claimDetails.activity_name || '';
|
||||||
const activityType = claimDetails.activityType || claimDetails.activity_type || '';
|
const activityType = claimDetails.activityType || claimDetails.activity_type || '';
|
||||||
const location = claimDetails.location || '';
|
const location = claimDetails.location || '';
|
||||||
|
|
||||||
// Activity fields mapped
|
// Activity fields mapped
|
||||||
|
|
||||||
// Get budget values from budgetTracking table (new source of truth)
|
// Get budget values from budgetTracking table (new source of truth)
|
||||||
const estimatedBudget = budgetTracking.proposalEstimatedBudget ||
|
const estimatedBudget = budgetTracking.proposalEstimatedBudget ??
|
||||||
budgetTracking.proposal_estimated_budget ||
|
budgetTracking.proposal_estimated_budget ??
|
||||||
budgetTracking.initialEstimatedBudget ||
|
budgetTracking.initialEstimatedBudget ??
|
||||||
budgetTracking.initial_estimated_budget ||
|
budgetTracking.initial_estimated_budget ??
|
||||||
claimDetails.estimatedBudget ||
|
claimDetails.estimatedBudget ??
|
||||||
claimDetails.estimated_budget;
|
claimDetails.estimated_budget;
|
||||||
|
|
||||||
// Get closed expenses - check multiple sources with proper number conversion
|
// Get closed expenses - check multiple sources with proper number conversion
|
||||||
const closedExpensesRaw = budgetTracking?.closedExpenses ||
|
const closedExpensesRaw = budgetTracking?.closedExpenses ??
|
||||||
budgetTracking?.closed_expenses ||
|
budgetTracking?.closed_expenses ??
|
||||||
completionDetails?.totalClosedExpenses ||
|
completionDetails?.totalClosedExpenses ??
|
||||||
completionDetails?.total_closed_expenses ||
|
completionDetails?.total_closed_expenses ??
|
||||||
claimDetails?.closedExpenses ||
|
claimDetails?.closedExpenses ??
|
||||||
claimDetails?.closed_expenses;
|
claimDetails?.closed_expenses;
|
||||||
// Convert to number and handle 0 as valid value
|
// Convert to number and handle 0 as valid value
|
||||||
const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined
|
const closedExpenses = closedExpensesRaw !== null && closedExpensesRaw !== undefined
|
||||||
? Number(closedExpensesRaw)
|
? Number(closedExpensesRaw)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
// 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 ?? exp.gst_rate,
|
||||||
: (completionDetails?.closedExpenses ||
|
gstAmt: exp.gstAmt ?? exp.gst_amt,
|
||||||
completionDetails?.closed_expenses ||
|
cgstAmt: exp.cgstAmt ?? exp.cgst_amt,
|
||||||
completionDetails?.closedExpensesBreakdown ||
|
sgstAmt: exp.sgstAmt ?? exp.sgst_amt,
|
||||||
[]);
|
igstAmt: exp.igstAmt ?? exp.igst_amt,
|
||||||
|
totalAmt: exp.totalAmt ?? exp.total_amt
|
||||||
|
}))
|
||||||
|
: (completionDetails?.closedExpenses ||
|
||||||
|
completionDetails?.closed_expenses ||
|
||||||
|
completionDetails?.closedExpensesBreakdown ||
|
||||||
|
[]);
|
||||||
|
|
||||||
const activityInfo = {
|
const activityInfo = {
|
||||||
activityName,
|
activityName,
|
||||||
activityType,
|
activityType,
|
||||||
|
defaultGstRate: claimDetails.defaultGstRate || 18,
|
||||||
requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt
|
requestedDate: claimDetails.activityDate || claimDetails.activity_date || apiRequest.createdAt, // Use activityDate as requestedDate, fallback to createdAt
|
||||||
location,
|
location,
|
||||||
period: (periodStartDate && periodEndDate) ? {
|
period: (periodStartDate && periodEndDate) ? {
|
||||||
@ -200,7 +232,29 @@ export function mapToClaimManagementRequest(
|
|||||||
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
|
const expectedCompletionDate = proposalDetails?.expectedCompletionDate || proposalDetails?.expected_completion_date;
|
||||||
const proposal = proposalDetails ? {
|
const proposal = proposalDetails ? {
|
||||||
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
|
proposalDocumentUrl: proposalDetails.proposalDocumentUrl || proposalDetails.proposal_document_url,
|
||||||
costBreakup: proposalDetails.costBreakup || proposalDetails.cost_breakup || [],
|
costBreakup: Array.isArray(proposalDetails.costItems || proposalDetails.cost_items)
|
||||||
|
? (proposalDetails.costItems || proposalDetails.cost_items).map((item: any) => ({
|
||||||
|
description: item.description || item.itemDescription || item.item_description || '',
|
||||||
|
amount: Number(item.amount) || 0,
|
||||||
|
gstRate: Number(item.gstRate ?? item.gst_rate ?? 0),
|
||||||
|
gstAmt: Number(item.gstAmt ?? item.gst_amt ?? 0),
|
||||||
|
cgstAmt: Number(item.cgstAmt ?? item.cgst_amt ?? 0),
|
||||||
|
sgstAmt: Number(item.sgstAmt ?? item.sgst_amt ?? 0),
|
||||||
|
igstAmt: Number(item.igstAmt ?? item.igst_amt ?? 0),
|
||||||
|
totalAmt: Number(item.totalAmt ?? item.total_amt ?? 0)
|
||||||
|
}))
|
||||||
|
: Array.isArray(proposalDetails.costBreakup || proposalDetails.cost_breakup)
|
||||||
|
? (proposalDetails.costBreakup || proposalDetails.cost_breakup).map((item: any) => ({
|
||||||
|
description: item.description || item.itemDescription || item.item_description || '',
|
||||||
|
amount: Number(item.amount) || 0,
|
||||||
|
gstRate: item.gstRate ?? item.gst_rate,
|
||||||
|
gstAmt: item.gstAmt ?? item.gst_amt,
|
||||||
|
cgstAmt: item.cgstAmt ?? item.cgst_amt,
|
||||||
|
sgstAmt: item.sgstAmt ?? item.sgst_amt,
|
||||||
|
igstAmt: item.igstAmt ?? item.igst_amt,
|
||||||
|
totalAmt: item.totalAmt ?? item.total_amt
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
|
totalEstimatedBudget: proposalDetails.totalEstimatedBudget || proposalDetails.total_estimated_budget || 0,
|
||||||
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
|
timelineMode: proposalDetails.timelineMode || proposalDetails.timeline_mode,
|
||||||
expectedCompletionDate: expectedCompletionDate,
|
expectedCompletionDate: expectedCompletionDate,
|
||||||
@ -223,21 +277,27 @@ export function mapToClaimManagementRequest(
|
|||||||
|
|
||||||
// Map DMS details from new invoice and credit note tables
|
// Map DMS details from new invoice and credit note tables
|
||||||
const dmsDetails = {
|
const dmsDetails = {
|
||||||
eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number ||
|
eInvoiceNumber: invoice.invoiceNumber || invoice.invoice_number ||
|
||||||
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
|
claimDetails.eInvoiceNumber || claimDetails.e_invoice_number,
|
||||||
eInvoiceDate: invoice.invoiceDate || invoice.invoice_date ||
|
eInvoiceDate: invoice.invoiceDate || invoice.invoice_date ||
|
||||||
claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
|
claimDetails.eInvoiceDate || claimDetails.e_invoice_date,
|
||||||
dmsNumber: invoice.dmsNumber || invoice.dms_number ||
|
dmsNumber: invoice.dmsNumber || invoice.dms_number ||
|
||||||
claimDetails.dmsNumber || claimDetails.dms_number,
|
claimDetails.dmsNumber || claimDetails.dms_number,
|
||||||
creditNoteNumber: creditNote.creditNoteNumber || creditNote.credit_note_number ||
|
creditNoteNumber: creditNote.creditNoteNumber || creditNote.credit_note_number ||
|
||||||
claimDetails.creditNoteNumber || claimDetails.credit_note_number,
|
claimDetails.creditNoteNumber || claimDetails.credit_note_number,
|
||||||
creditNoteDate: creditNote.creditNoteDate || creditNote.credit_note_date ||
|
creditNoteDate: creditNote.creditNoteDate || creditNote.credit_note_date ||
|
||||||
claimDetails.creditNoteDate || claimDetails.credit_note_date,
|
claimDetails.creditNoteDate || claimDetails.credit_note_date,
|
||||||
creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
|
creditNoteAmount: creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
|
||||||
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
|
(creditNote.credit_note_amount ? Number(creditNote.credit_note_amount) :
|
||||||
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
|
(creditNote.creditNoteAmount ? Number(creditNote.creditNoteAmount) :
|
||||||
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
|
(claimDetails.creditNoteAmount ? Number(claimDetails.creditNoteAmount) :
|
||||||
(claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))),
|
(claimDetails.credit_note_amount ? Number(claimDetails.credit_note_amount) : undefined)))),
|
||||||
|
// PWC fields
|
||||||
|
irn: invoice.irn || claimDetails.irn,
|
||||||
|
ackNo: invoice.ackNo || claimDetails.ackNo,
|
||||||
|
ackDate: invoice.ackDate || claimDetails.ackDate,
|
||||||
|
signedInvoiceUrl: invoice.signedInvoiceUrl || claimDetails.signedInvoiceUrl,
|
||||||
|
taxBreakdown: invoice.taxBreakdown || claimDetails.taxBreakdown,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map claim amounts
|
// Map claim amounts
|
||||||
@ -266,15 +326,15 @@ export function mapToClaimManagementRequest(
|
|||||||
export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole {
|
export function determineUserRole(apiRequest: any, currentUserId: string): RequestRole {
|
||||||
try {
|
try {
|
||||||
// Check if user is the initiator
|
// Check if user is the initiator
|
||||||
if (apiRequest.initiatorId === currentUserId ||
|
if (apiRequest.initiatorId === currentUserId ||
|
||||||
apiRequest.initiator?.userId === currentUserId ||
|
apiRequest.initiator?.userId === currentUserId ||
|
||||||
apiRequest.requestedBy?.userId === currentUserId) {
|
apiRequest.requestedBy?.userId === currentUserId) {
|
||||||
return 'INITIATOR';
|
return 'INITIATOR';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is a dealer (participant with DEALER type)
|
// Check if user is a dealer (participant with DEALER type)
|
||||||
const participants = apiRequest.participants || [];
|
const participants = apiRequest.participants || [];
|
||||||
const dealerParticipant = participants.find((p: any) =>
|
const dealerParticipant = participants.find((p: any) =>
|
||||||
(p.userId === currentUserId || p.user?.userId === currentUserId) &&
|
(p.userId === currentUserId || p.user?.userId === currentUserId) &&
|
||||||
(p.participantType === 'DEALER' || p.type === 'DEALER')
|
(p.participantType === 'DEALER' || p.type === 'DEALER')
|
||||||
);
|
);
|
||||||
@ -284,7 +344,7 @@ export function determineUserRole(apiRequest: any, currentUserId: string): Reque
|
|||||||
|
|
||||||
// Check if user is a department lead (approver at level 3)
|
// Check if user is a department lead (approver at level 3)
|
||||||
const approvalLevels = apiRequest.approvalLevels || [];
|
const approvalLevels = apiRequest.approvalLevels || [];
|
||||||
const deptLeadLevel = approvalLevels.find((level: any) =>
|
const deptLeadLevel = approvalLevels.find((level: any) =>
|
||||||
level.levelNumber === 3 &&
|
level.levelNumber === 3 &&
|
||||||
(level.approverId === currentUserId || level.approver?.userId === currentUserId)
|
(level.approverId === currentUserId || level.approver?.userId === currentUserId)
|
||||||
);
|
);
|
||||||
@ -293,7 +353,7 @@ export function determineUserRole(apiRequest: any, currentUserId: string): Reque
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is an approver
|
// Check if user is an approver
|
||||||
const approverLevel = approvalLevels.find((level: any) =>
|
const approverLevel = approvalLevels.find((level: any) =>
|
||||||
(level.approverId === currentUserId || level.approver?.userId === currentUserId) &&
|
(level.approverId === currentUserId || level.approver?.userId === currentUserId) &&
|
||||||
level.status === 'PENDING'
|
level.status === 'PENDING'
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,166 +2,7 @@
|
|||||||
// This database is exclusively for claim management requests created via ClaimManagementWizard
|
// This database is exclusively for claim management requests created via ClaimManagementWizard
|
||||||
// Template: Claim Management (8-step workflow)
|
// Template: Claim Management (8-step workflow)
|
||||||
|
|
||||||
export const CLAIM_MANAGEMENT_DATABASE: any = {
|
export const CLAIM_MANAGEMENT_DATABASE: any = {};
|
||||||
'RE-REQ-2024-CM-001': {
|
|
||||||
id: 'RE-REQ-2024-CM-001',
|
|
||||||
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
|
|
||||||
description: 'Claim request for dealer-led Diwali festival marketing campaign including showroom decoration, test ride events, customer engagement activities, and promotional merchandise distribution. Activity conducted at Royal Motors Mumbai dealership.',
|
|
||||||
category: 'Dealer Operations',
|
|
||||||
subcategory: 'Claim Management',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: 'TBD',
|
|
||||||
slaProgress: 35,
|
|
||||||
slaRemaining: '4 days 12 hours',
|
|
||||||
slaEndDate: 'Oct 16, 2024 5:00 PM',
|
|
||||||
currentStep: 1,
|
|
||||||
totalSteps: 8,
|
|
||||||
template: 'claim-management',
|
|
||||||
templateName: 'Claim Management',
|
|
||||||
initiator: {
|
|
||||||
name: 'Sneha Patil',
|
|
||||||
role: 'Regional Marketing Coordinator',
|
|
||||||
department: 'Marketing - West Zone',
|
|
||||||
email: 'sneha.patil@royalenfield.com',
|
|
||||||
phone: '+91 98765 43250',
|
|
||||||
avatar: 'SP'
|
|
||||||
},
|
|
||||||
department: 'Marketing - West Zone',
|
|
||||||
createdAt: 'Oct 7, 2024 9:30 AM',
|
|
||||||
updatedAt: 'Oct 7, 2024 9:30 AM',
|
|
||||||
dueDate: '2024-10-16T17:00:00Z',
|
|
||||||
conclusionRemark: '',
|
|
||||||
claimDetails: {
|
|
||||||
activityName: 'Diwali Festival Campaign 2024',
|
|
||||||
activityType: 'Marketing Activity',
|
|
||||||
activityDate: 'Oct 5, 2024',
|
|
||||||
location: 'Mumbai, Maharashtra',
|
|
||||||
dealerCode: 'RE-MH-001',
|
|
||||||
dealerName: 'Royal Motors Mumbai',
|
|
||||||
dealerEmail: 'dealer@royalmotorsmumbai.com',
|
|
||||||
dealerPhone: '+91 98765 12345',
|
|
||||||
dealerAddress: '123 Main Street, Andheri West, Mumbai, Maharashtra 400053',
|
|
||||||
requestDescription: 'Marketing campaign for Diwali festival including showroom decoration, test ride events, customer engagement activities, and promotional merchandise distribution at Royal Motors Mumbai dealership.',
|
|
||||||
estimatedBudget: '₹2,45,000',
|
|
||||||
periodStart: 'Oct 1, 2024',
|
|
||||||
periodEnd: 'Oct 10, 2024'
|
|
||||||
},
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Royal Motors Mumbai (Dealer)',
|
|
||||||
role: 'Dealer - Document Upload',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 12,
|
|
||||||
assignedAt: '2024-10-07T09:30:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Sneha Patil (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: 'Royal Motors Mumbai (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: 'Sneha Patil (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: 'Meera Patel',
|
|
||||||
role: 'Finance - Credit Note Issuance',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Finance team issues credit note to dealer'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Claim_Proposal_Diwali_2024.pdf', size: '1.8 MB', type: 'PDF', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:35 AM' },
|
|
||||||
{ name: 'Cost_Breakup_Detailed.xlsx', size: '450 KB', type: 'Excel', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:38 AM' },
|
|
||||||
{ name: 'Activity_Timeline.pdf', size: '320 KB', type: 'PDF', uploadedBy: 'Sneha Patil', uploadedAt: 'Oct 7, 2024 9:40 AM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Arjun Menon', role: 'Brand Manager', avatar: 'AM' },
|
|
||||||
{ name: 'Finance Team', role: 'Budget Monitoring', avatar: 'FT' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Claim Request Created', details: 'Diwali festival campaign claim initiated using Claim Management template', user: 'Sneha Patil', timestamp: 'Oct 7, 2024 9:30 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Dealer', details: 'Dealer Royal Motors Mumbai assigned for document upload', user: 'System', timestamp: 'Oct 7, 2024 9:31 AM' },
|
|
||||||
{ type: 'status_change', action: 'Workflow Started', details: 'Step 1: Dealer document upload phase initiated', user: 'System', timestamp: 'Oct 7, 2024 9:31 AM' }
|
|
||||||
],
|
|
||||||
tags: ['claim-management', 'dealer-activity', 'marketing', 'diwali-campaign', 'template']
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// API Endpoints for Claim Management (to be implemented with backend)
|
// API Endpoints for Claim Management (to be implemented with backend)
|
||||||
export const CLAIM_MANAGEMENT_API_ENDPOINTS = {
|
export const CLAIM_MANAGEMENT_API_ENDPOINTS = {
|
||||||
|
|||||||
@ -2,720 +2,7 @@
|
|||||||
// This database is exclusively for custom requests created via NewRequestWizard
|
// This database is exclusively for custom requests created via NewRequestWizard
|
||||||
// Users define their own workflow, approvers, spectators, and tagged participants
|
// Users define their own workflow, approvers, spectators, and tagged participants
|
||||||
|
|
||||||
export const CUSTOM_REQUEST_DATABASE: any = {
|
export const CUSTOM_REQUEST_DATABASE: any = {};
|
||||||
'RE-REQ-2024-001': {
|
|
||||||
id: 'RE-REQ-2024-001',
|
|
||||||
title: 'Himalayan 450 Launch Campaign - Digital Media Blitz',
|
|
||||||
description: 'Comprehensive digital marketing campaign for Himalayan 450 adventure motorcycle launch. Includes social media campaigns, influencer partnerships, performance marketing, content creation, and digital advertising across platforms. Target: Reach 10M adventure enthusiasts across India.\n\nEquipment Specifications:\n• 10x MacBook Pro 16-inch (M2 Pro chip)\n• 5x Professional Camera Kits (Canon EOS R5)\n• Video Editing Workstations\n• Social Media Management Tools',
|
|
||||||
category: 'Marketing & Campaigns',
|
|
||||||
subcategory: 'Digital Marketing',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'express',
|
|
||||||
amount: '₹3,75,00,000',
|
|
||||||
slaProgress: 65,
|
|
||||||
slaRemaining: '8 hours 45 minutes',
|
|
||||||
slaEndDate: 'Oct 9, 2024 5:00 PM',
|
|
||||||
currentStep: 1,
|
|
||||||
totalSteps: 3,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Priya Sharma',
|
|
||||||
role: 'Senior Digital Marketing Manager',
|
|
||||||
department: 'Marketing',
|
|
||||||
email: 'priya.sharma@royalenfield.com',
|
|
||||||
phone: '+91 98765 43210',
|
|
||||||
avatar: 'PS'
|
|
||||||
},
|
|
||||||
department: 'Marketing',
|
|
||||||
createdAt: 'Oct 6, 2024 10:30 AM',
|
|
||||||
updatedAt: 'Oct 7, 2024 2:15 PM',
|
|
||||||
dueDate: '2024-10-09T17:00:00Z',
|
|
||||||
conclusionRemark: '',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Rajesh Kumar',
|
|
||||||
role: 'Marketing Director - India',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 24,
|
|
||||||
elapsedHours: 22,
|
|
||||||
assignedAt: '2024-10-06T10:30:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Amit Desai',
|
|
||||||
role: 'VP Product Marketing',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Deepika Sharma',
|
|
||||||
role: 'VP Sales & Marketing',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Himalayan_450_Digital_Strategy.pdf', size: '5.2 MB', type: 'PDF', uploadedBy: 'Priya Sharma', uploadedAt: 'Oct 6, 2024 10:45 AM' },
|
|
||||||
{ name: 'Budget_Breakdown_Q4_2024.xlsx', size: '980 KB', type: 'Excel', uploadedBy: 'Priya Sharma', uploadedAt: 'Oct 6, 2024 11:15 AM' },
|
|
||||||
{ name: 'Influencer_Partnership_List.xlsx', size: '450 KB', type: 'Excel', uploadedBy: 'Marketing Team', uploadedAt: 'Oct 6, 2024 2:30 PM' },
|
|
||||||
{ name: 'Creative_Campaign_Assets.zip', size: '125 MB', type: 'ZIP', uploadedBy: 'Creative Team', uploadedAt: 'Oct 6, 2024 4:15 PM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Sarah Khan', role: 'Brand Strategy Lead', avatar: 'SK' },
|
|
||||||
{ name: 'Finance Team', role: 'Budget Approval', avatar: 'FT' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'New digital marketing campaign request submitted', user: 'Priya Sharma', timestamp: 'Oct 6, 2024 10:30 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Rajesh Kumar', details: 'Forwarded to Marketing Director for review', user: 'System', timestamp: 'Oct 6, 2024 10:31 AM' },
|
|
||||||
{ type: 'comment', action: 'Work Note Added', details: 'Reviewed budget allocation and target metrics', user: 'Rajesh Kumar', timestamp: 'Oct 7, 2024 2:15 PM' },
|
|
||||||
{ type: 'reminder', action: 'SLA Reminder', details: 'TAT approaching - 8 hours remaining', user: 'System', timestamp: 'Oct 7, 2024 8:15 AM' }
|
|
||||||
],
|
|
||||||
tags: ['digital-marketing', 'launch-campaign', 'himalayan-450', 'high-priority']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-002': {
|
|
||||||
id: 'RE-REQ-2024-002',
|
|
||||||
title: 'New Laptop Procurement - Design Team Expansion',
|
|
||||||
description: 'Purchase of 10 high-performance laptops for the newly expanded Product Design team. Required specifications: Latest generation processor, 32GB RAM, dedicated graphics card for 3D modeling and rendering work.',
|
|
||||||
category: 'IT & Infrastructure',
|
|
||||||
subcategory: 'Hardware Procurement',
|
|
||||||
status: 'in-review',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: '₹12,50,000',
|
|
||||||
slaProgress: 45,
|
|
||||||
slaRemaining: '2 days 8 hours',
|
|
||||||
slaEndDate: 'Oct 11, 2024 5:00 PM',
|
|
||||||
currentStep: 2,
|
|
||||||
totalSteps: 3,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Vikram Singh',
|
|
||||||
role: 'Head - IT Operations',
|
|
||||||
department: 'Information Technology',
|
|
||||||
email: 'vikram.singh@royalenfield.com',
|
|
||||||
phone: '+91 98765 43221',
|
|
||||||
avatar: 'VS'
|
|
||||||
},
|
|
||||||
department: 'Information Technology',
|
|
||||||
createdAt: 'Oct 5, 2024 9:15 AM',
|
|
||||||
updatedAt: 'Oct 7, 2024 3:45 PM',
|
|
||||||
dueDate: '2024-10-11T17:00:00Z',
|
|
||||||
conclusionRemark: '',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Meera Patel',
|
|
||||||
role: 'IT Manager',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 24,
|
|
||||||
actualHours: 18,
|
|
||||||
assignedAt: '2024-10-05T09:15:00Z',
|
|
||||||
comment: 'Technical specifications verified. Hardware meets design team requirements.',
|
|
||||||
timestamp: '2024-10-06T03:15:00Z'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Anil Kapoor',
|
|
||||||
role: 'Finance Manager',
|
|
||||||
status: 'in-review',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 32,
|
|
||||||
assignedAt: '2024-10-06T03:15:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Ramesh Kulkarni',
|
|
||||||
role: 'VP Operations',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Laptop_Specifications.pdf', size: '850 KB', type: 'PDF', uploadedBy: 'Vikram Singh', uploadedAt: 'Oct 5, 2024 9:20 AM' },
|
|
||||||
{ name: 'Vendor_Quotations.xlsx', size: '1.2 MB', type: 'Excel', uploadedBy: 'Procurement Team', uploadedAt: 'Oct 5, 2024 11:45 AM' },
|
|
||||||
{ name: 'Team_Expansion_Plan.pdf', size: '620 KB', type: 'PDF', uploadedBy: 'Design Team', uploadedAt: 'Oct 5, 2024 2:30 PM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Design Team Lead', role: 'End Users', avatar: 'DT' },
|
|
||||||
{ name: 'Procurement Team', role: 'Vendor Management', avatar: 'PT' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'Laptop procurement request for design team', user: 'Vikram Singh', timestamp: 'Oct 5, 2024 9:15 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Meera Patel', details: 'IT Manager to verify specifications', user: 'System', timestamp: 'Oct 5, 2024 9:16 AM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Meera Patel', details: 'Technical specifications approved', user: 'Meera Patel', timestamp: 'Oct 6, 2024 3:15 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance for budget approval', user: 'System', timestamp: 'Oct 6, 2024 3:15 AM' }
|
|
||||||
],
|
|
||||||
tags: ['hardware', 'procurement', 'design-team', 'laptops']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-003': {
|
|
||||||
id: 'RE-REQ-2024-003',
|
|
||||||
title: 'Annual Service Center Expansion - Western Region',
|
|
||||||
description: 'Proposal for opening 15 new authorized service centers across tier-2 cities in Western region. Includes infrastructure setup, technician training, spare parts inventory, and marketing support. Expected to improve service accessibility by 35% in the target region.',
|
|
||||||
category: 'Operations & Expansion',
|
|
||||||
subcategory: 'Service Network',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: '₹8,75,00,000',
|
|
||||||
slaProgress: 78,
|
|
||||||
slaRemaining: '1 day 4 hours',
|
|
||||||
slaEndDate: 'Oct 10, 2024 5:00 PM',
|
|
||||||
currentStep: 1,
|
|
||||||
totalSteps: 4,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Sanjay Reddy',
|
|
||||||
role: 'Regional Service Manager - West',
|
|
||||||
department: 'After Sales Service',
|
|
||||||
email: 'sanjay.reddy@royalenfield.com',
|
|
||||||
phone: '+91 98765 43232',
|
|
||||||
avatar: 'SR'
|
|
||||||
},
|
|
||||||
department: 'After Sales Service',
|
|
||||||
createdAt: 'Oct 3, 2024 8:45 AM',
|
|
||||||
updatedAt: 'Oct 6, 2024 5:45 PM',
|
|
||||||
dueDate: '2024-10-10T17:00:00Z',
|
|
||||||
conclusionRemark: '',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Ramesh Kulkarni',
|
|
||||||
role: 'Head - After Sales Service',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 85,
|
|
||||||
assignedAt: '2024-10-03T08:45:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Finance Team',
|
|
||||||
role: 'Budget Allocation',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 96,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Legal Team',
|
|
||||||
role: 'Compliance Review',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 120,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 4,
|
|
||||||
approver: 'Deepika Sharma',
|
|
||||||
role: 'VP Sales & Marketing',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Western_Region_Expansion_Plan.pdf', size: '7.5 MB', type: 'PDF', uploadedBy: 'Sanjay Reddy', uploadedAt: 'Oct 3, 2024 9:00 AM' },
|
|
||||||
{ name: 'Service_Center_Requirements.xlsx', size: '2.8 MB', type: 'Excel', uploadedBy: 'Planning Team', uploadedAt: 'Oct 3, 2024 11:30 AM' },
|
|
||||||
{ name: 'Customer_Demand_Analysis.pptx', size: '4.2 MB', type: 'PowerPoint', uploadedBy: 'Analytics Team', uploadedAt: 'Oct 4, 2024 2:15 PM' },
|
|
||||||
{ name: 'ROI_Projections_Service_Network.xlsx', size: '1.9 MB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Oct 5, 2024 10:45 AM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Regional Managers', role: 'Service Operations', avatar: 'RM' },
|
|
||||||
{ name: 'Training Team', role: 'Technician Development', avatar: 'TT' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'Service center expansion proposal submitted', user: 'Sanjay Reddy', timestamp: 'Oct 3, 2024 8:45 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Ramesh Kulkarni', details: 'Forwarded to Head of After Sales Service', user: 'System', timestamp: 'Oct 3, 2024 8:46 AM' },
|
|
||||||
{ type: 'reminder', action: 'Reminder Sent', details: 'TAT breach reminder sent to approver', user: 'System', timestamp: 'Oct 6, 2024 5:45 PM' },
|
|
||||||
{ type: 'updated', action: 'Additional Documents', details: 'ROI projections added by finance team', user: 'Finance Team', timestamp: 'Oct 5, 2024 10:45 AM' }
|
|
||||||
],
|
|
||||||
tags: ['service-expansion', 'western-region', 'tier2-cities', 'overdue']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-004': {
|
|
||||||
id: 'RE-REQ-2024-004',
|
|
||||||
title: 'Employee Training Program - Advanced Motorcycle Mechanics',
|
|
||||||
description: 'Comprehensive training program for 50 service center technicians covering advanced diagnostics, electrical systems, fuel injection troubleshooting, and customer service excellence. Program duration: 3 weeks. Includes certification upon completion.',
|
|
||||||
category: 'Human Resources',
|
|
||||||
subcategory: 'Training & Development',
|
|
||||||
status: 'approved',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: '₹18,50,000',
|
|
||||||
slaProgress: 100,
|
|
||||||
slaRemaining: 'Completed',
|
|
||||||
slaEndDate: 'Oct 5, 2024 5:00 PM',
|
|
||||||
currentStep: 3,
|
|
||||||
totalSteps: 3,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Kavita Menon',
|
|
||||||
role: 'Training Manager',
|
|
||||||
department: 'Human Resources',
|
|
||||||
email: 'kavita.menon@royalenfield.com',
|
|
||||||
phone: '+91 98765 43243',
|
|
||||||
avatar: 'KM'
|
|
||||||
},
|
|
||||||
department: 'Human Resources',
|
|
||||||
createdAt: 'Sep 28, 2024 11:00 AM',
|
|
||||||
updatedAt: 'Oct 5, 2024 4:30 PM',
|
|
||||||
dueDate: '2024-10-05T17:00:00Z',
|
|
||||||
submittedDate: '2024-09-28T11:00:00Z',
|
|
||||||
estimatedCompletion: 'Oct 5, 2024',
|
|
||||||
currentApprover: 'Completed',
|
|
||||||
approverLevel: '3 of 3',
|
|
||||||
conclusionRemark: 'All approvals completed. Training program scheduled for November 2024.',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Ramesh Kulkarni',
|
|
||||||
role: 'Head - After Sales Service',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 48,
|
|
||||||
actualHours: 36,
|
|
||||||
assignedAt: '2024-09-28T11:00:00Z',
|
|
||||||
comment: 'Excellent initiative. Training content approved.',
|
|
||||||
timestamp: 'Sep 29, 2024 11:00 PM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Anil Kapoor',
|
|
||||||
role: 'Finance Manager',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 72,
|
|
||||||
actualHours: 48,
|
|
||||||
assignedAt: '2024-09-29T23:00:00Z',
|
|
||||||
comment: 'Budget approved. Cost per participant is reasonable.',
|
|
||||||
timestamp: 'Oct 1, 2024 11:00 PM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Deepika Sharma',
|
|
||||||
role: 'VP Sales & Marketing',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 96,
|
|
||||||
actualHours: 72,
|
|
||||||
assignedAt: '2024-10-01T23:00:00Z',
|
|
||||||
comment: 'Final approval granted. Proceed with program execution.',
|
|
||||||
timestamp: 'Oct 5, 2024 4:30 PM'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Training_Curriculum.pdf', size: '3.2 MB', type: 'PDF', uploadedBy: 'Kavita Menon', uploadedAt: 'Sep 28, 2024 11:15 AM' },
|
|
||||||
{ name: 'Trainer_Profiles.pdf', size: '1.8 MB', type: 'PDF', uploadedBy: 'HR Team', uploadedAt: 'Sep 28, 2024 2:45 PM' },
|
|
||||||
{ name: 'Budget_Training_Program.xlsx', size: '680 KB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Sep 29, 2024 10:30 AM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Service Center Managers', role: 'Participant Coordination', avatar: 'SC' },
|
|
||||||
{ name: 'Quality Assurance', role: 'Training Quality', avatar: 'QA' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'Training program proposal submitted', user: 'Kavita Menon', timestamp: 'Sep 28, 2024 11:00 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Ramesh Kulkarni', details: 'Forwarded to After Sales Service Head', user: 'System', timestamp: 'Sep 28, 2024 11:01 AM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Ramesh Kulkarni', details: 'Level 1 approval completed', user: 'Ramesh Kulkarni', timestamp: 'Sep 29, 2024 11:00 PM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Sep 29, 2024 11:01 PM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Anil Kapoor', details: 'Budget approval completed', user: 'Anil Kapoor', timestamp: 'Oct 1, 2024 11:00 PM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Deepika Sharma', details: 'Forwarded to VP for final approval', user: 'System', timestamp: 'Oct 1, 2024 11:01 PM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Deepika Sharma', details: 'Final approval - Request completed', user: 'Deepika Sharma', timestamp: 'Oct 5, 2024 4:30 PM' },
|
|
||||||
{ type: 'completed', action: 'Request Completed', details: 'All approvals obtained. Training scheduled.', user: 'System', timestamp: 'Oct 5, 2024 4:31 PM' }
|
|
||||||
],
|
|
||||||
tags: ['training', 'technicians', 'approved', 'completed']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-005': {
|
|
||||||
id: 'RE-REQ-2024-005',
|
|
||||||
title: 'Showroom Renovation - Chennai Flagship Store',
|
|
||||||
description: 'Complete renovation of Chennai flagship showroom including modern interior design, interactive display zones, customer lounge upgrade, motorcycle test ride facility, and digital experience center. Project timeline: 8 weeks.',
|
|
||||||
category: 'Infrastructure',
|
|
||||||
subcategory: 'Retail & Showroom',
|
|
||||||
status: 'rejected',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: '₹65,00,000',
|
|
||||||
slaProgress: 100,
|
|
||||||
slaRemaining: 'Rejected',
|
|
||||||
slaEndDate: 'Oct 4, 2024 5:00 PM',
|
|
||||||
currentStep: 2,
|
|
||||||
totalSteps: 4,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Arjun Nair',
|
|
||||||
role: 'Showroom Manager - South',
|
|
||||||
department: 'Retail Operations',
|
|
||||||
email: 'arjun.nair@royalenfield.com',
|
|
||||||
phone: '+91 98765 43254',
|
|
||||||
avatar: 'AN'
|
|
||||||
},
|
|
||||||
department: 'Retail Operations',
|
|
||||||
createdAt: 'Oct 1, 2024 9:30 AM',
|
|
||||||
updatedAt: 'Oct 4, 2024 3:15 PM',
|
|
||||||
dueDate: '2024-10-04T17:00:00Z',
|
|
||||||
submittedDate: '2024-10-01T09:30:00Z',
|
|
||||||
estimatedCompletion: 'N/A',
|
|
||||||
currentApprover: 'Rejected by Anil Kapoor',
|
|
||||||
approverLevel: '2 of 4',
|
|
||||||
conclusionRemark: 'Request rejected due to insufficient budget justification. Please revise with detailed ROI analysis.',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Suresh Iyer',
|
|
||||||
role: 'Regional Manager - South',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 48,
|
|
||||||
actualHours: 24,
|
|
||||||
assignedAt: '2024-10-01T09:30:00Z',
|
|
||||||
comment: 'Renovation is necessary. Current showroom needs upgrade.',
|
|
||||||
timestamp: 'Oct 2, 2024 9:30 AM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Anil Kapoor',
|
|
||||||
role: 'Finance Manager',
|
|
||||||
status: 'rejected',
|
|
||||||
tatHours: 72,
|
|
||||||
actualHours: 48,
|
|
||||||
assignedAt: '2024-10-02T09:30:00Z',
|
|
||||||
comment: 'Budget allocation not justified. Need detailed ROI analysis and comparison with alternative renovation options. Please revise and resubmit with comprehensive financial projections.',
|
|
||||||
timestamp: 'Oct 4, 2024 3:15 PM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Legal Team',
|
|
||||||
role: 'Compliance & Contracts',
|
|
||||||
status: 'cancelled',
|
|
||||||
tatHours: 96,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 4,
|
|
||||||
approver: 'Ramesh Kulkarni',
|
|
||||||
role: 'VP Operations',
|
|
||||||
status: 'cancelled',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Showroom_Renovation_Plan.pdf', size: '12.5 MB', type: 'PDF', uploadedBy: 'Arjun Nair', uploadedAt: 'Oct 1, 2024 9:45 AM' },
|
|
||||||
{ name: 'Interior_Design_Mockups.zip', size: '85 MB', type: 'ZIP', uploadedBy: 'Design Team', uploadedAt: 'Oct 1, 2024 2:30 PM' },
|
|
||||||
{ name: 'Contractor_Quotations.xlsx', size: '2.1 MB', type: 'Excel', uploadedBy: 'Procurement Team', uploadedAt: 'Oct 2, 2024 11:15 AM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Marketing Team', role: 'Brand Experience', avatar: 'MT' },
|
|
||||||
{ name: 'Customer Experience', role: 'Feedback & Analysis', avatar: 'CX' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'Showroom renovation request submitted', user: 'Arjun Nair', timestamp: 'Oct 1, 2024 9:30 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Suresh Iyer', details: 'Forwarded to Regional Manager', user: 'System', timestamp: 'Oct 1, 2024 9:31 AM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Suresh Iyer', details: 'Level 1 approval completed', user: 'Suresh Iyer', timestamp: 'Oct 2, 2024 9:30 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 2, 2024 9:31 AM' },
|
|
||||||
{ type: 'rejection', action: 'Rejected by Anil Kapoor', details: 'Budget justification insufficient', user: 'Anil Kapoor', timestamp: 'Oct 4, 2024 3:15 PM' },
|
|
||||||
{ type: 'completed', action: 'Request Rejected', details: 'Workflow terminated. Requires resubmission with revisions.', user: 'System', timestamp: 'Oct 4, 2024 3:16 PM' }
|
|
||||||
],
|
|
||||||
tags: ['showroom', 'renovation', 'rejected', 'south-region']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-006': {
|
|
||||||
id: 'RE-REQ-2024-006',
|
|
||||||
title: 'Spare Parts Inventory Optimization System',
|
|
||||||
description: 'Implementation of AI-powered inventory management system for spare parts across all service centers. Features include demand forecasting, automated reordering, stock level optimization, and real-time tracking. Expected to reduce inventory costs by 20% and improve part availability.',
|
|
||||||
category: 'Technology & Innovation',
|
|
||||||
subcategory: 'Software Implementation',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'express',
|
|
||||||
amount: '₹42,00,000',
|
|
||||||
slaProgress: 35,
|
|
||||||
slaRemaining: '1 day 16 hours',
|
|
||||||
slaEndDate: 'Oct 12, 2024 5:00 PM',
|
|
||||||
currentStep: 1,
|
|
||||||
totalSteps: 4,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Rahul Deshmukh',
|
|
||||||
role: 'Head - Supply Chain Technology',
|
|
||||||
department: 'Supply Chain',
|
|
||||||
email: 'rahul.deshmukh@royalenfield.com',
|
|
||||||
phone: '+91 98765 43265',
|
|
||||||
avatar: 'RD'
|
|
||||||
},
|
|
||||||
department: 'Supply Chain',
|
|
||||||
createdAt: 'Oct 7, 2024 10:00 AM',
|
|
||||||
updatedAt: 'Oct 8, 2024 9:15 AM',
|
|
||||||
dueDate: '2024-10-12T17:00:00Z',
|
|
||||||
submittedDate: '2024-10-07T10:00:00Z',
|
|
||||||
estimatedCompletion: 'Oct 12, 2024',
|
|
||||||
currentApprover: 'Vikram Singh',
|
|
||||||
approverLevel: '1 of 4',
|
|
||||||
conclusionRemark: '',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Vikram Singh',
|
|
||||||
role: 'Head - IT Operations',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 23,
|
|
||||||
assignedAt: '2024-10-07T10:00:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Supply Chain Director',
|
|
||||||
role: 'Operations Approval',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Anil Kapoor',
|
|
||||||
role: 'Finance Manager',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 96,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 4,
|
|
||||||
approver: 'Ramesh Kulkarni',
|
|
||||||
role: 'VP Operations',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'AI_Inventory_System_Proposal.pdf', size: '8.9 MB', type: 'PDF', uploadedBy: 'Rahul Deshmukh', uploadedAt: 'Oct 7, 2024 10:15 AM' },
|
|
||||||
{ name: 'Vendor_Comparison_Analysis.xlsx', size: '3.4 MB', type: 'Excel', uploadedBy: 'IT Procurement', uploadedAt: 'Oct 7, 2024 2:45 PM' },
|
|
||||||
{ name: 'Cost_Benefit_Analysis.pptx', size: '6.2 MB', type: 'PowerPoint', uploadedBy: 'Analytics Team', uploadedAt: 'Oct 7, 2024 4:30 PM' },
|
|
||||||
{ name: 'Implementation_Timeline.pdf', size: '1.5 MB', type: 'PDF', uploadedBy: 'Project Management', uploadedAt: 'Oct 8, 2024 9:15 AM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Service Center Network', role: 'End Users', avatar: 'SN' },
|
|
||||||
{ name: 'Data Analytics Team', role: 'System Integration', avatar: 'DA' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'AI inventory system proposal submitted', user: 'Rahul Deshmukh', timestamp: 'Oct 7, 2024 10:00 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Vikram Singh', details: 'Forwarded to IT Operations Head', user: 'System', timestamp: 'Oct 7, 2024 10:01 AM' },
|
|
||||||
{ type: 'updated', action: 'Documents Added', details: 'Implementation timeline document uploaded', user: 'Project Management', timestamp: 'Oct 8, 2024 9:15 AM' }
|
|
||||||
],
|
|
||||||
tags: ['technology', 'ai', 'inventory', 'supply-chain', 'high-priority']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-007': {
|
|
||||||
id: 'RE-REQ-2024-007',
|
|
||||||
title: 'Dealer Network Meeting - Q4 Business Review',
|
|
||||||
description: 'Quarterly business review meeting for all authorized dealers across India. Venue: Bangalore. Topics include Q3 performance review, Q4 targets, new model launches, marketing initiatives, service excellence programs, and dealer support policies. Expected attendance: 250 dealers.',
|
|
||||||
category: 'Events & Conferences',
|
|
||||||
subcategory: 'Dealer Meetings',
|
|
||||||
status: 'in-review',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: '₹28,50,000',
|
|
||||||
slaProgress: 58,
|
|
||||||
slaRemaining: '1 day 12 hours',
|
|
||||||
slaEndDate: 'Oct 11, 2024 5:00 PM',
|
|
||||||
currentStep: 2,
|
|
||||||
totalSteps: 3,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Neha Kapoor',
|
|
||||||
role: 'Dealer Network Manager',
|
|
||||||
department: 'Sales & Distribution',
|
|
||||||
email: 'neha.kapoor@royalenfield.com',
|
|
||||||
phone: '+91 98765 43276',
|
|
||||||
avatar: 'NK'
|
|
||||||
},
|
|
||||||
department: 'Sales & Distribution',
|
|
||||||
createdAt: 'Oct 6, 2024 2:00 PM',
|
|
||||||
updatedAt: 'Oct 8, 2024 11:30 AM',
|
|
||||||
dueDate: '2024-10-11T17:00:00Z',
|
|
||||||
submittedDate: '2024-10-06T14:00:00Z',
|
|
||||||
estimatedCompletion: 'Oct 11, 2024',
|
|
||||||
currentApprover: 'Anil Kapoor',
|
|
||||||
approverLevel: '2 of 3',
|
|
||||||
conclusionRemark: '',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Suresh Mehta',
|
|
||||||
role: 'Sales Director',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 48,
|
|
||||||
actualHours: 36,
|
|
||||||
assignedAt: '2024-10-06T14:00:00Z',
|
|
||||||
comment: 'Dealer meeting approved. Agenda looks comprehensive.',
|
|
||||||
timestamp: 'Oct 8, 2024 2:00 AM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Anil Kapoor',
|
|
||||||
role: 'Finance Manager',
|
|
||||||
status: 'in-review',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 33,
|
|
||||||
assignedAt: '2024-10-08T02:00:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Deepika Sharma',
|
|
||||||
role: 'VP Sales & Marketing',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Q4_Dealer_Meeting_Agenda.pdf', size: '2.8 MB', type: 'PDF', uploadedBy: 'Neha Kapoor', uploadedAt: 'Oct 6, 2024 2:15 PM' },
|
|
||||||
{ name: 'Venue_Booking_Confirmation.pdf', size: '980 KB', type: 'PDF', uploadedBy: 'Events Team', uploadedAt: 'Oct 6, 2024 4:45 PM' },
|
|
||||||
{ name: 'Event_Budget_Breakdown.xlsx', size: '1.2 MB', type: 'Excel', uploadedBy: 'Finance Team', uploadedAt: 'Oct 7, 2024 10:30 AM' },
|
|
||||||
{ name: 'Dealer_Invitations_List.xlsx', size: '580 KB', type: 'Excel', uploadedBy: 'Sales Team', uploadedAt: 'Oct 7, 2024 3:15 PM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Marketing Team', role: 'Presentation Support', avatar: 'MT' },
|
|
||||||
{ name: 'Events Management', role: 'Logistics Coordination', avatar: 'EM' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'Dealer meeting proposal submitted', user: 'Neha Kapoor', timestamp: 'Oct 6, 2024 2:00 PM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Suresh Mehta', details: 'Forwarded to Sales Director', user: 'System', timestamp: 'Oct 6, 2024 2:01 PM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Suresh Mehta', details: 'Sales approval completed', user: 'Suresh Mehta', timestamp: 'Oct 8, 2024 2:00 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 8, 2024 2:01 AM' },
|
|
||||||
{ type: 'updated', action: 'Documents Added', details: 'Dealer invitations list uploaded', user: 'Sales Team', timestamp: 'Oct 7, 2024 3:15 PM' }
|
|
||||||
],
|
|
||||||
tags: ['dealer-meeting', 'q4-review', 'event', 'bangalore']
|
|
||||||
},
|
|
||||||
|
|
||||||
'RE-REQ-2024-008': {
|
|
||||||
id: 'RE-REQ-2024-008',
|
|
||||||
title: 'Cybersecurity Infrastructure Upgrade',
|
|
||||||
description: 'Comprehensive upgrade of cybersecurity infrastructure including next-gen firewall, intrusion detection system, endpoint protection for 500+ devices, security information and event management (SIEM) system, and employee security awareness training. Critical for protecting customer data and business operations.',
|
|
||||||
category: 'IT & Infrastructure',
|
|
||||||
subcategory: 'Security & Compliance',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'urgent',
|
|
||||||
amount: '₹52,00,000',
|
|
||||||
slaProgress: 82,
|
|
||||||
slaRemaining: '4 hours 20 minutes',
|
|
||||||
slaEndDate: 'Oct 8, 2024 6:00 PM',
|
|
||||||
currentStep: 2,
|
|
||||||
totalSteps: 3,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Sameer Joshi',
|
|
||||||
role: 'Chief Information Security Officer',
|
|
||||||
department: 'Information Technology',
|
|
||||||
email: 'sameer.joshi@royalenfield.com',
|
|
||||||
phone: '+91 98765 43287',
|
|
||||||
avatar: 'SJ'
|
|
||||||
},
|
|
||||||
department: 'Information Technology',
|
|
||||||
createdAt: 'Oct 5, 2024 11:30 AM',
|
|
||||||
updatedAt: 'Oct 8, 2024 12:45 PM',
|
|
||||||
dueDate: '2024-10-08T18:00:00Z',
|
|
||||||
submittedDate: '2024-10-05T11:30:00Z',
|
|
||||||
estimatedCompletion: 'Oct 8, 2024',
|
|
||||||
currentApprover: 'Anil Kapoor',
|
|
||||||
approverLevel: '2 of 3',
|
|
||||||
conclusionRemark: '',
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Vikram Singh',
|
|
||||||
role: 'Head - IT Operations',
|
|
||||||
status: 'approved',
|
|
||||||
tatHours: 24,
|
|
||||||
actualHours: 18,
|
|
||||||
assignedAt: '2024-10-05T11:30:00Z',
|
|
||||||
comment: 'Critical security upgrade. Approve immediately.',
|
|
||||||
timestamp: 'Oct 6, 2024 5:30 AM'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Anil Kapoor',
|
|
||||||
role: 'Finance Manager',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 55,
|
|
||||||
assignedAt: '2024-10-06T05:30:00Z',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'Ramesh Kulkarni',
|
|
||||||
role: 'VP Operations',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [
|
|
||||||
{ name: 'Security_Assessment_Report.pdf', size: '15.3 MB', type: 'PDF', uploadedBy: 'Sameer Joshi', uploadedAt: 'Oct 5, 2024 11:45 AM' },
|
|
||||||
{ name: 'Vendor_Solutions_Comparison.xlsx', size: '4.8 MB', type: 'Excel', uploadedBy: 'IT Security Team', uploadedAt: 'Oct 5, 2024 3:30 PM' },
|
|
||||||
{ name: 'Implementation_Roadmap.pptx', size: '7.6 MB', type: 'PowerPoint', uploadedBy: 'Project Management', uploadedAt: 'Oct 6, 2024 10:15 AM' },
|
|
||||||
{ name: 'Risk_Analysis_Report.pdf', size: '5.9 MB', type: 'PDF', uploadedBy: 'Security Consultant', uploadedAt: 'Oct 6, 2024 4:45 PM' }
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{ name: 'Legal & Compliance', role: 'Data Protection', avatar: 'LC' },
|
|
||||||
{ name: 'IT Infrastructure', role: 'System Integration', avatar: 'IT' }
|
|
||||||
],
|
|
||||||
auditTrail: [
|
|
||||||
{ type: 'created', action: 'Request Created', details: 'Cybersecurity upgrade proposal submitted', user: 'Sameer Joshi', timestamp: 'Oct 5, 2024 11:30 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Vikram Singh', details: 'Forwarded to IT Operations Head', user: 'System', timestamp: 'Oct 5, 2024 11:31 AM' },
|
|
||||||
{ type: 'approval', action: 'Approved by Vikram Singh', details: 'IT approval - marked as critical', user: 'Vikram Singh', timestamp: 'Oct 6, 2024 5:30 AM' },
|
|
||||||
{ type: 'assignment', action: 'Assigned to Anil Kapoor', details: 'Forwarded to Finance Manager', user: 'System', timestamp: 'Oct 6, 2024 5:31 AM' },
|
|
||||||
{ type: 'reminder', action: 'Urgent Reminder', details: 'TAT breach warning - 4 hours remaining', user: 'System', timestamp: 'Oct 8, 2024 12:45 PM' }
|
|
||||||
],
|
|
||||||
tags: ['cybersecurity', 'urgent', 'critical', 'infrastructure', 'overdue']
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// API Endpoints for Custom Requests (to be implemented with backend)
|
// API Endpoints for Custom Requests (to be implemented with backend)
|
||||||
export const CUSTOM_REQUEST_API_ENDPOINTS = {
|
export const CUSTOM_REQUEST_API_ENDPOINTS = {
|
||||||
|
|||||||
@ -1,188 +0,0 @@
|
|||||||
// Mock Dealer Database - In production, this would be fetched from API
|
|
||||||
export interface DealerInfo {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
region: string;
|
|
||||||
managerName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DEALER_DATABASE: Record<string, DealerInfo> = {
|
|
||||||
'RE-MH-001': {
|
|
||||||
code: 'RE-MH-001',
|
|
||||||
name: 'Royal Motors Mumbai',
|
|
||||||
email: 'dealer@royalmotorsmumbai.com',
|
|
||||||
phone: '+91 98765 12345',
|
|
||||||
address: 'Shop No. 12-15, Central Avenue, Andheri West',
|
|
||||||
city: 'Mumbai',
|
|
||||||
state: 'Maharashtra',
|
|
||||||
region: 'West',
|
|
||||||
managerName: 'Rahul Deshmukh'
|
|
||||||
},
|
|
||||||
'RE-DL-002': {
|
|
||||||
code: 'RE-DL-002',
|
|
||||||
name: 'Delhi Enfield Center',
|
|
||||||
email: 'contact@delhienfield.com',
|
|
||||||
phone: '+91 98765 23456',
|
|
||||||
address: '45-48, Rajouri Garden, Main Market',
|
|
||||||
city: 'New Delhi',
|
|
||||||
state: 'Delhi',
|
|
||||||
region: 'North',
|
|
||||||
managerName: 'Vikram Singh'
|
|
||||||
},
|
|
||||||
'RE-BLR-003': {
|
|
||||||
code: 'RE-BLR-003',
|
|
||||||
name: 'Bangalore Royal Bikes',
|
|
||||||
email: 'info@bangaloreroyalbikes.com',
|
|
||||||
phone: '+91 98765 34567',
|
|
||||||
address: '123, MG Road, Near Trinity Metro',
|
|
||||||
city: 'Bangalore',
|
|
||||||
state: 'Karnataka',
|
|
||||||
region: 'South',
|
|
||||||
managerName: 'Suresh Kumar'
|
|
||||||
},
|
|
||||||
'RE-CHN-004': {
|
|
||||||
code: 'RE-CHN-004',
|
|
||||||
name: 'Chennai Enfield Hub',
|
|
||||||
email: 'chennai@enfieldhub.com',
|
|
||||||
phone: '+91 98765 45678',
|
|
||||||
address: '78-80, Anna Salai, T Nagar',
|
|
||||||
city: 'Chennai',
|
|
||||||
state: 'Tamil Nadu',
|
|
||||||
region: 'South',
|
|
||||||
managerName: 'Venkat Ramanan'
|
|
||||||
},
|
|
||||||
'RE-HYD-005': {
|
|
||||||
code: 'RE-HYD-005',
|
|
||||||
name: 'Hyderabad Royal Motorcycles',
|
|
||||||
email: 'hyderabad@royalmotorcycles.com',
|
|
||||||
phone: '+91 98765 56789',
|
|
||||||
address: '234, Banjara Hills, Road No. 12',
|
|
||||||
city: 'Hyderabad',
|
|
||||||
state: 'Telangana',
|
|
||||||
region: 'South',
|
|
||||||
managerName: 'Anil Reddy'
|
|
||||||
},
|
|
||||||
'RE-KOL-006': {
|
|
||||||
code: 'RE-KOL-006',
|
|
||||||
name: 'Kolkata Enfield Motors',
|
|
||||||
email: 'kolkata@enfieldmotors.com',
|
|
||||||
phone: '+91 98765 67890',
|
|
||||||
address: '56-58, Park Street, Near Park Hotel',
|
|
||||||
city: 'Kolkata',
|
|
||||||
state: 'West Bengal',
|
|
||||||
region: 'East',
|
|
||||||
managerName: 'Amit Chatterjee'
|
|
||||||
},
|
|
||||||
'RE-PUN-007': {
|
|
||||||
code: 'RE-PUN-007',
|
|
||||||
name: 'Pune Royal Dealership',
|
|
||||||
email: 'pune@royaldealership.com',
|
|
||||||
phone: '+91 98765 78901',
|
|
||||||
address: '345, FC Road, Deccan Gymkhana',
|
|
||||||
city: 'Pune',
|
|
||||||
state: 'Maharashtra',
|
|
||||||
region: 'West',
|
|
||||||
managerName: 'Sandeep Patil'
|
|
||||||
},
|
|
||||||
'RE-AHM-008': {
|
|
||||||
code: 'RE-AHM-008',
|
|
||||||
name: 'Ahmedabad Enfield Plaza',
|
|
||||||
email: 'ahmedabad@enfieldplaza.com',
|
|
||||||
phone: '+91 98765 89012',
|
|
||||||
address: '123, CG Road, Navrangpura',
|
|
||||||
city: 'Ahmedabad',
|
|
||||||
state: 'Gujarat',
|
|
||||||
region: 'West',
|
|
||||||
managerName: 'Kiran Patel'
|
|
||||||
},
|
|
||||||
'RE-JP-009': {
|
|
||||||
code: 'RE-JP-009',
|
|
||||||
name: 'Jaipur Royal Enfield',
|
|
||||||
email: 'jaipur@royalenfield.com',
|
|
||||||
phone: '+91 98765 90123',
|
|
||||||
address: '67, MI Road, C-Scheme',
|
|
||||||
city: 'Jaipur',
|
|
||||||
state: 'Rajasthan',
|
|
||||||
region: 'North',
|
|
||||||
managerName: 'Rajesh Sharma'
|
|
||||||
},
|
|
||||||
'RE-LKO-010': {
|
|
||||||
code: 'RE-LKO-010',
|
|
||||||
name: 'Lucknow Enfield Showroom',
|
|
||||||
email: 'lucknow@enfieldshowroom.com',
|
|
||||||
phone: '+91 98765 01234',
|
|
||||||
address: '89, Hazratganj, Near Halwasiya Crossing',
|
|
||||||
city: 'Lucknow',
|
|
||||||
state: 'Uttar Pradesh',
|
|
||||||
region: 'North',
|
|
||||||
managerName: 'Ankit Verma'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get dealer information by dealer code
|
|
||||||
* @param dealerCode - The dealer code (e.g., 'RE-MH-001')
|
|
||||||
* @returns DealerInfo object or null if not found
|
|
||||||
*/
|
|
||||||
export function getDealerInfo(dealerCode: string): DealerInfo | null {
|
|
||||||
return DEALER_DATABASE[dealerCode] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all dealers for a specific region
|
|
||||||
* @param region - Region name (North, South, East, West)
|
|
||||||
* @returns Array of DealerInfo objects
|
|
||||||
*/
|
|
||||||
export function getDealersByRegion(region: string): DealerInfo[] {
|
|
||||||
return Object.values(DEALER_DATABASE).filter(
|
|
||||||
dealer => dealer.region.toLowerCase() === region.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all dealers for a specific state
|
|
||||||
* @param state - State name
|
|
||||||
* @returns Array of DealerInfo objects
|
|
||||||
*/
|
|
||||||
export function getDealersByState(state: string): DealerInfo[] {
|
|
||||||
return Object.values(DEALER_DATABASE).filter(
|
|
||||||
dealer => dealer.state.toLowerCase() === state.toLowerCase()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all dealers as an array (for dropdowns, etc.)
|
|
||||||
* @returns Array of DealerInfo objects
|
|
||||||
*/
|
|
||||||
export function getAllDealers(): DealerInfo[] {
|
|
||||||
return Object.values(DEALER_DATABASE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search dealers by name or code
|
|
||||||
* @param searchTerm - Search term
|
|
||||||
* @returns Array of matching DealerInfo objects
|
|
||||||
*/
|
|
||||||
export function searchDealers(searchTerm: string): DealerInfo[] {
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
return Object.values(DEALER_DATABASE).filter(
|
|
||||||
dealer =>
|
|
||||||
dealer.name.toLowerCase().includes(term) ||
|
|
||||||
dealer.code.toLowerCase().includes(term) ||
|
|
||||||
dealer.city.toLowerCase().includes(term)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format dealer address for display
|
|
||||||
* @param dealer - DealerInfo object
|
|
||||||
* @returns Formatted address string
|
|
||||||
*/
|
|
||||||
export function formatDealerAddress(dealer: DealerInfo): string {
|
|
||||||
return `${dealer.address}, ${dealer.city}, ${dealer.state}`;
|
|
||||||
}
|
|
||||||
122
src/utils/gstUtils.ts
Normal file
122
src/utils/gstUtils.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
/**
|
||||||
|
* GST Utility for state validation and tax calculations
|
||||||
|
* Contains state codes and helper functions for determining GST components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STATE_CODES: Record<string, string> = {
|
||||||
|
'01': 'Jammu and Kashmir',
|
||||||
|
'02': 'Himachal Pradesh',
|
||||||
|
'03': 'Punjab',
|
||||||
|
'04': 'Chandigarh',
|
||||||
|
'05': 'Uttarakhand',
|
||||||
|
'06': 'Haryana',
|
||||||
|
'07': 'Delhi',
|
||||||
|
'08': 'Rajasthan',
|
||||||
|
'09': 'Uttar Pradesh',
|
||||||
|
'10': 'Bihar',
|
||||||
|
'11': 'Sikkim',
|
||||||
|
'12': 'Arunachal Pradesh',
|
||||||
|
'13': 'Nagaland',
|
||||||
|
'14': 'Manipur',
|
||||||
|
'15': 'Mizoram',
|
||||||
|
'16': 'Tripura',
|
||||||
|
'17': 'Meghalaya',
|
||||||
|
'18': 'Assam',
|
||||||
|
'19': 'West Bengal',
|
||||||
|
'20': 'Jharkhand',
|
||||||
|
'21': 'Odisha',
|
||||||
|
'22': 'Chhattisgarh',
|
||||||
|
'23': 'Madhya Pradesh',
|
||||||
|
'24': 'Gujarat',
|
||||||
|
'25': 'Daman and Diu',
|
||||||
|
'26': 'Dadra and Nagar Haveli',
|
||||||
|
'27': 'Maharashtra',
|
||||||
|
'29': 'Karnataka',
|
||||||
|
'30': 'Goa',
|
||||||
|
'31': 'Lakshadweep Islands',
|
||||||
|
'32': 'Kerala',
|
||||||
|
'33': 'Tamil Nadu',
|
||||||
|
'34': 'Pondicherry',
|
||||||
|
'35': 'Andaman and Nicobar',
|
||||||
|
'36': 'Telangana',
|
||||||
|
'37': 'Andhra Pradesh',
|
||||||
|
'38': 'Ladakh',
|
||||||
|
'97': 'Others',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Royal Enfield State Code (Tamil Nadu)
|
||||||
|
*/
|
||||||
|
export const COMPANY_STATE_CODE = '33';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State codes that use UTGST instead of SGST
|
||||||
|
* Andaman and Nicobar Islands, Chandigarh, Dadra and Nagar Haveli and Daman and Diu, Ladakh, Lakshadweep
|
||||||
|
*/
|
||||||
|
export const UT_STATE_CODES = new Set(['04', '25', '26', '31', '35', '38']);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts state code from GSTIN
|
||||||
|
* @param gstin The 15-digit GSTIN string
|
||||||
|
* @returns 2-digit state code or null
|
||||||
|
*/
|
||||||
|
export const getStateCodeFromGSTIN = (gstin: string | undefined | null): string | null => {
|
||||||
|
if (!gstin || gstin.length < 2) return null;
|
||||||
|
const stateCode = gstin.substring(0, 2);
|
||||||
|
return STATE_CODES[stateCode] ? stateCode : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a state code corresponds to a Union Territory (requiring UTGST)
|
||||||
|
* @param stateCode 2-digit state code
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
export const isUnionTerritory = (stateCode: string | undefined | null): boolean => {
|
||||||
|
if (!stateCode) return false;
|
||||||
|
return UT_STATE_CODES.has(stateCode);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if a transaction is Inter-state (IGST) or Intra-state (CGST+SGST/UTGST)
|
||||||
|
* @param dealerStateCode 2-digit state code of the dealer
|
||||||
|
* @returns true if IGST, false if CGST+SGST/UTGST
|
||||||
|
*/
|
||||||
|
export const isInterState = (dealerStateCode: string | undefined | null): boolean => {
|
||||||
|
if (!dealerStateCode) return false; // Default to intra-state if unknown
|
||||||
|
return dealerStateCode !== COMPANY_STATE_CODE;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the tax components for a given dealer state
|
||||||
|
* @param dealerStateCode 2-digit state code of the dealer
|
||||||
|
* @returns Object indicating which tax components are active
|
||||||
|
*/
|
||||||
|
export const getActiveTaxComponents = (dealerStateCode: string | undefined | null) => {
|
||||||
|
if (!dealerStateCode) {
|
||||||
|
return {
|
||||||
|
isIGST: false,
|
||||||
|
isCGST: true,
|
||||||
|
isSGST: true,
|
||||||
|
isUTGST: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInter = isInterState(dealerStateCode);
|
||||||
|
|
||||||
|
if (isInter) {
|
||||||
|
return {
|
||||||
|
isIGST: true,
|
||||||
|
isCGST: false,
|
||||||
|
isSGST: false,
|
||||||
|
isUTGST: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUT = isUnionTerritory(dealerStateCode);
|
||||||
|
return {
|
||||||
|
isIGST: false,
|
||||||
|
isCGST: true,
|
||||||
|
isSGST: !isUT,
|
||||||
|
isUTGST: isUT,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -21,5 +21,8 @@ export function sanitizeHTML(html: string): string {
|
|||||||
// 5. Remove meta and link tags (except for purely visual ones if needed, but safer to remove)
|
// 5. Remove meta and link tags (except for purely visual ones if needed, but safer to remove)
|
||||||
sanitized = sanitized.replace(/<(meta|link|iframe|object|embed|applet)[^>]*>/gi, '');
|
sanitized = sanitized.replace(/<(meta|link|iframe|object|embed|applet)[^>]*>/gi, '');
|
||||||
|
|
||||||
|
// 6. Explicitly remove <a> tags to prevent HTML injection of links (VAPT compliance)
|
||||||
|
sanitized = sanitized.replace(/<a[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
||||||
|
|
||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|||||||
82
src/utils/securityToast.ts
Normal file
82
src/utils/securityToast.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Security Toast Helper
|
||||||
|
* Shows distinct, styled toast messages for antivirus/security scan errors.
|
||||||
|
* Each error type (malware, file validation, XSS, scan unavailable) gets
|
||||||
|
* its own clear message so the user knows exactly what happened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
// Security error codes returned by the backend
|
||||||
|
const SECURITY_ERROR_CODES = [
|
||||||
|
'MALWARE_DETECTED',
|
||||||
|
'FILE_VALIDATION_FAILED',
|
||||||
|
'CONTENT_THREAT_DETECTED',
|
||||||
|
'SCAN_UNAVAILABLE',
|
||||||
|
'SCAN_ERROR',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type SecurityErrorCode = typeof SECURITY_ERROR_CODES[number];
|
||||||
|
|
||||||
|
interface SecurityErrorResponse {
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
details?: {
|
||||||
|
errors?: string[];
|
||||||
|
warnings?: string[];
|
||||||
|
scanEngine?: string;
|
||||||
|
signatures?: string[];
|
||||||
|
scanType?: string;
|
||||||
|
threats?: Array<{ description: string; severity: string }>;
|
||||||
|
};
|
||||||
|
scanEventId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-friendly titles for each error type
|
||||||
|
const ERROR_TITLES: Record<SecurityErrorCode, string> = {
|
||||||
|
MALWARE_DETECTED: '🛑 Malware Detected',
|
||||||
|
FILE_VALIDATION_FAILED: '⛔ File Rejected',
|
||||||
|
CONTENT_THREAT_DETECTED: '⚠️ Malicious Content Detected',
|
||||||
|
SCAN_UNAVAILABLE: '🔒 Security Scan Unavailable',
|
||||||
|
SCAN_ERROR: '❌ Security Scan Error',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an API error response is a security/scan error.
|
||||||
|
* Returns true if it was handled (showed a toast), false otherwise.
|
||||||
|
*/
|
||||||
|
export function handleSecurityError(error: any): boolean {
|
||||||
|
const responseData: SecurityErrorResponse = error?.response?.data;
|
||||||
|
if (!responseData?.error) return false;
|
||||||
|
|
||||||
|
const errorCode = responseData.error as SecurityErrorCode;
|
||||||
|
if (!SECURITY_ERROR_CODES.includes(errorCode)) return false;
|
||||||
|
|
||||||
|
const title = ERROR_TITLES[errorCode] || 'Security Error';
|
||||||
|
const message = responseData.message || 'File was blocked by security scan';
|
||||||
|
|
||||||
|
// Build detail text
|
||||||
|
let detailText = '';
|
||||||
|
if (responseData.details) {
|
||||||
|
if (responseData.details.signatures?.length) {
|
||||||
|
detailText = `Virus: ${responseData.details.signatures.join(', ')}`;
|
||||||
|
} else if (responseData.details.errors?.length) {
|
||||||
|
detailText = responseData.details.errors[0] || '';
|
||||||
|
} else if (responseData.details.threats?.length) {
|
||||||
|
detailText = responseData.details.threats.map(t => t.description).join(', ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show custom styled toast
|
||||||
|
toast.error(title, {
|
||||||
|
description: detailText || message,
|
||||||
|
duration: 8000,
|
||||||
|
style: {
|
||||||
|
background: '#fef2f2',
|
||||||
|
border: '1px solid #fca5a5',
|
||||||
|
color: '#991b1b',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@ -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`;
|
||||||
|
|||||||
@ -12,23 +12,22 @@ export function getSocketBaseUrl(): string {
|
|||||||
if (baseUrl) {
|
if (baseUrl) {
|
||||||
return baseUrl;
|
return baseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: derive from VITE_API_BASE_URL by removing /api/v1
|
// Fallback: derive from VITE_API_BASE_URL by removing /api/v1
|
||||||
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
|
||||||
if (apiBaseUrl) {
|
if (apiBaseUrl) {
|
||||||
return apiBaseUrl.replace(/\/api\/v1\/?$/, '');
|
return apiBaseUrl.replace(/\/api\/v1\/?$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development fallback
|
// Dev fallback
|
||||||
console.warn('[Socket] No VITE_BASE_URL or VITE_API_BASE_URL found, using localhost:5000');
|
return '';
|
||||||
return 'http://localhost:5000';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSocket(baseUrl?: string): Socket {
|
export function getSocket(baseUrl?: string): Socket {
|
||||||
// Use provided baseUrl or get from environment
|
// Use provided baseUrl or get from environment
|
||||||
const url = baseUrl || getSocketBaseUrl();
|
const url = baseUrl || getSocketBaseUrl();
|
||||||
if (socket) return socket;
|
if (socket) return socket;
|
||||||
|
|
||||||
socket = io(url, {
|
socket = io(url, {
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
transports: ['websocket', 'polling'],
|
transports: ['websocket', 'polling'],
|
||||||
@ -37,19 +36,19 @@ export function getSocket(baseUrl?: string): Socket {
|
|||||||
reconnectionDelay: 1000,
|
reconnectionDelay: 1000,
|
||||||
reconnectionAttempts: 5
|
reconnectionAttempts: 5
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect', () => {
|
socket.on('connect', () => {
|
||||||
// Socket connected
|
// Socket connected
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('connect_error', (error) => {
|
socket.on('connect_error', (error) => {
|
||||||
console.error('[Socket] Connection error:', error.message);
|
console.error('[Socket] Connection error:', error.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('disconnect', (_reason) => {
|
socket.on('disconnect', (_reason) => {
|
||||||
// Socket disconnected
|
// Socket disconnected
|
||||||
});
|
});
|
||||||
|
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
// Dev 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
|
// Dev: 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
|
// Dev 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,20 +161,9 @@ 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)
|
//: 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
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('__logout_in_progress__', 'true');
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
||||||
@ -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,8 +192,8 @@ export class TokenManager {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEVELOPMENT MODE: Clear everything
|
// Dev MODE: Clear everything
|
||||||
const authKeys = [
|
const authKeys = [
|
||||||
ACCESS_TOKEN_KEY,
|
ACCESS_TOKEN_KEY,
|
||||||
REFRESH_TOKEN_KEY,
|
REFRESH_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
|
||||||
*/
|
*/
|
||||||
|
|||||||
50
src/utils/validationUtils.ts
Normal file
50
src/utils/validationUtils.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Validation utilities for HSN and SAC codes
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
isValid: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates HSN or SAC code based on GST rules
|
||||||
|
* @param code The HSN/SAC code string
|
||||||
|
* @param isService Boolean indicating if it's a Service (SAC) or Goods (HSN)
|
||||||
|
* @returns ValidationResult object
|
||||||
|
*/
|
||||||
|
export const validateHSNSAC = (code: string, isService: boolean): ValidationResult => {
|
||||||
|
if (!code) return { isValid: true, message: '' };
|
||||||
|
|
||||||
|
const cleanCode = code.trim();
|
||||||
|
|
||||||
|
// Basic check for digits only
|
||||||
|
if (!/^\d+$/.test(cleanCode)) {
|
||||||
|
return { isValid: false, message: 'Code must contain only digits' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isService) {
|
||||||
|
// SAC (Services Accounting Code)
|
||||||
|
// Must start with 99 and typically has 6 digits
|
||||||
|
if (!cleanCode.startsWith('99')) {
|
||||||
|
return { isValid: false, message: 'SAC (Service) code must start with 99' };
|
||||||
|
}
|
||||||
|
if (cleanCode.length !== 6) {
|
||||||
|
return { isValid: false, message: 'SAC code must be exactly 6 digits' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// HSN (Harmonized System of Nomenclature) for Goods
|
||||||
|
// Usually 4, 6, or 8 digits in India
|
||||||
|
const validHSNLengths = [4, 6, 8];
|
||||||
|
if (!validHSNLengths.includes(cleanCode.length)) {
|
||||||
|
return { isValid: false, message: 'HSN code must be 4, 6, or 8 digits' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// HSN codes for goods should generally not start with 99 (that's reserved for SAC)
|
||||||
|
if (cleanCode.startsWith('99')) {
|
||||||
|
return { isValid: false, message: 'HSN code should not start with 99 (use SAC type for services)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, message: '' };
|
||||||
|
};
|
||||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_APP_VERSION: string;
|
readonly VITE_APP_VERSION: string;
|
||||||
readonly VITE_ENABLE_ANALYTICS: string;
|
readonly VITE_ENABLE_ANALYTICS: string;
|
||||||
readonly VITE_ENABLE_DEBUG: string;
|
readonly VITE_ENABLE_DEBUG: string;
|
||||||
|
readonly VITE_TANFLOW_BASE_URL: string;
|
||||||
|
readonly VITE_TANFLOW_CLIENT_ID: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -60,9 +60,24 @@ const ensureChunkOrder = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plugin to replace axios localhost fallback for VAPT compliance
|
||||||
|
const replaceAxiosLocalhost = () => {
|
||||||
|
return {
|
||||||
|
name: 'replace-axios-localhost',
|
||||||
|
transform(code: string, id: string) {
|
||||||
|
// Target the specific utils.js file in axios where the localhost string exists
|
||||||
|
if (id.includes('node_modules') && id.includes('axios') && id.includes('utils.js')) {
|
||||||
|
// Replace 'http://localhost' with empty string
|
||||||
|
return code.replace(/'http:\/\/localhost'/g, "''");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), suppressCssWarnings(), ensureChunkOrder()],
|
plugins: [react(), suppressCssWarnings(), ensureChunkOrder(), replaceAxiosLocalhost()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
@ -78,7 +93,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
sourcemap: true,
|
sourcemap: false,
|
||||||
// CSS minification warning is harmless - it's a known issue with certain Tailwind class combinations
|
// CSS minification warning is harmless - it's a known issue with certain Tailwind class combinations
|
||||||
// Re-enable minification with settings that preserve initialization order
|
// Re-enable minification with settings that preserve initialization order
|
||||||
// The "Cannot access 'React' before initialization" error is fixed by keeping React in main bundle
|
// The "Cannot access 'React' before initialization" error is fixed by keeping React in main bundle
|
||||||
@ -119,7 +134,7 @@ export default defineConfig({
|
|||||||
chunkFileNames: 'assets/[name]-[hash].js',
|
chunkFileNames: 'assets/[name]-[hash].js',
|
||||||
// Explicitly define chunk order - React must load before Radix UI
|
// Explicitly define chunk order - React must load before Radix UI
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
// CRITICAL FIX: Keep React in main bundle OR ensure it loads first
|
// IMPORTANT: Keep React in main bundle OR ensure it loads first
|
||||||
// The "Cannot access 'React' before initialization" error occurs when
|
// The "Cannot access 'React' before initialization" error occurs when
|
||||||
// Radix UI components try to access React before it's initialized
|
// Radix UI components try to access React before it's initialized
|
||||||
// Option 1: Don't split React - keep it in main bundle (most reliable)
|
// Option 1: Don't split React - keep it in main bundle (most reliable)
|
||||||
@ -128,7 +143,7 @@ export default defineConfig({
|
|||||||
// For now, let's keep React in main bundle to avoid initialization issues
|
// For now, let's keep React in main bundle to avoid initialization issues
|
||||||
// Only split other vendors
|
// Only split other vendors
|
||||||
|
|
||||||
// Radix UI - CRITICAL: ALL Radix packages MUST stay together in ONE chunk
|
// Radix UI - IMPORTANT: ALL Radix packages MUST stay together in ONE chunk
|
||||||
// This chunk will import React from the main bundle, avoiding initialization issues
|
// This chunk will import React from the main bundle, avoiding initialization issues
|
||||||
if (id.includes('node_modules/@radix-ui')) {
|
if (id.includes('node_modules/@radix-ui')) {
|
||||||
return 'radix-vendor';
|
return 'radix-vendor';
|
||||||
@ -172,7 +187,7 @@ export default defineConfig({
|
|||||||
chunkSizeWarningLimit: 1500, // Increased limit since we have manual chunks
|
chunkSizeWarningLimit: 1500, // Increased limit since we have manual chunks
|
||||||
},
|
},
|
||||||
esbuild: {
|
esbuild: {
|
||||||
// CRITICAL: Strip all legal comments to prevent "Suspicious Comments" alerts (e.g. from Redux docs)
|
//: Strip all legal comments to prevent "Suspicious Comments" alerts (e.g. from Redux docs)
|
||||||
legalComments: 'none',
|
legalComments: 'none',
|
||||||
},
|
},
|
||||||
optimizeDeps: {
|
optimizeDeps: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user