Compare commits
2 Commits
main
...
mongo_migr
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bab9c0481 | |||
| 6b4b80c0d4 |
@ -1,27 +0,0 @@
|
|||||||
#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
|
|
||||||
70
index.html
70
index.html
@ -1,23 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
||||||
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
<head>
|
<!-- Preload critical fonts and icons -->
|
||||||
<meta charset="UTF-8" />
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description"
|
|
||||||
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
|
||||||
<meta name="theme-color" content="#2d4a3e" />
|
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
|
||||||
|
|
||||||
<!-- Preload essential fonts and icons -->
|
<!-- Ensure proper icon rendering and layout -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<style>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
/* Ensure Lucide icons render properly */
|
||||||
</head>
|
svg {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
<body>
|
/* Fix for icon alignment in buttons */
|
||||||
<div id="root"></div>
|
button svg {
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
flex-shrink: 0;
|
||||||
</body>
|
}
|
||||||
|
|
||||||
|
/* Ensure proper text rendering */
|
||||||
|
body {
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for mobile viewport and sidebar */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper sidebar toggle behavior */
|
||||||
|
.sidebar-toggle {
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for icon button hover states */
|
||||||
|
button:hover svg {
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Disallow: /api/
|
|
||||||
|
|
||||||
Sitemap: https://reflow.royalenfield.com/sitemap.xml
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
<?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>
|
|
||||||
254
src/App.tsx
254
src/App.tsx
@ -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);
|
||||||
@ -193,7 +193,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(dynamicRequests.length + 1).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
||||||
|
|
||||||
// Create full custom request object
|
// Create full custom request object
|
||||||
const newCustomRequest = {
|
const newCustomRequest = {
|
||||||
@ -412,6 +412,201 @@ 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 (
|
||||||
@ -463,7 +658,44 @@ 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
|
||||||
@ -610,16 +842,6 @@ 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,13 +12,6 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
import {
|
||||||
FileText,
|
FileText,
|
||||||
Plus,
|
Plus,
|
||||||
@ -96,17 +89,16 @@ export function ActivityTypeManager() {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (!formData.title.trim() || !formData.taxationType.trim() || !formData.sapRefNo.trim()) {
|
if (!formData.title.trim()) {
|
||||||
setError('Title, Taxation Type, and Claim Document Type (SAP Ref) are required');
|
setError('Activity type title is 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(),
|
taxationType: formData.taxationType.trim() || null,
|
||||||
sapRefNo: formData.sapRefNo.trim()
|
sapRefNo: formData.sapRefNo.trim() || null
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingActivityType) {
|
if (editingActivityType) {
|
||||||
@ -405,37 +397,32 @@ 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 flex items-center gap-1">
|
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900">
|
||||||
Taxation Type <span className="text-red-500">*</span>
|
Taxation Type <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Input
|
||||||
|
id="taxationType"
|
||||||
|
placeholder="e.g., GST, VAT, Exempt"
|
||||||
value={formData.taxationType}
|
value={formData.taxationType}
|
||||||
onValueChange={(value) => setFormData({ ...formData, taxationType: value })}
|
onChange={(e) => setFormData({ ...formData, taxationType: 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"
|
||||||
<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">
|
/>
|
||||||
<SelectValue placeholder="Select Taxation Type" />
|
<p className="text-xs text-slate-500">Optional taxation type for the activity</p>
|
||||||
</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 flex items-center gap-1">
|
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900">
|
||||||
Claim Document Type (SAP Ref) <span className="text-red-500">*</span>
|
SAP Reference Number <span className="text-slate-400 font-normal text-xs">(Optional)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="sapRefNo"
|
id="sapRefNo"
|
||||||
placeholder="e.g., ZCNS, ZRE"
|
placeholder="e.g., SAP-12345"
|
||||||
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">Required SAP reference number for CSV generation</p>
|
<p className="text-xs text-slate-500">Optional SAP reference number</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -449,7 +436,7 @@ export function ActivityTypeManager() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!formData.title.trim() || !formData.taxationType || !formData.sapRefNo.trim()}
|
disabled={!formData.title.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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -318,7 +318,7 @@ export function UserManagement() {
|
|||||||
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');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -332,6 +332,7 @@ export function UserManagement() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Implement backend API for deleting users
|
||||||
toast.info('User deletion functionality coming soon');
|
toast.info('User deletion functionality coming soon');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -514,10 +515,11 @@ export function UserManagement() {
|
|||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`border-2 rounded-lg p-4 ${message.type === 'success'
|
<div className={`border-2 rounded-lg p-4 ${
|
||||||
? 'border-green-200 bg-green-50'
|
message.type === 'success'
|
||||||
: 'border-red-200 bg-red-50'
|
? 'border-green-200 bg-green-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" />
|
||||||
@ -662,10 +664,11 @@ 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 ${currentPage === pageNum
|
className={`w-9 h-9 p-0 ${
|
||||||
? 'bg-re-green hover:bg-re-green/90'
|
currentPage === pageNum
|
||||||
: ''
|
? 'bg-re-green hover:bg-re-green/90'
|
||||||
}`}
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/components/ui/utils";
|
import { cn } from "@/components/ui/utils";
|
||||||
import { sanitizeHTML } from "@/utils/sanitizer";
|
|
||||||
|
|
||||||
interface FormattedDescriptionProps {
|
interface FormattedDescriptionProps {
|
||||||
content: string;
|
content: string;
|
||||||
@ -31,11 +30,10 @@ export function FormattedDescription({ content, className }: FormattedDescriptio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wrap the table in a scrollable container
|
// Wrap the table in a scrollable container
|
||||||
return `<div class="table-wrapper">${match}</div>`;
|
return `<div class="table-wrapper" style="overflow-x: auto; max-width: 100%; margin: 8px 0;">${match}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sanitize the content to prevent CSP violations (onclick, style tags, etc.)
|
return processed;
|
||||||
return sanitizeHTML(processed);
|
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
|
import { formatBreachTime } from '@/pages/Dashboard/utils/dashboardCalculations';
|
||||||
|
|
||||||
export interface CriticalAlertData {
|
export interface CriticalAlertData {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@ -12,6 +13,8 @@ export interface CriticalAlertData {
|
|||||||
breachCount: number;
|
breachCount: number;
|
||||||
currentLevel: number;
|
currentLevel: number;
|
||||||
totalLevels: number;
|
totalLevels: number;
|
||||||
|
isActionable?: boolean;
|
||||||
|
requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CriticalAlertCardProps {
|
interface CriticalAlertCardProps {
|
||||||
@ -40,23 +43,29 @@ const calculateProgress = (alert: CriticalAlertData) => {
|
|||||||
return Math.min(100, Math.max(0, Math.round(percentageUsed)));
|
return Math.min(100, Math.max(0, Math.round(percentageUsed)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatRemainingTime = (alert: CriticalAlertData) => {
|
const formatDisplayTime = (alert: CriticalAlertData) => {
|
||||||
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
|
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
|
||||||
|
|
||||||
const hours = alert.totalTATHours;
|
const hours = alert.totalTATHours;
|
||||||
|
const isOverdue = hours <= 0;
|
||||||
|
const absHours = Math.abs(hours);
|
||||||
|
|
||||||
// If TAT is breached (negative or zero)
|
const formattedTime = formatBreachTime(absHours);
|
||||||
if (hours <= 0) {
|
|
||||||
const overdue = Math.abs(hours);
|
if (formattedTime === 'Just breached') return 'Breached';
|
||||||
if (overdue < 1) return `Breached`;
|
|
||||||
if (overdue < 24) return `${Math.round(overdue)}h overdue`;
|
return isOverdue ? `${formattedTime} overdue` : `${formattedTime} left`;
|
||||||
return `${Math.round(overdue / 24)}d overdue`;
|
};
|
||||||
|
|
||||||
|
const getRoleBadge = (role?: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'APPROVER':
|
||||||
|
return { label: 'Action Required', className: 'bg-red-100 text-red-700 border-red-200' };
|
||||||
|
case 'INITIATOR':
|
||||||
|
return { label: 'My Request', className: 'bg-orange-100 text-orange-700 border-orange-200' };
|
||||||
|
default:
|
||||||
|
return { label: 'Monitoring', className: 'bg-blue-100 text-blue-700 border-blue-200' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// If TAT is still remaining
|
|
||||||
if (hours < 1) return `${Math.round(hours * 60)}min left`;
|
|
||||||
if (hours < 24) return `${Math.round(hours)}h left`;
|
|
||||||
return `${Math.round(hours / 24)}d left`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CriticalAlertCard({
|
export function CriticalAlertCard({
|
||||||
@ -65,10 +74,15 @@ export function CriticalAlertCard({
|
|||||||
testId = 'critical-alert-card'
|
testId = 'critical-alert-card'
|
||||||
}: CriticalAlertCardProps) {
|
}: CriticalAlertCardProps) {
|
||||||
const progress = calculateProgress(alert);
|
const progress = calculateProgress(alert);
|
||||||
|
const isActionable = alert.isActionable ?? true; // Default to true if not provided (admin view)
|
||||||
|
const roleInfo = getRoleBadge(alert.requestRole);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="p-3 sm:p-4 bg-red-50 rounded-lg sm:rounded-xl border border-red-100 hover:shadow-md transition-all duration-200 cursor-pointer"
|
className={`p-3 sm:p-4 rounded-lg sm:rounded-xl border hover:shadow-md transition-all duration-200 cursor-pointer ${isActionable
|
||||||
|
? 'bg-red-50 border-red-100'
|
||||||
|
: 'bg-orange-50/50 border-orange-100'
|
||||||
|
}`}
|
||||||
onClick={() => onNavigate?.(alert.requestNumber)}
|
onClick={() => onNavigate?.(alert.requestNumber)}
|
||||||
data-testid={`${testId}-${alert.requestId}`}
|
data-testid={`${testId}-${alert.requestId}`}
|
||||||
>
|
>
|
||||||
@ -83,14 +97,22 @@ export function CriticalAlertCard({
|
|||||||
</p>
|
</p>
|
||||||
{alert.priority === 'express' && (
|
{alert.priority === 'express' && (
|
||||||
<Star
|
<Star
|
||||||
className="h-3 w-3 text-red-500 flex-shrink-0"
|
className={`h-3 w-3 flex-shrink-0 ${isActionable ? 'text-red-500' : 'text-orange-500'}`}
|
||||||
data-testid={`${testId}-priority-icon`}
|
data-testid={`${testId}-priority-icon`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{alert.requestRole && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] px-1.5 py-0 h-4 ${roleInfo.className}`}
|
||||||
|
>
|
||||||
|
{roleInfo.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{alert.breachCount > 0 && (
|
{alert.breachCount > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="text-xs"
|
className="text-[10px] px-1.5 py-0 h-4"
|
||||||
data-testid={`${testId}-breach-count`}
|
data-testid={`${testId}-breach-count`}
|
||||||
>
|
>
|
||||||
{alert.breachCount}
|
{alert.breachCount}
|
||||||
@ -106,10 +128,11 @@ export function CriticalAlertCard({
|
|||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-xs bg-white border-red-200 text-red-700 font-medium whitespace-nowrap"
|
className={`text-xs bg-white font-medium whitespace-nowrap ${isActionable ? 'border-red-200 text-red-700' : 'border-orange-200 text-orange-700'
|
||||||
|
}`}
|
||||||
data-testid={`${testId}-remaining-time`}
|
data-testid={`${testId}-remaining-time`}
|
||||||
>
|
>
|
||||||
{formatRemainingTime(alert)}
|
{formatDisplayTime(alert)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1 sm:space-y-2">
|
<div className="space-y-1 sm:space-y-2">
|
||||||
@ -124,11 +147,10 @@ export function CriticalAlertCard({
|
|||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
value={progress}
|
value={progress}
|
||||||
className={`h-1.5 sm:h-2 ${
|
className={`h-1.5 sm:h-2 ${progress >= 80 ? '[&>div]:bg-red-600' :
|
||||||
progress >= 80 ? '[&>div]:bg-red-600' :
|
|
||||||
progress >= 50 ? '[&>div]:bg-orange-500' :
|
progress >= 50 ? '[&>div]:bg-orange-500' :
|
||||||
'[&>div]:bg-green-600'
|
'[&>div]:bg-green-600'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`${testId}-progress-bar`}
|
data-testid={`${testId}-progress-bar`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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, adminOnly: false }
|
{ id: 'requests', label: 'All Requests', icon: List },
|
||||||
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
{ id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add remaining menu items (exclude "My Requests" for dealers)
|
// Add remaining menu items (exclude "My Requests" for dealers)
|
||||||
@ -275,18 +275,18 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Action in Sidebar - Right below menu items */}
|
{/* Quick Action in Sidebar - Right below menu items */}
|
||||||
{!isDealer && (
|
{/* {!isDealer && ( */}
|
||||||
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={onNewRequest}
|
onClick={onNewRequest}
|
||||||
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
|
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Raise New Request
|
Raise New Request
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{/* )} */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@ -42,6 +42,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
spectators: [] as any[],
|
spectators: [] as any[],
|
||||||
documents: [] as File[]
|
documents: [] as File[]
|
||||||
});
|
});
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const totalSteps = 5;
|
const totalSteps = 5;
|
||||||
|
|
||||||
@ -78,9 +79,36 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
|
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement> | React.DragEvent) => {
|
||||||
const files = Array.from(event.target.files || []);
|
let files: File[] = [];
|
||||||
updateFormData('documents', [...formData.documents, ...files]);
|
if ('target' in event && event.target instanceof HTMLInputElement && event.target.files) {
|
||||||
|
files = Array.from(event.target.files);
|
||||||
|
} else if ('dataTransfer' in event && event.dataTransfer.files) {
|
||||||
|
files = Array.from(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.length > 0) {
|
||||||
|
updateFormData('documents', [...formData.documents, ...files]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
handleFileUpload(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeDocument = (index: number) => {
|
const removeDocument = (index: number) => {
|
||||||
@ -375,10 +403,16 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
Attach supporting documents for your request. Maximum 10MB per file.
|
Attach supporting documents for your request. Maximum 10MB per file.
|
||||||
</p>
|
</p>
|
||||||
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
<div
|
||||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-border'
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<Upload className={`h-8 w-8 mx-auto mb-2 ${isDragging ? 'text-re-green' : 'text-muted-foreground'}`} />
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
click to browse
|
Drag and drop files here, or click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -151,12 +151,13 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
className={`h-full transition-all duration-300 border-2 ${
|
||||||
? 'opacity-50 cursor-not-allowed border-gray-200'
|
isDisabled
|
||||||
: isSelected
|
? 'opacity-50 cursor-not-allowed border-gray-200'
|
||||||
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
: isSelected
|
||||||
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
? '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'
|
||||||
|
}`}
|
||||||
onClick={() => handleSelect(template.id)}
|
onClick={() => handleSelect(template.id)}
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-4 pb-4">
|
<CardHeader className="space-y-4 pb-4">
|
||||||
@ -261,10 +262,11 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
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 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
className={`gap-2 px-8 ${
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
||||||
: 'bg-gray-400 cursor-not-allowed'
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
}`}
|
: 'bg-gray-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Continue with Template
|
Continue with Template
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
import { sanitizeHTML } from '../../utils/sanitizer';
|
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||||
@ -167,8 +166,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
// Simple mention highlighting
|
// Simple mention highlighting
|
||||||
const formatted = content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
|
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
|
||||||
return sanitizeHTML(formatted);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -197,10 +195,11 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
|
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
|
||||||
{!msg.isSystem && (
|
{!msg.isSystem && (
|
||||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||||
<AvatarFallback className={`text-white text-xs ${msg.user.role === 'Initiator' ? 'bg-re-green' :
|
<AvatarFallback className={`text-white text-xs ${
|
||||||
|
msg.user.role === 'Initiator' ? 'bg-re-green' :
|
||||||
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
||||||
'bg-re-light-green'
|
'bg-re-light-green'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -307,8 +306,9 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<div key={index} className="flex items-center gap-3">
|
<div key={index} className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className={`text-white text-xs ${participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
|
<AvatarFallback className={`text-white text-xs ${
|
||||||
}`}>
|
participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
|
||||||
|
}`}>
|
||||||
{participant.avatar}
|
{participant.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@ -1,297 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -54,13 +54,13 @@ function ChartContainer({
|
|||||||
<div
|
<div
|
||||||
data-slot="chart"
|
data-slot="chart"
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
style={getChartStyle(config)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
<RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
{children}
|
{children}
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
@ -69,39 +69,37 @@ function ChartContainer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChartStyle = (config: ChartConfig) => {
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(
|
||||||
([, config]) => config.theme || config.color,
|
([, config]) => config.theme || config.color,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return {};
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles: Record<string, string> = {};
|
return (
|
||||||
|
<style
|
||||||
colorConfig.forEach(([key, itemConfig]) => {
|
dangerouslySetInnerHTML={{
|
||||||
// For simplicity, we'll use the default color or the light theme color
|
__html: Object.entries(THEMES)
|
||||||
// If you need per-theme variables, they should be handled via CSS classes or media queries
|
.map(
|
||||||
// but applying them here as inline styles is CSP-safe.
|
([theme, prefix]) => `
|
||||||
const color = itemConfig.color || itemConfig.theme?.light;
|
${prefix} [data-chart=${id}] {
|
||||||
if (color) {
|
${colorConfig
|
||||||
styles[`--color-${key}`] = color;
|
.map(([key, itemConfig]) => {
|
||||||
}
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
// Handle dark theme if present
|
itemConfig.color;
|
||||||
const darkColor = itemConfig.theme?.dark;
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
if (darkColor) {
|
})
|
||||||
styles[`--color-${key}-dark`] = darkColor;
|
.join("\n")}
|
||||||
}
|
}
|
||||||
});
|
`,
|
||||||
|
)
|
||||||
return styles as React.CSSProperties;
|
.join("\n"),
|
||||||
};
|
}}
|
||||||
|
/>
|
||||||
// Deprecated: Kept for backward compatibility if needed in other files.
|
);
|
||||||
const ChartStyle = () => {
|
|
||||||
return null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
@ -318,8 +316,8 @@ function getPayloadConfigFromPayload(
|
|||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
"payload" in payload &&
|
"payload" in payload &&
|
||||||
typeof payload.payload === "object" &&
|
typeof payload.payload === "object" &&
|
||||||
payload.payload !== null
|
payload.payload !== null
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { cn } from "./utils";
|
|||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
|
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||||
import { sanitizeHTML } from "@/utils/sanitizer";
|
|
||||||
|
|
||||||
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
value: string;
|
value: string;
|
||||||
@ -60,8 +59,7 @@ export function RichTextEditor({
|
|||||||
// Only update if the value actually changed externally
|
// Only update if the value actually changed externally
|
||||||
const currentValue = editorRef.current.innerHTML;
|
const currentValue = editorRef.current.innerHTML;
|
||||||
if (currentValue !== value) {
|
if (currentValue !== value) {
|
||||||
// Sanitize incoming content
|
editorRef.current.innerHTML = value || '';
|
||||||
editorRef.current.innerHTML = sanitizeHTML(value || '');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@ -171,6 +169,9 @@ export function RichTextEditor({
|
|||||||
// Wrap table in scrollable container for mobile
|
// Wrap table in scrollable container for mobile
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'table-wrapper';
|
wrapper.className = 'table-wrapper';
|
||||||
|
wrapper.style.overflowX = 'auto';
|
||||||
|
wrapper.style.maxWidth = '100%';
|
||||||
|
wrapper.style.margin = '8px 0';
|
||||||
wrapper.appendChild(table);
|
wrapper.appendChild(table);
|
||||||
fragment.appendChild(wrapper);
|
fragment.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
@ -181,7 +182,7 @@ export function RichTextEditor({
|
|||||||
const innerHTML = element.innerHTML;
|
const innerHTML = element.innerHTML;
|
||||||
// Remove style tags and comments from inner HTML
|
// Remove style tags and comments from inner HTML
|
||||||
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<!--[\s\S]*?-->/g, '');
|
.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
p.innerHTML = cleaned;
|
p.innerHTML = cleaned;
|
||||||
p.removeAttribute('style');
|
p.removeAttribute('style');
|
||||||
p.removeAttribute('class');
|
p.removeAttribute('class');
|
||||||
@ -232,9 +233,9 @@ export function RichTextEditor({
|
|||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
|
|
||||||
// Trigger onChange with sanitized content
|
// Trigger onChange
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
}, [onChange, cleanWordHTML]);
|
}, [onChange, cleanWordHTML]);
|
||||||
|
|
||||||
@ -272,34 +273,34 @@ export function RichTextEditor({
|
|||||||
if (style.textAlign === 'right') formats.add('right');
|
if (style.textAlign === 'right') formats.add('right');
|
||||||
if (style.textAlign === 'left') formats.add('left');
|
if (style.textAlign === 'left') formats.add('left');
|
||||||
|
|
||||||
// Convert RGB/RGBA to hex for comparison
|
// Convert RGB/RGBA to hex for comparison
|
||||||
const colorToHex = (color: string): string | null => {
|
const colorToHex = (color: string): string | null => {
|
||||||
// If already hex format
|
// If already hex format
|
||||||
if (color.startsWith('#')) {
|
if (color.startsWith('#')) {
|
||||||
return color.toUpperCase();
|
return color.toUpperCase();
|
||||||
}
|
}
|
||||||
// If RGB/RGBA format
|
// If RGB/RGBA format
|
||||||
const result = color.match(/\d+/g);
|
const result = color.match(/\d+/g);
|
||||||
if (!result || result.length < 3) return null;
|
if (!result || result.length < 3) return null;
|
||||||
const r = result[0];
|
const r = result[0];
|
||||||
const g = result[1];
|
const g = result[1];
|
||||||
const b = result[2];
|
const b = result[2];
|
||||||
if (!r || !g || !b) return null;
|
if (!r || !g || !b) return null;
|
||||||
const rHex = parseInt(r).toString(16).padStart(2, '0');
|
const rHex = parseInt(r).toString(16).padStart(2, '0');
|
||||||
const gHex = parseInt(g).toString(16).padStart(2, '0');
|
const gHex = parseInt(g).toString(16).padStart(2, '0');
|
||||||
const bHex = parseInt(b).toString(16).padStart(2, '0');
|
const bHex = parseInt(b).toString(16).padStart(2, '0');
|
||||||
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for background color (highlight)
|
// Check for background color (highlight)
|
||||||
const bgColor = style.backgroundColor;
|
const bgColor = style.backgroundColor;
|
||||||
// Check if background color is set and not transparent/default
|
// Check if background color is set and not transparent/default
|
||||||
if (bgColor &&
|
if (bgColor &&
|
||||||
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
||||||
bgColor !== 'transparent' &&
|
bgColor !== 'transparent' &&
|
||||||
bgColor !== 'rgb(255, 255, 255)' &&
|
bgColor !== 'rgb(255, 255, 255)' &&
|
||||||
bgColor !== '#ffffff' &&
|
bgColor !== '#ffffff' &&
|
||||||
bgColor !== '#FFFFFF') {
|
bgColor !== '#FFFFFF') {
|
||||||
formats.add('highlight');
|
formats.add('highlight');
|
||||||
const hexColor = colorToHex(bgColor);
|
const hexColor = colorToHex(bgColor);
|
||||||
if (hexColor) {
|
if (hexColor) {
|
||||||
@ -327,8 +328,8 @@ export function RichTextEditor({
|
|||||||
const hexTextColor = colorToHex(textColor);
|
const hexTextColor = colorToHex(textColor);
|
||||||
// Check if text color is set and not default black
|
// Check if text color is set and not default black
|
||||||
if (textColor && hexTextColor &&
|
if (textColor && hexTextColor &&
|
||||||
textColor !== 'rgba(0, 0, 0, 0)' &&
|
textColor !== 'rgba(0, 0, 0, 0)' &&
|
||||||
hexTextColor !== '#000000') {
|
hexTextColor !== '#000000') {
|
||||||
formats.add('textColor');
|
formats.add('textColor');
|
||||||
// Find matching color from our palette
|
// Find matching color from our palette
|
||||||
const matchedColor = HIGHLIGHT_COLORS.find(c => {
|
const matchedColor = HIGHLIGHT_COLORS.find(c => {
|
||||||
@ -379,7 +380,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check active formats after a short delay
|
// Check active formats after a short delay
|
||||||
@ -421,7 +422,7 @@ export function RichTextEditor({
|
|||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
const bgColor = style.backgroundColor;
|
const bgColor = style.backgroundColor;
|
||||||
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
||||||
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
|
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
|
||||||
// Convert to hex and compare
|
// Convert to hex and compare
|
||||||
const colorToHex = (c: string): string | null => {
|
const colorToHex = (c: string): string | null => {
|
||||||
if (c.startsWith('#')) return c.toUpperCase();
|
if (c.startsWith('#')) return c.toUpperCase();
|
||||||
@ -531,7 +532,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
@ -635,7 +636,7 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
@ -648,7 +649,7 @@ export function RichTextEditor({
|
|||||||
// Handle input changes
|
// Handle input changes
|
||||||
const handleInput = React.useCallback(() => {
|
const handleInput = React.useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
checkActiveFormats();
|
checkActiveFormats();
|
||||||
}, [onChange, checkActiveFormats]);
|
}, [onChange, checkActiveFormats]);
|
||||||
@ -684,7 +685,7 @@ export function RichTextEditor({
|
|||||||
const handleBlur = React.useCallback(() => {
|
const handleBlur = React.useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
onChange(editorRef.current.innerHTML);
|
||||||
}
|
}
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
||||||
import { sanitizeHTML } from '@/utils/sanitizer';
|
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
@ -110,7 +109,9 @@ const getStatusText = (status: string) => {
|
|||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
// Enhanced mention highlighting - Blue color with extra bold font for high visibility
|
// Enhanced mention highlighting - Blue color with extra bold font for high visibility
|
||||||
const formattedContent = content
|
// Matches: @username or @FirstName LastName (only one space allowed for first name + last name)
|
||||||
|
// Pattern: @word or @word word (stops after second word)
|
||||||
|
return content
|
||||||
.replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
|
.replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
|
||||||
const afterPos = offset + match.length;
|
const afterPos = offset + match.length;
|
||||||
const afterChar = string[afterPos];
|
const afterChar = string[afterPos];
|
||||||
@ -123,8 +124,6 @@ const formatMessage = (content: string) => {
|
|||||||
return match;
|
return match;
|
||||||
})
|
})
|
||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
|
|
||||||
return sanitizeHTML(formattedContent);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
const formatFileSize = (bytes: number): string => {
|
||||||
@ -780,8 +779,18 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
})) : undefined
|
})) : undefined
|
||||||
};
|
};
|
||||||
}) : [];
|
}) : [];
|
||||||
setMessages(mapped as any);
|
setMessages(prev => {
|
||||||
} catch {
|
// Keep system messages (activities) from the previous state
|
||||||
|
const systemMessages = prev.filter(m => m.isSystem);
|
||||||
|
// Combine with the newly fetched work notes
|
||||||
|
const combined = [...mapped, ...systemMessages];
|
||||||
|
// Sort to maintain chronological order
|
||||||
|
return combined.sort((a, b) =>
|
||||||
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
|
) as any;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WorkNoteChat] Failed to send message or fetch notes:', error);
|
||||||
setMessages(prev => [...prev, newMessage]);
|
setMessages(prev => [...prev, newMessage]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1276,9 +1285,9 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
{!msg.isSystem && !isCurrentUser && (
|
{!msg.isSystem && !isCurrentUser && (
|
||||||
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
|
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
|
||||||
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
|
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
|
||||||
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
||||||
msg.user.role === 'System' ? 'bg-gray-500' :
|
msg.user.role === 'System' ? 'bg-gray-500' :
|
||||||
'bg-slate-600'
|
'bg-slate-600'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@ -1415,8 +1424,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
key={index}
|
key={index}
|
||||||
onClick={() => addReaction(msg.id, reaction.emoji)}
|
onClick={() => addReaction(msg.id, reaction.emoji)}
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
|
||||||
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{reaction.emoji}</span>
|
<span>{reaction.emoji}</span>
|
||||||
@ -1564,8 +1573,8 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
>
|
>
|
||||||
<Avatar className="h-10 w-10">
|
<Avatar className="h-10 w-10">
|
||||||
<AvatarFallback className={`text-white text-sm font-semibold ${participant.role === 'Initiator' ? 'bg-green-600' :
|
<AvatarFallback className={`text-white text-sm font-semibold ${participant.role === 'Initiator' ? 'bg-green-600' :
|
||||||
participant.role === 'Approver' ? 'bg-purple-600' :
|
participant.role === 'Approver' ? 'bg-purple-600' :
|
||||||
'bg-blue-500'
|
'bg-blue-500'
|
||||||
}`}>
|
}`}>
|
||||||
{participant.avatar}
|
{participant.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
@ -1712,7 +1721,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
|||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-9 w-9 sm:h-10 sm:w-10">
|
<Avatar className="h-9 w-9 sm:h-10 sm:w-10">
|
||||||
<AvatarFallback className={`text-white font-semibold text-sm ${participant.role === 'Initiator' ? 'bg-green-600' :
|
<AvatarFallback className={`text-white font-semibold text-sm ${participant.role === 'Initiator' ? 'bg-green-600' :
|
||||||
isCurrentUser ? 'bg-blue-500' : 'bg-slate-600'
|
isCurrentUser ? 'bg-blue-500' : 'bg-slate-600'
|
||||||
}`}>
|
}`}>
|
||||||
{participant.avatar}
|
{participant.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
||||||
import { sanitizeHTML } from '@/utils/sanitizer';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
@ -59,11 +58,9 @@ interface WorkNoteChatSimpleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
const formattedContent = content
|
return content
|
||||||
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
|
|
||||||
return sanitizeHTML(formattedContent);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileIcon = ({ type }: { type: string }) => {
|
const FileIcon = ({ type }: { type: string }) => {
|
||||||
@ -143,7 +140,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
if (details?.workflow?.requestId) {
|
if (details?.workflow?.requestId) {
|
||||||
joinedId = details.workflow.requestId;
|
joinedId = details.workflow.requestId;
|
||||||
}
|
}
|
||||||
} catch { }
|
} catch {}
|
||||||
try {
|
try {
|
||||||
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
||||||
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
||||||
@ -192,7 +189,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
try { (window as any).__wn_cleanup?.(); } catch { }
|
try { (window as any).__wn_cleanup?.(); } catch {}
|
||||||
};
|
};
|
||||||
}, [requestId, currentUserId]);
|
}, [requestId, currentUserId]);
|
||||||
|
|
||||||
@ -221,7 +218,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
};
|
};
|
||||||
}) : [];
|
}) : [];
|
||||||
setMessages(mapped as any);
|
setMessages(mapped as any);
|
||||||
} catch { }
|
} catch {}
|
||||||
}
|
}
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
@ -260,7 +257,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
setMessages(mapped);
|
setMessages(mapped);
|
||||||
} catch { }
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@ -397,10 +394,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
||||||
{!msg.isSystem && !isCurrentUser && (
|
{!msg.isSystem && !isCurrentUser && (
|
||||||
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
||||||
<AvatarFallback className={`text-white font-semibold text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
|
<AvatarFallback className={`text-white font-semibold text-sm ${
|
||||||
msg.user.role === 'Approver' ? 'bg-blue-600' :
|
msg.user.role === 'Initiator' ? 'bg-green-600' :
|
||||||
'bg-slate-600'
|
msg.user.role === 'Approver' ? 'bg-blue-600' :
|
||||||
}`}>
|
'bg-slate-600'
|
||||||
|
}`}>
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
@ -453,70 +451,70 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const attachmentId = attachment.attachmentId || attachment.attachment_id;
|
const attachmentId = attachment.attachmentId || attachment.attachment_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
|
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<FileIcon type={fileType} />
|
<FileIcon type={fileType} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-700 truncate">
|
<p className="text-sm font-medium text-gray-700 truncate">
|
||||||
{fileName}
|
{fileName}
|
||||||
|
</p>
|
||||||
|
{fileSize && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
</p>
|
</p>
|
||||||
{fileSize && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{formatFileSize(fileSize)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Preview button for images and PDFs */}
|
|
||||||
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
|
|
||||||
setPreviewFile({
|
|
||||||
fileName,
|
|
||||||
fileType,
|
|
||||||
fileUrl: previewUrl,
|
|
||||||
fileSize,
|
|
||||||
attachmentId
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
title="Preview file"
|
|
||||||
>
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Download button */}
|
{/* Preview button for images and PDFs */}
|
||||||
|
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
|
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
|
||||||
onClick={async (e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
|
||||||
if (!attachmentId) {
|
setPreviewFile({
|
||||||
toast.error('Cannot download: Attachment ID missing');
|
fileName,
|
||||||
return;
|
fileType,
|
||||||
}
|
fileUrl: previewUrl,
|
||||||
|
fileSize,
|
||||||
try {
|
attachmentId
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
});
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to download file');
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
title="Download file"
|
title="Preview file"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
|
||||||
|
onClick={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!attachmentId) {
|
||||||
|
toast.error('Cannot download: Attachment ID missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to download file');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Download file"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -530,10 +528,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => addReaction(msg.id, reaction.emoji)}
|
onClick={() => addReaction(msg.id, reaction.emoji)}
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${
|
||||||
|
reaction.users.includes('You')
|
||||||
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{reaction.emoji}</span>
|
<span>{reaction.emoji}</span>
|
||||||
<span className="text-xs font-medium">{reaction.users.length}</span>
|
<span className="text-xs font-medium">{reaction.users.length}</span>
|
||||||
|
|||||||
@ -297,15 +297,17 @@ export function ApprovalWorkflowStep({
|
|||||||
<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 ${approver.email
|
<div className={`p-4 rounded-lg border-2 transition-all ${
|
||||||
? 'border-green-200 bg-green-50'
|
approver.email
|
||||||
: 'border-gray-200 bg-gray-50'
|
? 'border-green-200 bg-green-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 ${approver.email
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
? 'bg-green-600'
|
approver.email
|
||||||
: 'bg-gray-400'
|
? 'bg-green-600'
|
||||||
}`}>
|
: '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">
|
||||||
@ -334,7 +336,7 @@ export function ApprovalWorkflowStep({
|
|||||||
<Input
|
<Input
|
||||||
id={`approver-${level}`}
|
id={`approver-${level}`}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`}
|
placeholder="approver@royalenfield.com"
|
||||||
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"
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { useState, ChangeEvent, DragEvent, RefObject } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
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';
|
||||||
@ -20,7 +21,7 @@ interface DocumentsStepProps {
|
|||||||
onDocumentsToDeleteChange: (ids: string[]) => void;
|
onDocumentsToDeleteChange: (ids: string[]) => void;
|
||||||
onPreviewDocument: (doc: any, isExisting: boolean) => void;
|
onPreviewDocument: (doc: any, isExisting: boolean) => void;
|
||||||
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
|
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
|
||||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
fileInputRef: RefObject<HTMLInputElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,8 +48,9 @@ export function DocumentsStep({
|
|||||||
onDocumentErrors,
|
onDocumentErrors,
|
||||||
fileInputRef
|
fileInputRef
|
||||||
}: DocumentsStepProps) {
|
}: DocumentsStepProps) {
|
||||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
|
const processFiles = (files: File[]) => {
|
||||||
if (files.length === 0) return;
|
if (files.length === 0) return;
|
||||||
|
|
||||||
// Validate files
|
// Validate files
|
||||||
@ -90,6 +92,11 @@ export function DocumentsStep({
|
|||||||
if (validationErrors.length > 0 && onDocumentErrors) {
|
if (validationErrors.length > 0 && onDocumentErrors) {
|
||||||
onDocumentErrors(validationErrors);
|
onDocumentErrors(validationErrors);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = Array.from(event.target.files || []);
|
||||||
|
processFiles(files);
|
||||||
|
|
||||||
// Reset file input
|
// Reset file input
|
||||||
if (event.target) {
|
if (event.target) {
|
||||||
@ -97,6 +104,27 @@ export function DocumentsStep({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
processFiles(files);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemove = (index: number) => {
|
const handleRemove = (index: number) => {
|
||||||
const newDocs = documents.filter((_, i) => i !== index);
|
const newDocs = documents.filter((_, i) => i !== index);
|
||||||
onDocumentsChange(newDocs);
|
onDocumentsChange(newDocs);
|
||||||
@ -156,11 +184,18 @@ export function DocumentsStep({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
|
<div
|
||||||
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
data-testid="documents-upload-area"
|
||||||
|
>
|
||||||
|
<Upload className={`h-12 w-12 mx-auto mb-4 ${isDragging ? 'text-re-green' : 'text-gray-400'}`} />
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
click to browse
|
Drag and drop files here, or click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -52,18 +52,18 @@ export function TemplateSelectionStep({
|
|||||||
const displayTemplates = viewMode === 'main'
|
const displayTemplates = viewMode === 'main'
|
||||||
? [
|
? [
|
||||||
...templates,
|
...templates,
|
||||||
// {
|
{
|
||||||
// id: 'admin-templates-category',
|
id: 'admin-templates-category',
|
||||||
// name: 'Admin Templates',
|
name: 'Admin Templates',
|
||||||
// description: 'Browse standardized request workflows created by your organization administrators',
|
description: 'Browse standardized request workflows created by your organization administrators',
|
||||||
// category: 'Organization',
|
category: 'Organization',
|
||||||
// icon: FolderOpen,
|
icon: FolderOpen,
|
||||||
// estimatedTime: 'Variable',
|
estimatedTime: 'Variable',
|
||||||
// commonApprovers: [],
|
commonApprovers: [],
|
||||||
// suggestedSLA: 0,
|
suggestedSLA: 0,
|
||||||
// priority: 'medium',
|
priority: 'medium',
|
||||||
// fields: {}
|
fields: {}
|
||||||
// } as any
|
} as any
|
||||||
]
|
]
|
||||||
: adminTemplates;
|
: adminTemplates;
|
||||||
|
|
||||||
@ -108,7 +108,7 @@ export function TemplateSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
displayTemplates.map((template) => {
|
displayTemplates.map((template) => {
|
||||||
const isComingSoon = false;
|
const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
|
||||||
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';
|
||||||
|
|||||||
@ -129,7 +129,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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 essential for production mode where we need to exchange code for tokens
|
// This is critical 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
|
||||||
@ -149,14 +149,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// 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) {
|
||||||
// Prod: Verify session with server via httpOnly cookie
|
// Production: Verify session with server via httpOnly cookie
|
||||||
if (!isLoggingOut) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Dev: If no auth data exists, user is not authenticated
|
// Development: If no auth data exists, user is not authenticated
|
||||||
if (!hasAuthData) {
|
if (!hasAuthData) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -323,7 +323,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// Prod MODE: Verify session via httpOnly cookie
|
// PRODUCTION 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();
|
||||||
@ -369,7 +369,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev MODE: Check local token
|
// DEVELOPMENT 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 || '{{IDP_DOMAIN}}';
|
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || 'https://dev-830839.oktapreview.com';
|
||||||
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';
|
||||||
@ -490,15 +490,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
//: Get id_token from TokenManager before clearing anything
|
// CRITICAL: 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
|
||||||
@ -609,7 +609,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: tokens in localStorage
|
// Development mode: tokens in localStorage
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
return token;
|
return token;
|
||||||
@ -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="{{IDP_DOMAIN}}/oauth2/default/v1"
|
domain="https://dev-830839.oktapreview.com/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,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onTemplateTypeChange,
|
// onTemplateTypeChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -130,7 +130,7 @@ 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" />
|
||||||
@ -140,7 +140,7 @@ export function StandardClosedRequestsFilters({
|
|||||||
<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')}>
|
||||||
|
|||||||
@ -31,13 +31,13 @@ export function StandardRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
// templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onPriorityFilterChange,
|
onPriorityFilterChange,
|
||||||
onTemplateTypeFilterChange,
|
// onTemplateTypeFilterChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -120,7 +120,7 @@ export function StandardRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
|
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -129,7 +129,7 @@ export function StandardRequestsFilters({
|
|||||||
<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: any) => onSortByChange(value)}>
|
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { CustomDatePicker } from '@/components/ui/date-picker';
|
|||||||
interface StandardUserAllRequestsFiltersProps {
|
interface StandardUserAllRequestsFiltersProps {
|
||||||
// Filters
|
// Filters
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
|
lifecycleFilter: string;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
templateTypeFilter: string;
|
templateTypeFilter: string;
|
||||||
@ -64,6 +65,7 @@ interface StandardUserAllRequestsFiltersProps {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
|
onLifecycleChange: (value: string) => void;
|
||||||
onStatusChange: (value: string) => void;
|
onStatusChange: (value: string) => void;
|
||||||
onPriorityChange: (value: string) => void;
|
onPriorityChange: (value: string) => void;
|
||||||
onTemplateTypeChange: (value: string) => void;
|
onTemplateTypeChange: (value: string) => void;
|
||||||
@ -85,9 +87,10 @@ interface StandardUserAllRequestsFiltersProps {
|
|||||||
|
|
||||||
export function StandardUserAllRequestsFilters({
|
export function StandardUserAllRequestsFilters({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
|
lifecycleFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
// templateTypeFilter,
|
||||||
departmentFilter,
|
departmentFilter,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
initiatorFilter: _initiatorFilter,
|
initiatorFilter: _initiatorFilter,
|
||||||
@ -102,9 +105,10 @@ export function StandardUserAllRequestsFilters({
|
|||||||
initiatorSearch,
|
initiatorSearch,
|
||||||
approverSearch,
|
approverSearch,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
|
onLifecycleChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onTemplateTypeChange,
|
// onTemplateTypeChange,
|
||||||
onDepartmentChange,
|
onDepartmentChange,
|
||||||
onSlaComplianceChange,
|
onSlaComplianceChange,
|
||||||
onInitiatorChange: _onInitiatorChange,
|
onInitiatorChange: _onInitiatorChange,
|
||||||
@ -155,6 +159,17 @@ export function StandardUserAllRequestsFilters({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
|
||||||
|
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
|
||||||
|
<SelectValue placeholder="All Requests" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Requests</SelectItem>
|
||||||
|
<SelectItem value="open">Open Requests</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed Requests</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
<SelectTrigger className="h-10" data-testid="status-filter">
|
||||||
<SelectValue placeholder="All Status" />
|
<SelectValue placeholder="All Status" />
|
||||||
@ -180,7 +195,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>
|
||||||
@ -189,7 +204,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Custom</SelectItem>
|
<SelectItem value="CUSTOM">Custom</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select> */}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={departmentFilter}
|
value={departmentFilter}
|
||||||
@ -240,7 +255,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search initiator..."
|
placeholder="Use @ to search initiator..."
|
||||||
value={initiatorSearch.searchQuery}
|
value={initiatorSearch.searchQuery}
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
@ -310,7 +325,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search approver..."
|
placeholder="Use @ to search approver..."
|
||||||
value={approverSearch.searchQuery}
|
value={approverSearch.searchQuery}
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
|
|||||||
@ -280,8 +280,9 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
setShowShareSummaryModal(true);
|
setShowShareSummaryModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
||||||
const isClosed = request?.status === 'closed';
|
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed';
|
||||||
|
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
|
||||||
|
|
||||||
// Fetch summary details if request is closed
|
// Fetch summary details if request is closed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -419,7 +420,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onBack={onBack || (() => window.history.back())}
|
onBack={onBack || (() => window.history.back())}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onShareSummary={handleShareSummary}
|
onShareSummary={summaryId ? handleShareSummary : undefined}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
// Custom module: Business logic for preparing SLA data
|
// Custom module: Business logic for preparing SLA data
|
||||||
slaData={request?.summary?.sla || request?.sla || null}
|
slaData={request?.summary?.sla || request?.sla || null}
|
||||||
@ -516,6 +517,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
generationAttempts={generationAttempts}
|
generationAttempts={generationAttempts}
|
||||||
generationFailed={generationFailed}
|
generationFailed={generationFailed}
|
||||||
maxAttemptsReached={maxAttemptsReached}
|
maxAttemptsReached={maxAttemptsReached}
|
||||||
|
isClosed={isClosed}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@ -172,7 +172,7 @@ export function DealerUserAllRequestsFilters({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search initiator..."
|
placeholder="Use @ to search initiator..."
|
||||||
value={initiatorSearch.searchQuery}
|
value={initiatorSearch.searchQuery}
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
@ -242,7 +242,7 @@ export function DealerUserAllRequestsFilters({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search approver..."
|
placeholder="Use @ to search approver..."
|
||||||
value={approverSearch.searchQuery}
|
value={approverSearch.searchQuery}
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
|
|||||||
@ -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@${import.meta.env.VITE_APP_DOMAIN}` : `system@${import.meta.env.VITE_APP_DOMAIN}`;
|
const systemEmail = step.level === 8 ? 'finance@royalenfield.com' : 'system@royalenfield.com';
|
||||||
const systemName = step.level === 8 ? 'System/Finance' : 'System';
|
const systemName = step.level === 8 ? 'System/Finance' : 'System';
|
||||||
newApprovers.push({
|
newApprovers.push({
|
||||||
email: systemEmail,
|
email: systemEmail,
|
||||||
@ -193,7 +193,7 @@ export function ClaimApproverSelectionStep({
|
|||||||
|
|
||||||
// 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);
|
||||||
@ -876,258 +876,237 @@ export function ClaimApproverSelectionStep({
|
|||||||
<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 ${
|
||||||
|
approver.email && approver.userId
|
||||||
? 'border-green-200 bg-green-50'
|
? '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="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
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||||
|
approver.email && approver.userId
|
||||||
? 'bg-green-600'
|
? 'bg-green-600'
|
||||||
: isPreFilled
|
: isPreFilled
|
||||||
? 'bg-blue-600'
|
? 'bg-blue-600'
|
||||||
: 'bg-gray-400'
|
: 'bg-gray-400'
|
||||||
}`}>
|
}`}>
|
||||||
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
|
<span className="text-white font-semibold text-sm">{currentStepDisplayNumber}</span>
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="font-semibold text-gray-900 text-sm">
|
|
||||||
{step.name}
|
|
||||||
</span>
|
|
||||||
{isLast && (
|
|
||||||
<Badge variant="destructive" className="text-xs">FINAL</Badge>
|
|
||||||
)}
|
|
||||||
{isPreFilled && (
|
|
||||||
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
|
|
||||||
|
|
||||||
{isEditable && (() => {
|
|
||||||
const isVerified = !!(approver.email && approver.userId);
|
|
||||||
const isEmpty = !approver.email && !isPreFilled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<Label htmlFor={`approver-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
|
|
||||||
}`}>
|
|
||||||
Approver Email {!isPreFilled && '*'}
|
|
||||||
{isEmpty && <span className="ml-2 text-[10px] font-semibold italic text-blue-600">(Required)</span>}
|
|
||||||
</Label>
|
|
||||||
{isVerified && (
|
|
||||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
|
||||||
Verified
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
id={`approver-${step.level}`}
|
|
||||||
type="text"
|
|
||||||
placeholder={isPreFilled ? approver.email : "@username or email..."}
|
|
||||||
value={approver.email || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newValue = e.target.value;
|
|
||||||
if (!isPreFilled) {
|
|
||||||
handleApproverEmailChange(step.level, newValue);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isPreFilled || step.isAuto}
|
|
||||||
className={`h-9 border-2 transition-all mt-1 w-full text-sm ${isPreFilled
|
|
||||||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
|
|
||||||
: isVerified
|
|
||||||
? 'bg-green-50/50 border-green-600 focus:border-green-700 ring-offset-green-50 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
|
|
||||||
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
{/* Search suggestions dropdown */}
|
|
||||||
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
|
|
||||||
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
|
||||||
{userSearchLoading[step.level - 1] ? (
|
|
||||||
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
|
||||||
) : (
|
|
||||||
<ul className="max-h-56 overflow-auto divide-y">
|
|
||||||
{userSearchResults[step.level - 1]?.map((u) => (
|
|
||||||
<li
|
|
||||||
key={u.userId}
|
|
||||||
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
|
||||||
onClick={() => handleUserSelect(step.level, u)}
|
|
||||||
>
|
|
||||||
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
|
||||||
<div className="text-xs text-gray-600">{u.email}</div>
|
|
||||||
{u.department && (
|
|
||||||
<div className="text-xs text-gray-500">{u.department}</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{approver.name && (
|
|
||||||
<p className="text-xs text-green-600 mt-1">
|
|
||||||
Selected: <span className="font-semibold">{approver.name}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor={`tat-${step.level}`} className={`text-xs font-bold ${isEmpty ? 'text-blue-900' : isVerified ? 'text-green-900' : 'text-gray-900'
|
|
||||||
}`}>
|
|
||||||
TAT (Turn Around Time) *
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<Input
|
|
||||||
id={`tat-${step.level}`}
|
|
||||||
type="number"
|
|
||||||
placeholder={approver.tatType === 'days' ? '7' : '24'}
|
|
||||||
min="1"
|
|
||||||
max={approver.tatType === 'days' ? '30' : '720'}
|
|
||||||
value={approver.tat || ''}
|
|
||||||
onChange={(e) => handleTatChange(step.level, parseInt(e.target.value) || '')}
|
|
||||||
disabled={step.isAuto}
|
|
||||||
className={`h-9 border-2 transition-all flex-1 text-sm ${isPreFilled
|
|
||||||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed font-medium'
|
|
||||||
: isVerified
|
|
||||||
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 font-semibold text-gray-900'
|
|
||||||
: 'bg-white border-blue-300 shadow-sm shadow-blue-100/50 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={approver.tatType || 'hours'}
|
|
||||||
onValueChange={(value) => handleTatTypeChange(step.level, value as 'hours' | 'days')}
|
|
||||||
disabled={step.isAuto}
|
|
||||||
>
|
|
||||||
<SelectTrigger className={`w-20 h-9 border-2 transition-all text-sm ${isPreFilled
|
|
||||||
? 'bg-gray-100/80 border-gray-300 text-gray-700 cursor-not-allowed'
|
|
||||||
: isVerified
|
|
||||||
? 'bg-green-50/50 border-green-600 focus:border-green-700 focus:ring-1 focus:ring-green-100 text-gray-900 font-medium'
|
|
||||||
: 'bg-white border-blue-300 focus:border-blue-500 focus:ring-1 focus:ring-blue-100 text-gray-900'
|
|
||||||
}`}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="hours">Hours</SelectItem>
|
|
||||||
<SelectItem value="days">Days</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="font-semibold text-gray-900 text-sm">
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
{isLast && (
|
||||||
|
<Badge variant="destructive" className="text-xs">FINAL</Badge>
|
||||||
|
)}
|
||||||
|
{isPreFilled && (
|
||||||
|
<Badge variant="outline" className="text-xs">PRE-FILLED</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mb-2">{step.description}</p>
|
||||||
|
|
||||||
{/* Render additional approvers after this step */}
|
{isEditable && (
|
||||||
{additionalApproversAfter.map((addApprover: ClaimApprover, addIndex: number) => {
|
<div className="space-y-2">
|
||||||
// Additional approvers come after the current step, so they should be numbered after it
|
<div>
|
||||||
const addDisplayNumber = currentStepDisplayNumber + addIndex + 1;
|
<div className="flex items-center justify-between mb-1">
|
||||||
return (
|
<Label htmlFor={`approver-${step.level}`} className="text-xs font-medium">
|
||||||
<div key={`additional-${addApprover.level}`} className="space-y-1">
|
Email Address {!isPreFilled && '*'}
|
||||||
<div className="flex justify-center">
|
</Label>
|
||||||
<div className="w-px h-3 bg-gray-300"></div>
|
{approver.email && approver.userId && (
|
||||||
</div>
|
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||||||
<div className="p-3 rounded-lg border-2 border-purple-200 bg-purple-50">
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
<div className="flex items-start gap-3">
|
Verified
|
||||||
<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>
|
</Badge>
|
||||||
{addApprover.email && addApprover.userId && (
|
)}
|
||||||
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
</div>
|
||||||
<CheckCircle className="w-3 h-3 mr-1" />
|
<div className="relative">
|
||||||
Verified
|
<Input
|
||||||
</Badge>
|
id={`approver-${step.level}`}
|
||||||
)}
|
type="text"
|
||||||
<Button
|
placeholder={isPreFilled ? approver.email : "@approver@royalenfield.com"}
|
||||||
type="button"
|
value={approver.email || ''}
|
||||||
variant="ghost"
|
onChange={(e) => {
|
||||||
size="sm"
|
const newValue = e.target.value;
|
||||||
onClick={() => handleRemoveAdditionalApprover(addApprover.level)}
|
if (!isPreFilled) {
|
||||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
handleApproverEmailChange(step.level, newValue);
|
||||||
>
|
}
|
||||||
<X className="w-3 h-3" />
|
}}
|
||||||
</Button>
|
disabled={isPreFilled || step.isAuto}
|
||||||
</div>
|
className="h-9 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full text-sm"
|
||||||
<p className="text-xs text-gray-600 mb-2">
|
/>
|
||||||
{addApprover.name || addApprover.email || 'No approver assigned'}
|
{/* Search suggestions dropdown */}
|
||||||
</p>
|
{!isPreFilled && !step.isAuto && (userSearchLoading[step.level - 1] || (userSearchResults[step.level - 1]?.length || 0) > 0) && (
|
||||||
{addApprover.email && (
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
||||||
<div className="text-xs text-gray-500 space-y-1">
|
{userSearchLoading[step.level - 1] ? (
|
||||||
<div>Email: {addApprover.email}</div>
|
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
||||||
{addApprover.tat && (
|
) : (
|
||||||
<div>TAT: {addApprover.tat} {addApprover.tatType}</div>
|
<ul className="max-h-56 overflow-auto divide-y">
|
||||||
|
{userSearchResults[step.level - 1]?.map((u) => (
|
||||||
|
<li
|
||||||
|
key={u.userId}
|
||||||
|
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
||||||
|
onClick={() => handleUserSelect(step.level, u)}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
||||||
|
<div className="text-xs text-gray-600">{u.email}</div>
|
||||||
|
{u.department && (
|
||||||
|
<div className="text-xs text-gray-500">{u.department}</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
);
|
</div>
|
||||||
|
);
|
||||||
});
|
});
|
||||||
})()}
|
})()}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -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 { verifyDealerLogin, searchExternalDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
import { getAllDealers as fetchDealersFromAPI, verifyDealerLogin, 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';
|
||||||
@ -194,26 +194,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
// Debounce search
|
// Debounce search
|
||||||
dealerSearchTimer.current = setTimeout(async () => {
|
dealerSearchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await searchExternalDealerByCode(value);
|
const results = await fetchDealersFromAPI(value, 10); // Limit to 10 results
|
||||||
if (result) {
|
setDealerSearchResults(results);
|
||||||
// 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 external dealer:', error);
|
console.error('Error searching dealers:', error);
|
||||||
setDealerSearchResults([]);
|
setDealerSearchResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setDealerSearchLoading(false);
|
setDealerSearchLoading(false);
|
||||||
@ -250,12 +234,12 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
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 || [];
|
||||||
@ -898,8 +882,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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'
|
<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'
|
||||||
|
}`}>
|
||||||
<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">
|
||||||
@ -1065,8 +1050,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
{STEP_NAMES.map((_name, index) => (
|
{STEP_NAMES.map((_name, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
className={`text-xs sm:text-sm ${
|
||||||
}`}
|
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
@ -1099,10 +1085,11 @@ 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 ${!isStepValid()
|
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
|
||||||
? 'opacity-50 cursor-pointer hover:opacity-60'
|
!isStepValid()
|
||||||
: ''
|
? '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, useMemo } from 'react';
|
import { useState, useEffect } 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, CircleAlert } from 'lucide-react';
|
import { DollarSign, Download, CircleCheckBig, Target } 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,87 +30,95 @@ interface IOBlockedDetails {
|
|||||||
blockedDate: string;
|
blockedDate: string;
|
||||||
blockedBy: string; // User who blocked
|
blockedBy: string; // User who blocked
|
||||||
sapDocumentNumber: string;
|
sapDocumentNumber: string;
|
||||||
status: 'blocked' | 'released' | 'failed' | 'pending';
|
status: 'blocked' | 'released' | 'failed';
|
||||||
}
|
}
|
||||||
|
|
||||||
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 claimDetails = apiRequest?.claimDetails || apiRequest || {};
|
const estimatedBudget = Number(proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
|
||||||
|
|
||||||
// Calculate total base amount (needed for budget verification as requested)
|
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
||||||
// 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 [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
|
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
||||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
const [blockingBudget, setBlockingBudget] = useState(false);
|
||||||
|
|
||||||
// Load existing IO blocks
|
// Load existing IO block details from apiRequest
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (internalOrdersList.length > 0) {
|
if (internalOrder && existingIONumber) {
|
||||||
const formattedIOs = internalOrdersList.map((io: any) => {
|
// IMPORTANT: ioAvailableBalance is already the available balance BEFORE blocking
|
||||||
const org = io.organizer || null;
|
// We should NOT add blockedAmount to it - that would cause double deduction
|
||||||
const blockedByName = org?.displayName ||
|
// Backend stores: availableBalance (before block), blockedAmount, remainingBalance (after block)
|
||||||
org?.display_name ||
|
const availableBeforeBlock = Number(existingAvailableBalance) || 0;
|
||||||
org?.name ||
|
|
||||||
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) ||
|
|
||||||
org?.email ||
|
|
||||||
'Unknown User';
|
|
||||||
return {
|
|
||||||
ioNumber: io.ioNumber || io.io_number,
|
|
||||||
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
|
|
||||||
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
|
|
||||||
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
|
|
||||||
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
|
|
||||||
blockedBy: blockedByName,
|
|
||||||
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
|
|
||||||
status: (io.status === 'BLOCKED' ? 'blocked' :
|
|
||||||
io.status === 'RELEASED' ? 'released' :
|
|
||||||
io.status === 'PENDING' ? 'pending' : 'blocked') as any,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setBlockedIOs(formattedIOs);
|
|
||||||
|
|
||||||
// If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience
|
// Get blocked by user name from organizer association (who blocked the amount)
|
||||||
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
|
// When amount is blocked, organizedBy stores the user who blocked it
|
||||||
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
|
const blockedByName = organizer?.displayName ||
|
||||||
|
organizer?.display_name ||
|
||||||
|
organizer?.name ||
|
||||||
|
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
|
||||||
|
organizer?.email ||
|
||||||
|
'Unknown User';
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
sapDocumentNumber: sapDocNumber,
|
||||||
|
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
||||||
|
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set fetched amount if available balance exists
|
||||||
|
if (availableBeforeBlock > 0) {
|
||||||
|
setFetchedAmount(availableBeforeBlock);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
|
}, [internalOrder, existingIONumber, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available budget from SAP
|
* Fetch available budget from SAP
|
||||||
@ -135,22 +143,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
|
|
||||||
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
|
||||||
// Calculate total already blocked amount
|
if (estimatedBudget > 0) {
|
||||||
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
|
setAmountToBlock(String(estimatedBudget));
|
||||||
|
|
||||||
// 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.toFixed(2)));
|
setAmountToBlock(String(ioData.availableBalance));
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
@ -201,18 +199,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
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));
|
||||||
const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(2));
|
if (Math.abs(blockAmount - roundedEstimatedBudget) > 0.01) {
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -271,11 +262,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
// 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,
|
||||||
@ -288,9 +279,8 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
setBlockedIOs(prev => [...prev, blocked]);
|
setBlockedDetails(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
|
||||||
@ -331,12 +321,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 || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
|
disabled={fetchingAmount || !!blockedDetails}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFetchAmount}
|
onClick={handleFetchAmount}
|
||||||
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
|
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
|
||||||
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" />
|
||||||
@ -346,7 +336,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 && blockedIOs.length === 0 && ioNumber.trim() && (
|
{!fetchedAmount && !blockedDetails && 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.
|
||||||
@ -355,7 +345,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fetched Amount Display */}
|
{/* Fetched Amount Display */}
|
||||||
{fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && (
|
{fetchedAmount !== null && !blockedDetails && (
|
||||||
<>
|
<>
|
||||||
<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">
|
||||||
@ -406,7 +396,7 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
!amountToBlock ||
|
!amountToBlock ||
|
||||||
parseFloat(amountToBlock) <= 0 ||
|
parseFloat(amountToBlock) <= 0 ||
|
||||||
parseFloat(amountToBlock) > fetchedAmount ||
|
parseFloat(amountToBlock) > fetchedAmount ||
|
||||||
(estimatedBudget > 0 && Math.abs((blockedIOs.reduce((s, i) => s + i.blockedAmount, 0) + parseFloat(amountToBlock)) - estimatedBudget) > 0.01)
|
(estimatedBudget > 0 && Math.abs(parseFloat(amountToBlock) - estimatedBudget) > 0.01)
|
||||||
}
|
}
|
||||||
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
||||||
>
|
>
|
||||||
@ -430,57 +420,71 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{blockedIOs.length > 0 ? (
|
{blockedDetails ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{isAdditionalBlockingNeeded && (
|
{/* Success Banner */}
|
||||||
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
|
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
|
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</p>
|
<p className="font-semibold text-green-900">IO Blocked Successfully</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>
|
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{blockedIOs.slice().reverse().map((io, idx) => (
|
{/* Blocked Details */}
|
||||||
<div key={idx} className="border rounded-lg overflow-hidden">
|
<div className="border rounded-lg divide-y">
|
||||||
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
|
<div className="p-4">
|
||||||
<span className="font-semibold text-sm">IO: {io.ioNumber}</span>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
|
||||||
<Badge className={
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
|
||||||
io.status === 'blocked' ? 'bg-green-100 text-green-800' :
|
</div>
|
||||||
io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
|
<div className="p-4">
|
||||||
'bg-blue-100 text-blue-800'
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
||||||
}>
|
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
||||||
{io.status === 'blocked' ? 'Blocked' :
|
</div>
|
||||||
io.status === 'pending' ? 'Provisioned' : 'Released'}
|
<div className="p-4 bg-green-50">
|
||||||
</Badge>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
|
||||||
</div>
|
<p className="text-xl font-bold text-green-700">
|
||||||
<div className="grid grid-cols-2 divide-x divide-y">
|
₹{blockedDetails.blockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
<div className="p-3">
|
</p>
|
||||||
<p className="text-[10px] text-gray-500 uppercase">Amount</p>
|
</div>
|
||||||
<p className="text-sm font-bold text-green-700">₹{io.blockedAmount.toLocaleString('en-IN')}</p>
|
<div className="p-4">
|
||||||
</div>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
|
||||||
<div className="p-3">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p>
|
₹{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
<p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3">
|
<div className="p-4 bg-blue-50">
|
||||||
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p>
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
|
||||||
<p className="text-xs">{io.blockedBy}</p>
|
<p className="text-sm font-bold text-blue-700">
|
||||||
</div>
|
₹{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
<div className="p-3">
|
</p>
|
||||||
<p className="text-[10px] text-gray-500 uppercase">Date</p>
|
</div>
|
||||||
<p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p>
|
<div className="p-4">
|
||||||
</div>
|
<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 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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -44,6 +44,7 @@ interface ClaimManagementOverviewTabProps {
|
|||||||
generationAttempts?: number;
|
generationAttempts?: number;
|
||||||
generationFailed?: boolean;
|
generationFailed?: boolean;
|
||||||
maxAttemptsReached?: boolean;
|
maxAttemptsReached?: boolean;
|
||||||
|
isClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaimManagementOverviewTab({
|
export function ClaimManagementOverviewTab({
|
||||||
@ -64,6 +65,7 @@ export function ClaimManagementOverviewTab({
|
|||||||
generationAttempts = 0,
|
generationAttempts = 0,
|
||||||
generationFailed = false,
|
generationFailed = false,
|
||||||
maxAttemptsReached = false,
|
maxAttemptsReached = false,
|
||||||
|
isClosed = false,
|
||||||
}: ClaimManagementOverviewTabProps) {
|
}: ClaimManagementOverviewTabProps) {
|
||||||
// Check if this is a claim management request
|
// Check if this is a claim management request
|
||||||
if (!isClaimManagementRequest(apiRequest)) {
|
if (!isClaimManagementRequest(apiRequest)) {
|
||||||
@ -136,7 +138,7 @@ export function ClaimManagementOverviewTab({
|
|||||||
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
|
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
|
||||||
|
|
||||||
{/* Closed Request Conclusion Remark Display */}
|
{/* Closed Request Conclusion Remark Display */}
|
||||||
{apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && (
|
{isClosed && apiRequest?.conclusionRemark && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@ -166,19 +168,16 @@ export function ClaimManagementOverviewTab({
|
|||||||
{/* Conclusion Remark Section - Closure Setup */}
|
{/* Conclusion Remark Section - Closure Setup */}
|
||||||
{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 ${(apiRequest?.status || '').toLowerCase() === 'rejected'
|
||||||
(apiRequest?.status || '').toLowerCase() === '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 ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||||
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
|
}`}>
|
||||||
}`}>
|
<CheckCircle className={`w-5 h-5 ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||||
<CheckCircle className={`w-5 h-5 ${
|
}`} />
|
||||||
(apiRequest?.status || '').toLowerCase() === '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">
|
||||||
@ -201,7 +200,7 @@ export function ClaimManagementOverviewTab({
|
|||||||
{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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye, FileSpreadsheet, X, Loader2 } from 'lucide-react';
|
import { TrendingUp, Clock, CheckCircle, CircleCheckBig, Upload, Mail, Download, Receipt, Activity, AlertTriangle, AlertOctagon, XCircle, History, ChevronDown, ChevronUp, RefreshCw, RotateCw, Eye } from 'lucide-react';
|
||||||
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||||
import {
|
import {
|
||||||
@ -29,7 +29,6 @@ import { toast } from 'sonner';
|
|||||||
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer } from '@/services/dealerClaimApi';
|
import { submitProposal, updateIODetails, submitCompletion, updateEInvoice, sendCreditNoteToDealer } from '@/services/dealerClaimApi';
|
||||||
import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi';
|
import { getWorkflowDetails, approveLevel, rejectLevel, handleInitiatorAction, getWorkflowHistory } from '@/services/workflowApi';
|
||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
import { TokenManager } from '@/utils/tokenManager';
|
|
||||||
|
|
||||||
interface DealerClaimWorkflowTabProps {
|
interface DealerClaimWorkflowTabProps {
|
||||||
request: any;
|
request: any;
|
||||||
@ -49,7 +48,7 @@ interface WorkflowStep {
|
|||||||
approver: string;
|
approver: string;
|
||||||
description: string;
|
description: string;
|
||||||
tatHours: number;
|
tatHours: number;
|
||||||
status: 'pending' | 'approved' | 'waiting' | 'rejected' | 'in_progress' | 'skipped';
|
status: 'pending' | 'approved' | 'waiting' | 'rejected' | 'in_progress';
|
||||||
comment?: string;
|
comment?: string;
|
||||||
approvedAt?: string;
|
approvedAt?: string;
|
||||||
elapsedHours?: number;
|
elapsedHours?: number;
|
||||||
@ -70,7 +69,6 @@ interface WorkflowStep {
|
|||||||
};
|
};
|
||||||
einvoiceUrl?: string;
|
einvoiceUrl?: string;
|
||||||
emailTemplateUrl?: string;
|
emailTemplateUrl?: string;
|
||||||
levelName?: string;
|
|
||||||
versionHistory?: {
|
versionHistory?: {
|
||||||
current: any;
|
current: any;
|
||||||
previous: any;
|
previous: any;
|
||||||
@ -109,8 +107,8 @@ const getStepIcon = (status: string) => {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
return <CircleCheckBig className="w-5 h-5 text-green-600" />;
|
return <CircleCheckBig className="w-5 h-5 text-green-600" />;
|
||||||
case 'skipped':
|
case 'in_progress':
|
||||||
return <CheckCircle className="w-5 h-5 text-green-600" />;
|
return <RotateCw className="w-5 h-5 text-purple-600 animate-spin-slow" />;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return <Clock className="w-5 h-5 text-blue-600" />;
|
return <Clock className="w-5 h-5 text-blue-600" />;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
@ -127,8 +125,8 @@ const getStepBadgeVariant = (status: string) => {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
return 'bg-green-100 text-green-800 border-green-200';
|
return 'bg-green-100 text-green-800 border-green-200';
|
||||||
case 'skipped':
|
case 'in_progress':
|
||||||
return 'bg-green-50 text-green-700 border-green-200';
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
@ -145,7 +143,7 @@ const getStepCardStyle = (status: string, isActive: boolean) => {
|
|||||||
if (isActive && (status === 'pending' || status === 'in_progress')) {
|
if (isActive && (status === 'pending' || status === 'in_progress')) {
|
||||||
return 'border-purple-500 bg-purple-50 shadow-md';
|
return 'border-purple-500 bg-purple-50 shadow-md';
|
||||||
}
|
}
|
||||||
if (status === 'approved' || status === 'skipped') {
|
if (status === 'approved') {
|
||||||
return 'border-green-500 bg-green-50';
|
return 'border-green-500 bg-green-50';
|
||||||
}
|
}
|
||||||
if (status === 'rejected') {
|
if (status === 'rejected') {
|
||||||
@ -161,8 +159,8 @@ const getStepIconBg = (status: string) => {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved':
|
case 'approved':
|
||||||
return 'bg-green-100';
|
return 'bg-green-100';
|
||||||
case 'skipped':
|
case 'in_progress':
|
||||||
return 'bg-green-100';
|
return 'bg-blue-100';
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'bg-purple-100';
|
return 'bg-purple-100';
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
@ -195,9 +193,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
const [showHistory, setShowHistory] = useState(false);
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set());
|
const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set());
|
||||||
const [viewSnapshot, setViewSnapshot] = useState<{ data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string } | null>(null);
|
const [viewSnapshot, setViewSnapshot] = useState<{ data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string } | null>(null);
|
||||||
const [invoicePdfUrl, setInvoicePdfUrl] = useState<string | null>(null);
|
|
||||||
const [showInvoicePdfModal, setShowInvoicePdfModal] = useState(false);
|
|
||||||
const [invoicePdfLoading, setInvoicePdfLoading] = useState(false);
|
|
||||||
|
|
||||||
// Load approval flows from real API
|
// Load approval flows from real API
|
||||||
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
|
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
|
||||||
@ -340,50 +335,28 @@ export function DealerClaimWorkflowTab({
|
|||||||
// Step title and description mapping based on actual step number (not array index)
|
// Step title and description mapping based on actual step number (not array index)
|
||||||
// This handles cases where approvers are added between steps
|
// This handles cases where approvers are added between steps
|
||||||
const getStepTitle = (stepNumber: number, levelName?: string, approverName?: string): string => {
|
const getStepTitle = (stepNumber: number, levelName?: string, approverName?: string): string => {
|
||||||
// Check if this is a legacy workflow (8 steps) or new workflow (5 steps)
|
|
||||||
// Legacy flows have system steps (Activity, E-Invoice, Credit Note) as approval levels
|
|
||||||
const isLegacyFlow = (request?.totalLevels || 0) > 5 || (request?.approvalLevels?.length || 0) > 5;
|
|
||||||
|
|
||||||
// Use levelName from backend if available (most accurate)
|
// Use levelName from backend if available (most accurate)
|
||||||
// Check if it's an "Additional Approver" - this indicates a dynamically added approver
|
// Check if it's an "Additional Approver" - this indicates a dynamically added approver
|
||||||
if (levelName && levelName.trim()) {
|
if (levelName && levelName.trim()) {
|
||||||
const levelNameLower = levelName.toLowerCase();
|
|
||||||
|
|
||||||
// If it starts with "Additional Approver", use it as-is (it's already formatted)
|
// If it starts with "Additional Approver", use it as-is (it's already formatted)
|
||||||
if (levelNameLower.includes('additional approver')) {
|
if (levelName.toLowerCase().includes('additional approver')) {
|
||||||
return levelName;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If levelName is NOT generic "Step X", return it
|
|
||||||
// This fixes the issue where backend sends "Step 1" instead of "Dealer Proposal Submission"
|
|
||||||
if (!/^step\s+\d+$/i.test(levelName)) {
|
|
||||||
return levelName;
|
return levelName;
|
||||||
}
|
}
|
||||||
|
// Otherwise use the levelName from backend (preserved from original step)
|
||||||
|
return levelName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to mapping based on step number and flow version
|
// Fallback to mapping based on step number
|
||||||
const stepTitleMap: Record<number, string> = isLegacyFlow
|
const stepTitleMap: Record<number, string> = {
|
||||||
? {
|
1: 'Dealer - Proposal Submission',
|
||||||
// Legacy 8-step flow
|
2: 'Requestor Evaluation & Confirmation',
|
||||||
1: 'Dealer - Proposal Submission',
|
3: 'Department Lead Approval',
|
||||||
2: 'Requestor Evaluation & Confirmation',
|
4: 'Activity Creation',
|
||||||
3: 'Department Lead Approval',
|
5: 'Dealer - Completion Documents',
|
||||||
4: 'Activity Creation',
|
6: 'Requestor - Claim Approval',
|
||||||
5: 'Dealer - Completion Documents',
|
7: 'E-Invoice Generation',
|
||||||
6: 'Requestor - Claim Approval',
|
8: 'Credit Note from SAP',
|
||||||
7: 'E-Invoice Generation',
|
};
|
||||||
8: 'Credit Note from SAP',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
// New 5-step flow
|
|
||||||
1: 'Dealer - Proposal Submission',
|
|
||||||
2: 'Requestor Evaluation & Confirmation',
|
|
||||||
3: 'Department Lead Approval',
|
|
||||||
4: 'Dealer - Completion Documents',
|
|
||||||
5: 'Requestor - Claim Approval',
|
|
||||||
6: 'E-Invoice Generation',
|
|
||||||
7: 'Credit Note from SAP',
|
|
||||||
};
|
|
||||||
|
|
||||||
// If step number exists in map, use it
|
// If step number exists in map, use it
|
||||||
if (stepTitleMap[stepNumber]) {
|
if (stepTitleMap[stepNumber]) {
|
||||||
@ -410,9 +383,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
return `Additional approver will review and approve this request.`;
|
return `Additional approver will review and approve this request.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a legacy workflow (8 steps) or new workflow (5 steps)
|
|
||||||
const isLegacyFlow = (request?.totalLevels || 0) > 5 || (request?.approvalLevels?.length || 0) > 5;
|
|
||||||
|
|
||||||
// Use levelName to determine description (handles shifted steps correctly)
|
// Use levelName to determine description (handles shifted steps correctly)
|
||||||
// This ensures descriptions shift with their steps when approvers are added
|
// This ensures descriptions shift with their steps when approvers are added
|
||||||
if (levelName && levelName.trim()) {
|
if (levelName && levelName.trim()) {
|
||||||
@ -428,7 +398,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
if (levelNameLower.includes('department lead')) {
|
if (levelNameLower.includes('department lead')) {
|
||||||
return 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)';
|
return 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)';
|
||||||
}
|
}
|
||||||
// Re-added for legacy support
|
|
||||||
if (levelNameLower.includes('activity creation')) {
|
if (levelNameLower.includes('activity creation')) {
|
||||||
return 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.';
|
return 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.';
|
||||||
}
|
}
|
||||||
@ -438,37 +407,25 @@ export function DealerClaimWorkflowTab({
|
|||||||
if (levelNameLower.includes('requestor') && (levelNameLower.includes('claim') || levelNameLower.includes('approval'))) {
|
if (levelNameLower.includes('requestor') && (levelNameLower.includes('claim') || levelNameLower.includes('approval'))) {
|
||||||
return 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.';
|
return 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.';
|
||||||
}
|
}
|
||||||
if (levelNameLower.includes('e-invoice') || levelNameLower.includes('invoice generation') || levelNameLower.includes('dms')) {
|
if (levelNameLower.includes('e-invoice') || levelNameLower.includes('invoice generation')) {
|
||||||
return 'E-Invoice will be generated upon settlement initiation.';
|
return 'E-invoice will be generated through DMS.';
|
||||||
}
|
}
|
||||||
if (levelNameLower.includes('credit note') || levelNameLower.includes('sap')) {
|
if (levelNameLower.includes('credit note') || levelNameLower.includes('sap')) {
|
||||||
return 'Got credit note from SAP. Review and send to dealer to complete the claim management process.';
|
return 'Got credit note from SAP. Review and send to dealer to complete the claim management process.';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to step number mapping depending on flow version
|
// Fallback to step number mapping (for backwards compatibility)
|
||||||
const stepDescriptionMap: Record<number, string> = isLegacyFlow
|
const stepDescriptionMap: Record<number, string> = {
|
||||||
? {
|
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
|
||||||
// Legacy 8-step flow
|
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
|
||||||
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
|
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
|
||||||
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
|
4: 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
|
||||||
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
|
5: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
|
||||||
4: 'Activity is created. Activity confirmation email is auto-triggered to dealer / requestor / Lead. IO confirmation to be made.',
|
6: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
|
||||||
5: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
|
7: 'E-invoice will be generated through DMS.',
|
||||||
6: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
|
8: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
|
||||||
7: 'E-Invoice will be generated upon settlement initiation.',
|
};
|
||||||
8: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
// New 5-step flow
|
|
||||||
1: 'Dealer submits the proposal for the activity with comments including proposal document with requested details, cost break-up, timeline for closure, and other requests',
|
|
||||||
2: 'Requestor evaluates the request and confirms with comments. Decision point: Confirms? (YES → Continue to Dept Lead / NO → Request is cancelled)',
|
|
||||||
3: 'Department Lead approval. Decision point: Approved? (YES → Budget is blocked in the respective IO for the activity / NO → More clarification required → Request is cancelled)',
|
|
||||||
4: 'Dealer submits the necessary documents upon completion of the activity including document attachments (Zip Folder) and brief description',
|
|
||||||
5: 'Requestor approves the claim in full or can modify the amount. If more information is required, can request additional details from dealer.',
|
|
||||||
6: 'E-Invoice will be generated upon settlement initiation.',
|
|
||||||
7: 'Got credit note from SAP. Review and send to dealer to complete the claim management process.',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (stepDescriptionMap[stepNumber]) {
|
if (stepDescriptionMap[stepNumber]) {
|
||||||
return stepDescriptionMap[stepNumber];
|
return stepDescriptionMap[stepNumber];
|
||||||
@ -676,8 +633,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
const approvalStatus = approval.status.toLowerCase();
|
const approvalStatus = approval.status.toLowerCase();
|
||||||
if (approvalStatus === 'approved') {
|
if (approvalStatus === 'approved') {
|
||||||
normalizedStatus = 'approved';
|
normalizedStatus = 'approved';
|
||||||
} else if (approvalStatus === 'skipped') {
|
|
||||||
normalizedStatus = 'skipped';
|
|
||||||
} else if (approvalStatus === 'rejected') {
|
} else if (approvalStatus === 'rejected') {
|
||||||
normalizedStatus = 'rejected';
|
normalizedStatus = 'rejected';
|
||||||
} else {
|
} else {
|
||||||
@ -776,7 +731,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
// Note: Status normalization already handled in workflowSteps mapping above
|
// Note: Status normalization already handled in workflowSteps mapping above
|
||||||
// backendCurrentLevel is already calculated above before the map function
|
// backendCurrentLevel is already calculated above before the map function
|
||||||
|
|
||||||
//: If request is rejected or closed, no step should be active
|
// CRITICAL: If request is rejected or closed, no step should be active
|
||||||
let activeStep = null;
|
let activeStep = null;
|
||||||
let currentStep = 1;
|
let currentStep = 1;
|
||||||
|
|
||||||
@ -896,29 +851,13 @@ export function DealerClaimWorkflowTab({
|
|||||||
await uploadDocument(file, requestId, 'SUPPORTING');
|
await uploadDocument(file, requestId, 'SUPPORTING');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Submit proposal using dealer claim API (calculate total from inclusive item totals)
|
// Submit proposal using dealer claim API
|
||||||
const totalBudget = data.costBreakup.reduce((sum, item: any) => sum + (item.totalAmt || item.amount || 0), 0);
|
const totalBudget = data.costBreakup.reduce((sum, item) => sum + item.amount, 0);
|
||||||
await submitProposal(requestId, {
|
await submitProposal(requestId, {
|
||||||
proposalDocument: data.proposalDocument || undefined,
|
proposalDocument: data.proposalDocument || undefined,
|
||||||
costBreakup: data.costBreakup.map((item: any) => ({
|
costBreakup: data.costBreakup.map(item => ({
|
||||||
description: item.description,
|
description: item.description,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
gstRate: item.gstRate,
|
|
||||||
gstAmt: item.gstAmt,
|
|
||||||
cgstRate: item.cgstRate,
|
|
||||||
cgstAmt: item.cgstAmt,
|
|
||||||
sgstRate: item.sgstRate,
|
|
||||||
sgstAmt: item.sgstAmt,
|
|
||||||
igstRate: item.igstRate,
|
|
||||||
igstAmt: item.igstAmt,
|
|
||||||
utgstRate: item.utgstRate,
|
|
||||||
utgstAmt: item.utgstAmt,
|
|
||||||
cessRate: item.cessRate,
|
|
||||||
cessAmt: item.cessAmt,
|
|
||||||
totalAmt: item.totalAmt,
|
|
||||||
quantity: item.quantity,
|
|
||||||
hsnCode: item.hsnCode,
|
|
||||||
isService: item.isService
|
|
||||||
})),
|
})),
|
||||||
totalEstimatedBudget: totalBudget,
|
totalEstimatedBudget: totalBudget,
|
||||||
expectedCompletionDate: data.expectedCompletionDate,
|
expectedCompletionDate: data.expectedCompletionDate,
|
||||||
@ -1102,45 +1041,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle re-quotation request from Claim Approval step (Step 6)
|
|
||||||
const handleClaimReQuotation = async (comments: string) => {
|
|
||||||
try {
|
|
||||||
if (!request?.id && !request?.requestId) {
|
|
||||||
throw new Error('Request ID not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = request.id || request.requestId;
|
|
||||||
|
|
||||||
// Get workflow details to find the Requestor Claim Approval levelId dynamically
|
|
||||||
const details = await getWorkflowDetails(requestId);
|
|
||||||
const approvals = details?.approvalLevels || details?.approvals || [];
|
|
||||||
|
|
||||||
// Find the Requestor Claim Approval step
|
|
||||||
const claimApprovalLevel = approvals.find((level: any) => {
|
|
||||||
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
|
||||||
return levelName.includes('requestor claim') || levelName.includes('requestor - claim');
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!claimApprovalLevel?.levelId && !claimApprovalLevel?.level_id) {
|
|
||||||
throw new Error('Claim approval level not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const levelId = claimApprovalLevel.levelId || claimApprovalLevel.level_id;
|
|
||||||
|
|
||||||
// Reject the claim approval step with 'Revised Quotation Requested'
|
|
||||||
// This will trigger the backend to return the workflow to the Dealer Proposal step
|
|
||||||
await rejectLevel(requestId, levelId, 'Revised Quotation Requested', comments);
|
|
||||||
|
|
||||||
toast.success('Re-quotation requested. Request returned to dealer.');
|
|
||||||
handleRefresh();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to request re-quotation:', error);
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to request re-quotation. Please try again.';
|
|
||||||
toast.error(errorMessage);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle IO approval (Department Lead step - found dynamically)
|
// Handle IO approval (Department Lead step - found dynamically)
|
||||||
const handleIOApproval = async (data: {
|
const handleIOApproval = async (data: {
|
||||||
ioNumber: string;
|
ioNumber: string;
|
||||||
@ -1212,26 +1112,10 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
const requestId = request.id || request.requestId;
|
const requestId = request.id || request.requestId;
|
||||||
|
|
||||||
// Transform expense items to match API format (include GST fields)
|
// Transform expense items to match API format
|
||||||
const closedExpenses = data.closedExpenses.map((item: any) => ({
|
const closedExpenses = data.closedExpenses.map(item => ({
|
||||||
description: item.description,
|
description: item.description,
|
||||||
amount: item.amount,
|
amount: item.amount,
|
||||||
gstRate: item.gstRate,
|
|
||||||
gstAmt: item.gstAmt,
|
|
||||||
cgstRate: item.cgstRate,
|
|
||||||
cgstAmt: item.cgstAmt,
|
|
||||||
sgstRate: item.sgstRate,
|
|
||||||
sgstAmt: item.sgstAmt,
|
|
||||||
igstRate: item.igstRate,
|
|
||||||
igstAmt: item.igstAmt,
|
|
||||||
utgstRate: item.utgstRate,
|
|
||||||
utgstAmt: item.utgstAmt,
|
|
||||||
cessRate: item.cessRate,
|
|
||||||
cessAmt: item.cessAmt,
|
|
||||||
totalAmt: item.totalAmt,
|
|
||||||
quantity: item.quantity,
|
|
||||||
hsnCode: item.hsnCode,
|
|
||||||
isService: item.isService
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Submit completion documents using dealer claim API
|
// Submit completion documents using dealer claim API
|
||||||
@ -1267,7 +1151,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle E-Invoice generation (Step 6)
|
// Handle DMS push (Step 6)
|
||||||
const handleDMSPush = async (_comments: string) => {
|
const handleDMSPush = async (_comments: string) => {
|
||||||
try {
|
try {
|
||||||
if (!request?.id && !request?.requestId) {
|
if (!request?.id && !request?.requestId) {
|
||||||
@ -1284,18 +1168,11 @@ export function DealerClaimWorkflowTab({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Activity is logged by backend service - no need to create work note
|
// Activity is logged by backend service - no need to create work note
|
||||||
toast.success('E-Invoice generation initiated successfully.');
|
toast.success('Pushed to DMS successfully. E-invoice will be generated automatically.');
|
||||||
handleRefresh();
|
handleRefresh();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[DealerClaimWorkflowTab] Error generating e-invoice:', error);
|
console.error('[DealerClaimWorkflowTab] Error pushing to DMS:', error);
|
||||||
// Backend now translates PWC error codes to user-friendly messages in 'message' field
|
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to push to DMS. Please try again.';
|
||||||
// Prefer 'message' (user-friendly) over 'error' (raw technical details)
|
|
||||||
const responseMessage = error?.response?.data?.message;
|
|
||||||
let errorMessage = responseMessage || error?.message || 'E-Invoice generation failed. Please try again.';
|
|
||||||
// Truncate very long error messages for toast display (keep first 300 chars)
|
|
||||||
if (errorMessage.length > 300) {
|
|
||||||
errorMessage = errorMessage.substring(0, 300) + '...';
|
|
||||||
}
|
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -1510,115 +1387,10 @@ export function DealerClaimWorkflowTab({
|
|||||||
loadCompletionDocuments();
|
loadCompletionDocuments();
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
const handleDownloadCSV = async () => {
|
|
||||||
try {
|
|
||||||
const requestId = request.id || request.requestId;
|
|
||||||
if (!requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
|
||||||
const response = await fetch(`${baseUrl}/dealer-claims/${requestId}/e-invoice/csv`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${TokenManager.getAccessToken()}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to download CSV');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `Invoice_${request.requestNumber || 'Export'}.csv`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
toast.success('CSV downloaded successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error downloading CSV:', error);
|
|
||||||
toast.error('Failed to download CSV');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreviewInvoice = async () => {
|
|
||||||
try {
|
|
||||||
const requestId = request.id || request.requestId;
|
|
||||||
if (!requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if invoice exists
|
|
||||||
if (!request.invoice && !request.irn) {
|
|
||||||
toast.error('Invoice not generated yet');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvoicePdfLoading(true);
|
|
||||||
setShowInvoicePdfModal(true);
|
|
||||||
|
|
||||||
// Fetch PDF securely via Authorization header (not in URL query)
|
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
|
||||||
const response = await fetch(`${baseUrl}/dealer-claims/${requestId}/e-invoice/pdf`, {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${TokenManager.getAccessToken()}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch invoice PDF');
|
|
||||||
}
|
|
||||||
|
|
||||||
const blob = await response.blob();
|
|
||||||
const blobUrl = window.URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
// Revoke previous blob URL to prevent memory leaks
|
|
||||||
if (invoicePdfUrl) {
|
|
||||||
window.URL.revokeObjectURL(invoicePdfUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
setInvoicePdfUrl(blobUrl);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to preview invoice:', error);
|
|
||||||
toast.error('Failed to load invoice preview');
|
|
||||||
setShowInvoicePdfModal(false);
|
|
||||||
} finally {
|
|
||||||
setInvoicePdfLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseInvoicePdf = () => {
|
|
||||||
setShowInvoicePdfModal(false);
|
|
||||||
if (invoicePdfUrl) {
|
|
||||||
window.URL.revokeObjectURL(invoicePdfUrl);
|
|
||||||
setInvoicePdfUrl(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownloadInvoicePdf = () => {
|
|
||||||
if (invoicePdfUrl) {
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = invoicePdfUrl;
|
|
||||||
a.download = `Invoice_${request.requestNumber || 'Download'}.pdf`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
toast.success('Invoice PDF downloaded');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get dealer and activity info
|
// Get dealer and activity info
|
||||||
const dealerName = request?.claimDetails?.dealerName ||
|
const dealerName = request?.claimDetails?.dealerName ||
|
||||||
request?.dealerInfo?.name ||
|
request?.dealerInfo?.name ||
|
||||||
'Dealer';
|
'Dealer';
|
||||||
const dealerGSTIN = request?.claimDetails?.dealerGstin ||
|
|
||||||
request?.dealerInfo?.gstin ||
|
|
||||||
request?.dealerInfo?.dealerGSTIN;
|
|
||||||
const activityName = request?.claimDetails?.activityName ||
|
const activityName = request?.claimDetails?.activityName ||
|
||||||
request?.activityInfo?.activityName ||
|
request?.activityInfo?.activityName ||
|
||||||
request?.title ||
|
request?.title ||
|
||||||
@ -1683,7 +1455,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
// - AND it matches the current step
|
// - AND it matches the current step
|
||||||
// - AND is pending/in_progress
|
// - AND is pending/in_progress
|
||||||
const isActive = isRequestActive && isPendingOrInProgress && matchesCurrentStep;
|
const isActive = isRequestActive && isPendingOrInProgress && matchesCurrentStep;
|
||||||
const isCompleted = step.status === 'approved' || step.status === 'skipped';
|
const isCompleted = step.status === 'approved';
|
||||||
|
|
||||||
// Find approval data for this step to get SLA information
|
// Find approval data for this step to get SLA information
|
||||||
// First find the corresponding level in approvalFlow to get levelId
|
// First find the corresponding level in approvalFlow to get levelId
|
||||||
@ -1747,40 +1519,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
<Download className="w-3.5 h-3.5 text-green-600" />
|
<Download className="w-3.5 h-3.5 text-green-600" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{/* Invoice Preview Button (Requestor Claim Approval) */}
|
|
||||||
{(() => {
|
|
||||||
const isRequestorClaimStep = (step.levelName || step.title || '').toLowerCase().includes('requestor claim') ||
|
|
||||||
(step.levelName || step.title || '').toLowerCase().includes('requestor - claim');
|
|
||||||
const hasInvoice = request?.invoice || (request?.irn && step.status === 'approved');
|
|
||||||
return isRequestorClaimStep && hasInvoice && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-amber-100"
|
|
||||||
title="Preview Invoice"
|
|
||||||
onClick={handlePreviewInvoice}
|
|
||||||
>
|
|
||||||
<Receipt className="w-3.5 h-3.5 text-amber-600" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
{/* CSV Export Button (Requestor Claim Approval) */}
|
|
||||||
{(() => {
|
|
||||||
const isRequestorClaimStep = (step.levelName || step.title || '').toLowerCase().includes('requestor claim') ||
|
|
||||||
(step.levelName || step.title || '').toLowerCase().includes('requestor - claim');
|
|
||||||
const hasInvoice = request?.invoice || (request?.irn && step.status === 'approved');
|
|
||||||
return isRequestorClaimStep && hasInvoice && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 w-6 p-0 hover:bg-emerald-100 ml-1"
|
|
||||||
title="Export CSV"
|
|
||||||
onClick={handleDownloadCSV}
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="w-3.5 h-3.5 text-emerald-600" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{step.approver}</p>
|
<p className="text-sm text-gray-600">{step.approver}</p>
|
||||||
<p className="text-sm text-gray-500 mt-2 italic">{step.description}</p>
|
<p className="text-sm text-gray-500 mt-2 italic">{step.description}</p>
|
||||||
@ -1989,10 +1727,10 @@ export function DealerClaimWorkflowTab({
|
|||||||
|
|
||||||
{/* Current Approver - Time Tracking */}
|
{/* Current Approver - Time Tracking */}
|
||||||
<div className={`border rounded-lg p-3 ${isPaused ? 'bg-gray-100 border-gray-300' :
|
<div className={`border rounded-lg p-3 ${isPaused ? 'bg-gray-100 border-gray-300' :
|
||||||
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
||||||
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
||||||
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
||||||
'bg-green-50 border-green-200'
|
'bg-green-50 border-green-200'
|
||||||
}`}>
|
}`}>
|
||||||
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" />
|
<Clock className="w-4 h-4" />
|
||||||
@ -2124,25 +1862,25 @@ export function DealerClaimWorkflowTab({
|
|||||||
<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" />
|
||||||
<p className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
|
<p className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
|
||||||
E-Invoice & Settlement Details
|
DMS Processing Details
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-gray-600">Settlement ID:</span>
|
<span className="text-xs text-gray-600">DMS Number:</span>
|
||||||
<span className="text-sm font-semibold text-gray-900">
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
{step.dmsDetails.dmsNumber}
|
{step.dmsDetails.dmsNumber}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{step.dmsDetails.dmsRemarks && (
|
{step.dmsDetails.dmsRemarks && (
|
||||||
<div className="pt-1.5 border-t border-purple-100">
|
<div className="pt-1.5 border-t border-purple-100">
|
||||||
<p className="text-xs text-gray-600 mb-1">Settlement Remarks:</p>
|
<p className="text-xs text-gray-600 mb-1">DMS Remarks:</p>
|
||||||
<p className="text-sm text-gray-900">{step.dmsDetails.dmsRemarks}</p>
|
<p className="text-sm text-gray-900">{step.dmsDetails.dmsRemarks}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{step.dmsDetails.pushedAt && (
|
{step.dmsDetails.pushedAt && (
|
||||||
<div className="pt-1.5 border-t border-purple-100 text-xs text-gray-500">
|
<div className="pt-1.5 border-t border-purple-100 text-xs text-gray-500">
|
||||||
Initiated by {step.dmsDetails.pushedBy} on{' '}
|
Pushed by {step.dmsDetails.pushedBy} on{' '}
|
||||||
{formatDateSafe(step.dmsDetails.pushedAt)}
|
{formatDateSafe(step.dmsDetails.pushedAt)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -2170,51 +1908,33 @@ export function DealerClaimWorkflowTab({
|
|||||||
})() && (
|
})() && (
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-4 flex gap-2">
|
||||||
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
|
{/* Step 1: Submit Proposal Button - Only for dealer or step 1 approver */}
|
||||||
{(() => {
|
{step.step === 1 && (isDealer || isStep1Approver) && (
|
||||||
// Check if this is Step 1 (Dealer Proposal Submission)
|
<Button
|
||||||
// Use levelName match or fallback to step 1
|
className="bg-purple-600 hover:bg-purple-700"
|
||||||
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
onClick={() => {
|
||||||
const isProposalStep = step.step === 1 ||
|
setShowProposalModal(true);
|
||||||
levelName.includes('proposal') ||
|
}}
|
||||||
levelName.includes('submission');
|
>
|
||||||
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
return isProposalStep && (isDealer || isStep1Approver);
|
Submit Proposal
|
||||||
})() && (
|
</Button>
|
||||||
<Button
|
)}
|
||||||
className="bg-purple-600 hover:bg-purple-700"
|
|
||||||
onClick={() => {
|
|
||||||
setShowProposalModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
|
||||||
Submit Proposal
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
||||||
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
|
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
|
||||||
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
||||||
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
|
{/* Use initiatorStepNumber to handle cases where approvers are added between steps */}
|
||||||
{/* Step 2 (or shifted step): Review Request - Only for initiator or step approver */}
|
{step.step === initiatorStepNumber && (isInitiator || isStep2Approver) && (
|
||||||
{(() => {
|
<Button
|
||||||
// Check if this is the Requestor Evaluation step
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
onClick={() => {
|
||||||
const isEvaluationStep = levelName.includes('requestor evaluation') ||
|
setShowApprovalModal(true);
|
||||||
levelName.includes('confirmation') ||
|
}}
|
||||||
step.step === initiatorStepNumber; // Fallback
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
return isEvaluationStep && (isInitiator || isStep2Approver);
|
Review Request
|
||||||
})() && (
|
</Button>
|
||||||
<Button
|
)}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
|
||||||
onClick={() => {
|
|
||||||
setShowApprovalModal(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
|
||||||
Review Request
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Initiator Action Step: Show action buttons (REVISE, REOPEN) - Direct actions, no modal */}
|
{/* Initiator Action Step: Show action buttons (REVISE, REOPEN) - Direct actions, no modal */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@ -2366,26 +2086,20 @@ export function DealerClaimWorkflowTab({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Activity className="w-4 h-4 mr-2" />
|
<Activity className="w-4 h-4 mr-2" />
|
||||||
Generate E-Invoice & Sync
|
Push to DMS
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Step 8: View & Send Credit Note - Only for finance approver or step 8 approver */}
|
{/* Step 8: View & Send Credit Note - Only for finance approver or step 8 approver */}
|
||||||
{(() => {
|
{step.step === 8 && (() => {
|
||||||
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
const step8Level = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === 8);
|
||||||
// Check for "Credit Note" or "SAP" in level name, or fallback to step 8 if it's the last step
|
const step8ApproverEmail = (step8Level?.approverEmail || '').toLowerCase();
|
||||||
const isCreditNoteStep = levelName.includes('credit note') ||
|
const isStep8Approver = step8ApproverEmail && userEmail === step8ApproverEmail;
|
||||||
levelName.includes('sap') ||
|
|
||||||
(step.step === 8 && !levelName.includes('additional'));
|
|
||||||
|
|
||||||
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
|
|
||||||
const isStepApprover = stepApproverEmail && userEmail === stepApproverEmail;
|
|
||||||
// Also check if user has finance role
|
// Also check if user has finance role
|
||||||
const userRole = (user as any)?.role?.toUpperCase() || '';
|
const userRole = (user as any)?.role?.toUpperCase() || '';
|
||||||
const isFinanceUser = userRole === 'FINANCE' || userRole === 'ADMIN';
|
const isFinanceUser = userRole === 'FINANCE' || userRole === 'ADMIN';
|
||||||
|
return isStep8Approver || isFinanceUser;
|
||||||
return isCreditNoteStep && (isStepApprover || isFinanceUser);
|
|
||||||
})() && (
|
})() && (
|
||||||
<Button
|
<Button
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
@ -2405,21 +2119,35 @@ export function DealerClaimWorkflowTab({
|
|||||||
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
const levelName = (stepLevel?.levelName || step.title || '').toLowerCase();
|
||||||
const isAdditionalApprover = levelName.includes('additional approver');
|
const isAdditionalApprover = levelName.includes('additional approver');
|
||||||
|
|
||||||
// Check if this step doesn't have any of the specific workflow action buttons above
|
|
||||||
// Check if this step doesn't have any of the specific workflow action buttons above
|
// Check if this step doesn't have any of the specific workflow action buttons above
|
||||||
const hasSpecificWorkflowAction =
|
const hasSpecificWorkflowAction =
|
||||||
// Proposal
|
step.step === 1 ||
|
||||||
(step.step === 1 || levelName.includes('proposal') || levelName.includes('submission')) ||
|
step.step === initiatorStepNumber ||
|
||||||
// Evaluation
|
(() => {
|
||||||
(levelName.includes('requestor evaluation') || levelName.includes('confirmation')) ||
|
const deptLeadStepLevel = approvalFlow.find((l: any) => {
|
||||||
// Dept Lead
|
const ln = (l.levelName || '').toLowerCase();
|
||||||
levelName.includes('department lead') ||
|
return ln.includes('department lead');
|
||||||
// Dealer Completion
|
});
|
||||||
(levelName.includes('dealer completion') || levelName.includes('completion documents')) ||
|
return deptLeadStepLevel &&
|
||||||
// Requestor Claim
|
(step.step === (deptLeadStepLevel.step || deptLeadStepLevel.levelNumber || deptLeadStepLevel.level_number));
|
||||||
(levelName.includes('requestor claim') || levelName.includes('requestor - claim')) ||
|
})() ||
|
||||||
// Credit Note
|
(() => {
|
||||||
(levelName.includes('credit note') || levelName.includes('sap'));
|
const stepLevel = approvalFlow.find((l: any) => (l.step || l.levelNumber || l.level_number) === step.step);
|
||||||
|
const stepApproverEmail = (stepLevel?.approverEmail || '').toLowerCase();
|
||||||
|
const isDealerForThisStep = isDealer && stepApproverEmail === dealerEmail;
|
||||||
|
const ln = (stepLevel?.levelName || step.title || '').toLowerCase();
|
||||||
|
const isDealerCompletionStep = ln.includes('dealer completion') || ln.includes('completion documents');
|
||||||
|
return isDealerForThisStep && isDealerCompletionStep;
|
||||||
|
})() ||
|
||||||
|
(() => {
|
||||||
|
const requestorClaimStepLevel = approvalFlow.find((l: any) => {
|
||||||
|
const ln = (l.levelName || '').toLowerCase();
|
||||||
|
return ln.includes('requestor claim') || ln.includes('requestor - claim');
|
||||||
|
});
|
||||||
|
return requestorClaimStepLevel &&
|
||||||
|
(step.step === (requestorClaimStepLevel.step || requestorClaimStepLevel.levelNumber || requestorClaimStepLevel.level_number));
|
||||||
|
})() ||
|
||||||
|
step.step === 8;
|
||||||
|
|
||||||
// Show "Review Request" button for additional approvers or steps without specific workflow actions
|
// Show "Review Request" button for additional approvers or steps without specific workflow actions
|
||||||
// Similar to the requestor approval step
|
// Similar to the requestor approval step
|
||||||
@ -2528,14 +2256,10 @@ export function DealerClaimWorkflowTab({
|
|||||||
onClose={() => setShowProposalModal(false)}
|
onClose={() => setShowProposalModal(false)}
|
||||||
onSubmit={handleProposalSubmit}
|
onSubmit={handleProposalSubmit}
|
||||||
dealerName={dealerName}
|
dealerName={dealerName}
|
||||||
dealerGSTIN={dealerGSTIN}
|
|
||||||
activityName={activityName}
|
activityName={activityName}
|
||||||
defaultGstRate={request?.claimDetails?.defaultGstRate}
|
|
||||||
requestId={request?.id || request?.requestId}
|
requestId={request?.id || request?.requestId}
|
||||||
previousProposalData={versionHistory?.find(v => v.snapshotType === 'PROPOSAL')?.snapshotData}
|
previousProposalData={versionHistory?.find(v => v.snapshotType === 'PROPOSAL')?.snapshotData}
|
||||||
documentPolicy={documentPolicy}
|
documentPolicy={documentPolicy}
|
||||||
taxationType={request?.claimDetails?.taxationType}
|
|
||||||
totalBlockedAmount={(request?.internalOrders || []).reduce((sum: number, io: any) => sum + (Number(io.ioBlockedAmount || io.io_blocked_amount || io.blockedAmount || 0)), 0)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Initiator Proposal Approval Modal */}
|
{/* Initiator Proposal Approval Modal */}
|
||||||
@ -2559,7 +2283,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
// proposalSnapshots[1] is the previous proposal (last iteration - 1)
|
// proposalSnapshots[1] is the previous proposal (last iteration - 1)
|
||||||
return proposalSnapshots.length > 1 ? proposalSnapshots[1].snapshotData : null;
|
return proposalSnapshots.length > 1 ? proposalSnapshots[1].snapshotData : null;
|
||||||
})()}
|
})()}
|
||||||
taxationType={request?.claimDetails?.taxationType}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dept Lead IO Approval Modal */}
|
{/* Dept Lead IO Approval Modal */}
|
||||||
@ -2573,7 +2296,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined}
|
preFilledIONumber={request?.internalOrder?.ioNumber || request?.internalOrder?.io_number || request?.internal_order?.ioNumber || request?.internal_order?.io_number || undefined}
|
||||||
preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined}
|
preFilledBlockedAmount={request?.internalOrder?.ioBlockedAmount || request?.internalOrder?.io_blocked_amount || request?.internal_order?.ioBlockedAmount || request?.internal_order?.io_blocked_amount || undefined}
|
||||||
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
|
preFilledRemainingBalance={request?.internalOrder?.ioRemainingBalance || request?.internalOrder?.io_remaining_balance || request?.internal_order?.ioRemainingBalance || request?.internal_order?.io_remaining_balance || undefined}
|
||||||
taxationType={request?.claimDetails?.taxationType}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Dealer Completion Documents Modal */}
|
{/* Dealer Completion Documents Modal */}
|
||||||
@ -2582,12 +2304,9 @@ export function DealerClaimWorkflowTab({
|
|||||||
onClose={() => setShowCompletionModal(false)}
|
onClose={() => setShowCompletionModal(false)}
|
||||||
onSubmit={handleCompletionSubmit}
|
onSubmit={handleCompletionSubmit}
|
||||||
dealerName={dealerName}
|
dealerName={dealerName}
|
||||||
dealerGSTIN={dealerGSTIN}
|
|
||||||
activityName={activityName}
|
activityName={activityName}
|
||||||
defaultGstRate={request?.claimDetails?.defaultGstRate}
|
|
||||||
requestId={request?.id || request?.requestId}
|
requestId={request?.id || request?.requestId}
|
||||||
documentPolicy={documentPolicy}
|
documentPolicy={documentPolicy}
|
||||||
taxationType={request?.claimDetails?.taxationType}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* DMS Push Modal */}
|
{/* DMS Push Modal */}
|
||||||
@ -2611,16 +2330,14 @@ export function DealerClaimWorkflowTab({
|
|||||||
completionDocuments={completionDocumentsData}
|
completionDocuments={completionDocumentsData}
|
||||||
requestTitle={request?.title}
|
requestTitle={request?.title}
|
||||||
requestNumber={request?.requestNumber || request?.request_number || request?.id}
|
requestNumber={request?.requestNumber || request?.request_number || request?.id}
|
||||||
taxationType={request?.claimDetails?.taxationType}
|
|
||||||
onReQuotation={handleClaimReQuotation}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Credit Note from SAP Modal (Step 8) */}
|
{/* Credit Note from SAP Modal (Step 8) */}
|
||||||
<CreditNoteSAPModal
|
<CreditNoteSAPModal
|
||||||
isOpen={showCreditNoteModal}
|
isOpen={showCreditNoteModal}
|
||||||
onClose={() => setShowCreditNoteModal(false)}
|
onClose={() => setShowCreditNoteModal(false)}
|
||||||
taxationType={request?.claimDetails?.taxationType}
|
|
||||||
onDownload={async () => {
|
onDownload={async () => {
|
||||||
|
// TODO: Implement download functionality
|
||||||
toast.info('Download functionality will be implemented');
|
toast.info('Download functionality will be implemented');
|
||||||
}}
|
}}
|
||||||
onSendToDealer={async () => {
|
onSendToDealer={async () => {
|
||||||
@ -2691,7 +2408,7 @@ export function DealerClaimWorkflowTab({
|
|||||||
stepNumber={selectedStepForEmail?.stepNumber || 4}
|
stepNumber={selectedStepForEmail?.stepNumber || 4}
|
||||||
stepName={selectedStepForEmail?.stepName || 'Activity Creation'}
|
stepName={selectedStepForEmail?.stepName || 'Activity Creation'}
|
||||||
requestNumber={request?.requestNumber || request?.id || request?.request_number}
|
requestNumber={request?.requestNumber || request?.id || request?.request_number}
|
||||||
recipientEmail={`system@${import.meta.env.VITE_EMAIL_DOMAIN}`}
|
recipientEmail="system@royalenfield.com"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Additional Approver Review Modal */}
|
{/* Additional Approver Review Modal */}
|
||||||
@ -2925,65 +2642,6 @@ export function DealerClaimWorkflowTab({
|
|||||||
type={viewSnapshot?.type || 'PROPOSAL'}
|
type={viewSnapshot?.type || 'PROPOSAL'}
|
||||||
title={viewSnapshot?.title}
|
title={viewSnapshot?.title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Invoice PDF Viewer Modal */}
|
|
||||||
{showInvoicePdfModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="absolute inset-0 bg-black/60" onClick={handleCloseInvoicePdf} />
|
|
||||||
{/* Modal */}
|
|
||||||
<div className="relative w-[95vw] max-w-5xl h-[90vh] bg-white rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b bg-gray-50">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Receipt className="w-5 h-5 text-amber-600" />
|
|
||||||
<h3 className="font-semibold text-gray-900">Invoice Preview</h3>
|
|
||||||
<Badge className="bg-amber-100 text-amber-800 text-xs">{request.requestNumber}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{invoicePdfUrl && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDownloadInvoicePdf}
|
|
||||||
className="gap-1.5 text-xs"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleCloseInvoicePdf}
|
|
||||||
className="h-8 w-8 hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Body */}
|
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
{invoicePdfLoading ? (
|
|
||||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
|
||||||
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
|
|
||||||
<p className="text-sm text-gray-500">Loading invoice...</p>
|
|
||||||
</div>
|
|
||||||
) : invoicePdfUrl ? (
|
|
||||||
<iframe
|
|
||||||
src={invoicePdfUrl}
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Invoice PDF Preview"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<p className="text-sm text-gray-500">Failed to load invoice</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 !== undefined && activityInfo.estimatedBudget !== null
|
{activityInfo.estimatedBudget
|
||||||
? formatCurrency(activityInfo.estimatedBudget)
|
? formatCurrency(activityInfo.estimatedBudget)
|
||||||
: 'TBD'}
|
: 'TBD'}
|
||||||
</p>
|
</p>
|
||||||
@ -123,11 +123,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">
|
||||||
<Receipt className="w-4 h-4 text-blue-600" />
|
<Receipt className="w-4 h-4 text-blue-600" />
|
||||||
{formatCurrency(
|
{formatCurrency(activityInfo.closedExpenses)}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
@ -151,40 +147,23 @@ 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 overflow-hidden">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
|
||||||
<table className="w-full text-xs sm:text-sm">
|
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
|
||||||
<thead className="bg-blue-100/50">
|
<div key={index} className="flex justify-between items-center text-sm">
|
||||||
<tr>
|
<span className="text-gray-700">{item.description}</span>
|
||||||
<th className="px-3 py-2 text-left font-semibold text-blue-900">Description</th>
|
<span className="font-medium text-gray-900">
|
||||||
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">Base</th>
|
{formatCurrency(item.amount)}
|
||||||
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">GST</th>
|
</span>
|
||||||
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-28">Total</th>
|
</div>
|
||||||
</tr>
|
))}
|
||||||
</thead>
|
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
|
||||||
<tbody className="divide-y divide-blue-200/50">
|
<span className="font-semibold text-gray-900">Total</span>
|
||||||
{activityInfo.closedExpensesBreakdown.map((item: any, index: number) => (
|
<span className="font-bold text-blue-600">
|
||||||
<tr key={index} className="hover:bg-blue-100/30">
|
{formatCurrency(
|
||||||
<td className="px-3 py-2 text-gray-700">
|
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
|
||||||
{item.description}
|
)}
|
||||||
{item.gstRate ? <span className="text-[10px] text-gray-400 block">{item.gstRate}% GST</span> : null}
|
</span>
|
||||||
</td>
|
</div>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ProcessDetailsCard Component
|
* ProcessDetailsCard Component
|
||||||
* Displays process-related details: IO Number, E-Invoice, Claim Amount, and Budget Breakdowns
|
* Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns
|
||||||
* Visibility controlled by user role
|
* Visibility controlled by user role
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -26,11 +26,6 @@ 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 {
|
||||||
@ -42,8 +37,6 @@ interface ClaimAmountDetails {
|
|||||||
interface CostBreakdownItem {
|
interface CostBreakdownItem {
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
gstAmt?: number;
|
|
||||||
totalAmt?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleBasedVisibility {
|
interface RoleBasedVisibility {
|
||||||
@ -92,7 +85,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.totalAmt ?? (item.amount + (item.gstAmt ?? 0))), 0);
|
return items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render if nothing to show
|
// Don't render if nothing to show
|
||||||
@ -172,57 +165,27 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* E-Invoice Details */}
|
{/* DMS 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">
|
||||||
E-Invoice Details
|
DMS Number
|
||||||
</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-[10px] text-gray-500 uppercase mb-1">Remarks</p>
|
<p className="text-xs text-gray-600 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-[10px] text-gray-500">By {dmsDetails.createdByName}</p>
|
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
|
||||||
<p className="text-[10px] text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
|
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -278,10 +241,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-[10px] sm:text-xs">
|
<div key={index} className="flex justify-between items-center text-xs">
|
||||||
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
|
<span className="text-gray-700">{item.description}</span>
|
||||||
<span className="font-medium text-gray-900 whitespace-nowrap">
|
<span className="font-medium text-gray-900">
|
||||||
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
|
{formatCurrency(item.amount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -306,10 +269,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-[10px] sm:text-xs">
|
<div key={index} className="flex justify-between items-center text-xs">
|
||||||
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
|
<span className="text-gray-700">{item.description}</span>
|
||||||
<span className="font-medium text-gray-900 whitespace-nowrap">
|
<span className="font-medium text-gray-900">
|
||||||
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
|
{formatCurrency(item.amount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -11,19 +11,11 @@ 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;
|
||||||
@ -37,18 +29,15 @@ 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 = () => {
|
||||||
const total = proposalDetails.totalEstimatedBudget ?? proposalDetails.estimatedBudgetTotal;
|
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
|
||||||
if (total !== undefined && total !== null) {
|
return proposalDetails.estimatedBudgetTotal;
|
||||||
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;
|
||||||
const gst = item.gstAmt || 0;
|
return sum + (Number.isNaN(amount) ? 0 : amount);
|
||||||
const lineTotal = item.totalAmt || (Number(amount) + Number(gst));
|
|
||||||
return sum + (Number.isNaN(lineTotal) ? 0 : lineTotal);
|
|
||||||
}, 0);
|
}, 0);
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@ -110,13 +99,7 @@ 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">
|
||||||
Base Amount
|
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>
|
||||||
@ -124,27 +107,16 @@ 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">
|
||||||
<div>{item.description}</div>
|
{item.description}
|
||||||
{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.totalAmt || (Number(item.amount || 0) + Number(item.gstAmt || 0)))}
|
{formatCurrency(item.amount)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="bg-green-50 font-semibold">
|
<tr className="bg-green-50 font-semibold">
|
||||||
<td colSpan={3} className="px-4 py-3 text-sm text-gray-900">
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
Estimated Budget (Total Inclusive of GST)
|
Estimated Budget (Total)
|
||||||
</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,7 +40,6 @@ interface CreditNoteSAPModalProps {
|
|||||||
requestNumber?: string;
|
requestNumber?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
taxationType?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreditNoteSAPModal({
|
export function CreditNoteSAPModal({
|
||||||
@ -54,13 +53,10 @@ 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
|
||||||
@ -122,16 +118,9 @@ 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 flex-wrap">
|
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
|
||||||
<div className="flex items-center gap-2">
|
<Receipt className="w-6 h-6 text-[--re-green]" />
|
||||||
<Receipt className="w-6 h-6 text-[--re-green]" />
|
Credit Note from SAP
|
||||||
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,16 +1,12 @@
|
|||||||
.settlement-push-modal {
|
.dms-push-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 1000px !important;
|
max-width: 90vw !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) {
|
||||||
.settlement-push-modal {
|
.dms-push-modal {
|
||||||
width: 95vw !important;
|
width: 95vw !important;
|
||||||
max-width: 95vw !important;
|
max-width: 95vw !important;
|
||||||
max-height: 95vh !important;
|
max-height: 95vh !important;
|
||||||
@ -19,48 +15,25 @@
|
|||||||
|
|
||||||
/* Tablet and small desktop */
|
/* Tablet and small desktop */
|
||||||
@media (min-width: 641px) and (max-width: 1023px) {
|
@media (min-width: 641px) and (max-width: 1023px) {
|
||||||
.settlement-push-modal {
|
.dms-push-modal {
|
||||||
width: 90vw !important;
|
width: 90vw !important;
|
||||||
max-width: 900px !important;
|
max-width: 90vw !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollable content area */
|
/* Large screens - fixed max-width for better readability */
|
||||||
.settlement-push-modal .flex-1 {
|
@media (min-width: 1024px) {
|
||||||
overflow-y: auto;
|
.dms-push-modal {
|
||||||
padding-right: 4px;
|
width: 90vw !important;
|
||||||
|
max-width: 1000px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for the modal content */
|
/* Extra large screens */
|
||||||
.settlement-push-modal .flex-1::-webkit-scrollbar {
|
@media (min-width: 1536px) {
|
||||||
width: 6px;
|
.dms-push-modal {
|
||||||
|
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: 1200px !important;
|
max-width: 1000px !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: 1200px !important;
|
max-width: 1000px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,3 +65,4 @@
|
|||||||
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: 1200px !important;
|
max-width: 1000px !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: 1200px !important;
|
max-width: 1000px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,3 +65,4 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -37,7 +37,6 @@ interface DeptLeadIOApprovalModalProps {
|
|||||||
preFilledIONumber?: string;
|
preFilledIONumber?: string;
|
||||||
preFilledBlockedAmount?: number;
|
preFilledBlockedAmount?: number;
|
||||||
preFilledRemainingBalance?: number;
|
preFilledRemainingBalance?: number;
|
||||||
taxationType?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeptLeadIOApprovalModal({
|
export function DeptLeadIOApprovalModal({
|
||||||
@ -50,16 +49,11 @@ 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 || '';
|
||||||
|
|
||||||
@ -144,13 +138,8 @@ 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 flex items-center gap-2 flex-wrap">
|
<DialogTitle className="font-semibold text-lg lg:text-xl">
|
||||||
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
|
||||||
@ -185,10 +174,11 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActionType('approve')}
|
onClick={() => setActionType('approve')}
|
||||||
className={`flex-1 text-sm lg:text-base ${actionType === 'approve'
|
className={`flex-1 text-sm lg:text-base ${
|
||||||
? 'bg-green-600 text-white shadow-sm'
|
actionType === 'approve'
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
? 'bg-green-600 text-white shadow-sm'
|
||||||
}`}
|
: '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" />
|
||||||
@ -197,10 +187,11 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActionType('reject')}
|
onClick={() => setActionType('reject')}
|
||||||
className={`flex-1 text-sm lg:text-base ${actionType === 'reject'
|
className={`flex-1 text-sm lg:text-base ${
|
||||||
? 'bg-red-600 text-white shadow-sm'
|
actionType === 'reject'
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
? 'bg-red-600 text-white shadow-sm'
|
||||||
}`}
|
: '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" />
|
||||||
@ -318,10 +309,11 @@ export function DeptLeadIOApprovalModal({
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isFormValid || submitting}
|
disabled={!isFormValid || submitting}
|
||||||
className={`text-sm lg:text-base ${actionType === 'approve'
|
className={`text-sm lg:text-base ${
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
actionType === 'approve'
|
||||||
: 'bg-red-600 hover:bg-red-700'
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
} text-white`}
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
|
} 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@${import.meta.env.VITE_EMAIL_DOMAIN}`,
|
recipientEmail = 'system@royalenfield.com',
|
||||||
subject,
|
subject,
|
||||||
emailBody,
|
emailBody,
|
||||||
}: EmailNotificationTemplateModalProps) {
|
}: EmailNotificationTemplateModalProps) {
|
||||||
|
|||||||
@ -39,7 +39,6 @@ interface CostItem {
|
|||||||
id: string;
|
id: string;
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
quantity?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProposalData {
|
interface ProposalData {
|
||||||
@ -71,7 +70,6 @@ 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({
|
||||||
@ -86,83 +84,16 @@ 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)
|
||||||
// Sum up all successful blocks from internalOrders array
|
const internalOrder = request?.internalOrder || request?.internal_order;
|
||||||
const totalBlockedAmount = useMemo(() => {
|
const ioBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
||||||
const internalOrders = request?.internalOrders || request?.internal_orders || [];
|
const isIOBlocked = ioBlockedAmount > 0;
|
||||||
|
|
||||||
// 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;
|
||||||
@ -171,6 +102,25 @@ 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 '—';
|
||||||
@ -191,11 +141,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
|
||||||
@ -323,16 +273,9 @@ 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 flex-wrap">
|
<DialogTitle className="flex items-center gap-2 text-lg lg:text-xl">
|
||||||
<div className="flex items-center gap-2">
|
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
|
||||||
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-green-600" />
|
Requestor Evaluation & Confirmation
|
||||||
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
|
||||||
@ -378,43 +321,43 @@ export function InitiatorProposalApprovalModal({
|
|||||||
<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 */}
|
||||||
@ -455,40 +398,40 @@ export function InitiatorProposalApprovalModal({
|
|||||||
|
|
||||||
{/* 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 */}
|
||||||
@ -510,273 +453,247 @@ 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="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">
|
||||||
<FileText className="w-4 h-4 text-blue-600" />
|
<FileText className="w-4 h-4 text-blue-600" />
|
||||||
Proposal Document
|
Proposal Document
|
||||||
</h3>
|
</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>
|
||||||
{proposalData?.proposalDocument ? (
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 flex items-center justify-between gap-2">
|
{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">
|
<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" />
|
<FileText className="w-4 h-4 lg:w-5 lg:h-5 text-gray-600 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<p className="text-xs lg:text-sm font-medium text-gray-900 truncate" title={doc.name}>
|
||||||
<p className="font-medium text-xs lg:text-sm text-gray-900 truncate" title={proposalData.proposalDocument.name}>
|
{doc.name}
|
||||||
{proposalData.proposalDocument.name}
|
</p>
|
||||||
</p>
|
|
||||||
{proposalData?.submittedAt && (
|
|
||||||
<p className="text-xs text-gray-500 truncate">
|
|
||||||
Submitted on {formatDate(proposalData.submittedAt)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{doc.id && (
|
||||||
{proposalData.proposalDocument.id && (
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<>
|
{canPreviewDocument(doc) && (
|
||||||
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={async () => {
|
onClick={() => handlePreviewDocument(doc)}
|
||||||
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"
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
title="Download document"
|
title="Preview document"
|
||||||
>
|
>
|
||||||
<Download className="w-5 h-5 text-gray-600" />
|
<Eye className="w-5 h-5 text-blue-600" />
|
||||||
</button>
|
</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>
|
|
||||||
)}
|
)}
|
||||||
|
<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>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Planning & Details */}
|
{/* Right Column - Planning & Details */}
|
||||||
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
<div className="space-y-4 lg:space-y-4 flex flex-col">
|
||||||
{/* Cost Breakup Section */}
|
{/* Cost Breakup Section */}
|
||||||
<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">
|
||||||
<IndianRupee className="w-4 h-4 text-green-600" />
|
<IndianRupee className="w-4 h-4 text-green-600" />
|
||||||
Cost Breakup
|
Cost Breakup
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
// Ensure costBreakup is an array
|
// Ensure costBreakup is an array
|
||||||
const costBreakup = proposalData?.costBreakup
|
const costBreakup = proposalData?.costBreakup
|
||||||
? (Array.isArray(proposalData.costBreakup)
|
? (Array.isArray(proposalData.costBreakup)
|
||||||
? proposalData.costBreakup
|
? proposalData.costBreakup
|
||||||
: (typeof proposalData.costBreakup === 'string'
|
: (typeof proposalData.costBreakup === 'string'
|
||||||
? JSON.parse(proposalData.costBreakup)
|
? JSON.parse(proposalData.costBreakup)
|
||||||
: []))
|
: []))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return costBreakup && Array.isArray(costBreakup) && costBreakup.length > 0 ? (
|
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="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="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="grid grid-cols-2 gap-4 text-xs lg:text-sm font-semibold text-gray-700">
|
||||||
<div className="col-span-1">Item Description</div>
|
<div>Item Description</div>
|
||||||
<div className="text-right">Base</div>
|
<div className="text-right">Amount</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>
|
||||||
<div className="border-2 border-[--re-green] rounded-lg p-2.5 lg:p-3">
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="divide-y">
|
||||||
<div className="flex items-center gap-2">
|
{costBreakup.map((item: any, index: number) => (
|
||||||
<IndianRupee className="w-4 h-4 text-[--re-green]" />
|
<div key={item?.id || item?.description || index} className="px-3 lg:px-4 py-2 lg:py-3 grid grid-cols-2 gap-4">
|
||||||
<span className="font-semibold text-xs lg:text-sm text-gray-700">Total Estimated Budget</span>
|
<div className="text-xs lg:text-sm text-gray-700">{item?.description || 'N/A'}</div>
|
||||||
</div>
|
<div className="text-xs lg:text-sm font-semibold text-gray-900 text-right">
|
||||||
<div className="text-lg lg:text-xl font-bold text-[--re-green]">
|
₹{(Number(item?.amount) || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
₹{totalBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-xs text-gray-500 italic">No cost breakdown available</p>
|
<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">
|
||||||
</div>
|
<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 */}
|
{/* 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">
|
||||||
<Calendar className="w-4 h-4 text-purple-600" />
|
<MessageSquare className="w-4 h-4 text-blue-600" />
|
||||||
Expected Completion Date
|
Dealer Comments
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
|
<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-sm lg:text-base font-semibold text-gray-900">
|
<p className="text-xs text-gray-700 whitespace-pre-wrap">
|
||||||
{proposalData?.expectedCompletionDate ? formatDate(proposalData.expectedCompletionDate) : 'Not specified'}
|
{proposalData?.dealerComments || 'No comments provided'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Comments Section - Side by Side */}
|
{/* Your Decision & Comments */}
|
||||||
<div className="space-y-2 border-t pt-3 lg:pt-3 lg:col-span-2 mt-2">
|
<div className="space-y-2">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 lg:gap-6">
|
<h3 className="font-semibold text-sm lg:text-base">Your Decision & Comments</h3>
|
||||||
{/* Dealer Comments */}
|
<Textarea
|
||||||
<div className="space-y-2">
|
placeholder="Provide your evaluation comments, approval conditions, or rejection reasons..."
|
||||||
<div className="flex items-center gap-2">
|
value={comments}
|
||||||
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
|
onChange={(e) => setComments(e.target.value)}
|
||||||
<MessageSquare className="w-4 h-4 text-blue-600" />
|
className="min-h-[150px] lg:min-h-[140px] text-xs lg:text-sm w-full"
|
||||||
Dealer Comments
|
/>
|
||||||
</h3>
|
<p className="text-xs text-gray-500">{comments.length} characters</p>
|
||||||
</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>
|
||||||
|
|
||||||
@ -839,10 +756,8 @@ 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 font-medium">
|
<p className="text-xs text-red-600 text-center sm:text-left">
|
||||||
{totalBlockedAmount > 0
|
Please block IO budget in the IO Tab before approving
|
||||||
? `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';
|
||||||
@ -155,12 +155,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
const currentUserId = (user as any)?.userId || '';
|
const currentUserId = (user as any)?.userId || '';
|
||||||
|
|
||||||
// IO tab visibility for dealer claims
|
// IO tab visibility for dealer claims
|
||||||
// Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin)
|
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
|
||||||
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer';
|
const showIOTab = isInitiator;
|
||||||
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
|
|
||||||
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
|
|
||||||
request?.templateType === 'claim-management';
|
|
||||||
const showIOTab = isClaimManagement && !isDealer;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mergedMessages,
|
mergedMessages,
|
||||||
@ -223,7 +219,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
// Closure functionality - only for initiator when request is approved/rejected
|
// Closure functionality - only for initiator when request is approved/rejected
|
||||||
// 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 isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed';
|
||||||
|
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
|
||||||
|
|
||||||
// Closure check completed
|
// Closure check completed
|
||||||
const {
|
const {
|
||||||
@ -325,7 +322,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setShowShareSummaryModal(true);
|
setShowShareSummaryModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isClosed = request?.status === 'closed';
|
// Summary check already handled by isClosed above
|
||||||
|
|
||||||
// Fetch summary details if request is closed
|
// Fetch summary details if request is closed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -494,7 +491,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
onBack={onBack || (() => window.history.back())}
|
onBack={onBack || (() => window.history.back())}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onShareSummary={handleShareSummary}
|
onShareSummary={summaryId ? handleShareSummary : undefined}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
// Dealer-claim module: Business logic for preparing SLA data
|
// Dealer-claim module: Business logic for preparing SLA data
|
||||||
slaData={request?.summary?.sla || request?.sla || null}
|
slaData={request?.summary?.sla || request?.sla || null}
|
||||||
@ -597,6 +594,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
generationAttempts={generationAttempts}
|
generationAttempts={generationAttempts}
|
||||||
generationFailed={generationFailed}
|
generationFailed={generationFailed}
|
||||||
maxAttemptsReached={maxAttemptsReached}
|
maxAttemptsReached={maxAttemptsReached}
|
||||||
|
isClosed={isClosed}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -677,7 +675,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
request={request}
|
request={request}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentApprovalLevel={currentApprovalLevel}
|
currentApprovalLevel={isClaimManagementRequest(apiRequest) ? null : currentApprovalLevel}
|
||||||
onAddApprover={() => setShowAddApproverModal(true)}
|
onAddApprover={() => setShowAddApproverModal(true)}
|
||||||
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
||||||
onApprove={() => setShowApproveModal(true)}
|
onApprove={() => setShowApproveModal(true)}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ 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
|
||||||
@ -202,10 +201,8 @@ export function useDocumentUpload(
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[useDocumentUpload] Upload error:', error);
|
console.error('[useDocumentUpload] Upload error:', error);
|
||||||
|
|
||||||
// Show security-specific red toast for scan errors, or generic error toast
|
// Error feedback with backend error message if available
|
||||||
if (!handleSecurityError(error)) {
|
toast.error(error?.response?.data?.error || 'Failed to upload document');
|
||||||
toast.error(error?.response?.data?.message || 'Failed to upload document');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDocument(false);
|
setUploadingDocument(false);
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
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';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -238,7 +240,6 @@ 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 {
|
||||||
@ -251,7 +252,6 @@ export function useRequestDetails(
|
|||||||
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;
|
||||||
@ -294,6 +294,7 @@ export function useRequestDetails(
|
|||||||
title: wf.title,
|
title: wf.title,
|
||||||
description: wf.description,
|
description: wf.description,
|
||||||
status: statusMap(wf.status),
|
status: statusMap(wf.status),
|
||||||
|
workflowState: wf.workflowState,
|
||||||
priority: (wf.priority || '').toString().toLowerCase(),
|
priority: (wf.priority || '').toString().toLowerCase(),
|
||||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||||
approvalFlow,
|
approvalFlow,
|
||||||
@ -328,7 +329,6 @@ 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,
|
||||||
@ -520,7 +520,6 @@ 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 {
|
||||||
@ -532,7 +531,6 @@ export function useRequestDetails(
|
|||||||
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;
|
||||||
@ -567,6 +565,7 @@ export function useRequestDetails(
|
|||||||
description: wf.description,
|
description: wf.description,
|
||||||
priority,
|
priority,
|
||||||
status: statusMap(wf.status),
|
status: statusMap(wf.status),
|
||||||
|
workflowState: wf.workflowState,
|
||||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||||
summary,
|
summary,
|
||||||
initiator: {
|
initiator: {
|
||||||
@ -596,7 +595,6 @@ 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,
|
||||||
@ -655,13 +653,21 @@ export function useRequestDetails(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed: Get final request object with fallback to static databases
|
* Computed: Get final request object with fallback to static databases
|
||||||
* Priority: API data → Custom Database → Claim Database → Dynamic props → null
|
* Priority: API data → Custom DB → Claim DB → 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: Dynamic requests passed as props
|
// Fallback 1: Static custom request database
|
||||||
|
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
|
||||||
|
if (customRequest) return customRequest;
|
||||||
|
|
||||||
|
// 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) =>
|
const dynamicRequest = dynamicRequests.find((req: any) =>
|
||||||
req.id === requestIdentifier ||
|
req.id === requestIdentifier ||
|
||||||
req.requestNumber === requestIdentifier ||
|
req.requestNumber === requestIdentifier ||
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { AuthProvider } from './contexts/AuthContext';
|
|||||||
import { AuthenticatedApp } from './pages/Auth';
|
import { AuthenticatedApp } from './pages/Auth';
|
||||||
import { store } from './redux/store';
|
import { store } from './redux/store';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
import './styles/base-layout.css';
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@ -1,12 +1,22 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Plus, Pencil, Search, FileText } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search, FileText, AlertTriangle } from 'lucide-react';
|
||||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { getTemplates, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { getTemplates, deleteTemplate, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function AdminTemplatesList() {
|
export function AdminTemplatesList() {
|
||||||
@ -15,6 +25,8 @@ export function AdminTemplatesList() {
|
|||||||
// Only show full loading skeleton if we don't have any data yet
|
// Only show full loading skeleton if we don't have any data yet
|
||||||
const [loading, setLoading] = useState(() => !getCachedTemplates());
|
const [loading, setLoading] = useState(() => !getCachedTemplates());
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
try {
|
try {
|
||||||
@ -37,6 +49,22 @@ export function AdminTemplatesList() {
|
|||||||
fetchTemplates();
|
fetchTemplates();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
await deleteTemplate(deleteId);
|
||||||
|
toast.success('Template deleted successfully');
|
||||||
|
setTemplates(prev => prev.filter(t => t.id !== deleteId));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete template:', error);
|
||||||
|
toast.error('Failed to delete template');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredTemplates = templates.filter(template =>
|
const filteredTemplates = templates.filter(template =>
|
||||||
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
@ -124,7 +152,7 @@ export function AdminTemplatesList() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle>
|
<CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle>
|
||||||
<CardDescription className="line-clamp-3 min-h-[4.5rem]">
|
<CardDescription className="line-clamp-2 h-10">
|
||||||
{template.description}
|
{template.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -153,6 +181,14 @@ export function AdminTemplatesList() {
|
|||||||
<Pencil className="w-4 h-4 mr-2" />
|
<Pencil className="w-4 h-4 mr-2" />
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-100"
|
||||||
|
onClick={() => setDeleteId(template.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -160,6 +196,33 @@ export function AdminTemplatesList() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||||
|
Delete Template
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete this template? This action cannot be undone.
|
||||||
|
Active requests using this template will not be affected.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleDelete();
|
||||||
|
}}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,13 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { LogIn, Shield } from 'lucide-react';
|
import { LogIn } from 'lucide-react';
|
||||||
import { ReLogo, LandingPageImage } from '@/assets';
|
import { ReLogo, LandingPageImage } from '@/assets';
|
||||||
import { initiateTanflowLogin } from '@/services/tanflowAuth';
|
// import { initiateTanflowLogin } from '@/services/tanflowAuth';
|
||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const { login, isLoading, error } = useAuth();
|
const { login, isLoading, error } = useAuth();
|
||||||
const [tanflowLoading, setTanflowLoading] = useState(false);
|
const [tanflowLoading] = useState(false);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
|
||||||
// Preload the background image
|
// Preload the background image
|
||||||
@ -41,7 +41,7 @@ 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();
|
||||||
@ -55,7 +55,7 @@ export function Auth() {
|
|||||||
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,7 +123,7 @@ 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>
|
||||||
@ -152,7 +152,7 @@ export function Auth() {
|
|||||||
Dealer Login
|
Dealer Login
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-400 mt-4">
|
<div className="text-center text-sm text-gray-400 mt-4">
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|||||||
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
|
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
import { ClosedRequest } from '../types/closedRequests.types';
|
import { ClosedRequest } from '../types/closedRequests.types';
|
||||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
|
||||||
|
|
||||||
interface ClosedRequestCardProps {
|
interface ClosedRequestCardProps {
|
||||||
request: ClosedRequest;
|
request: ClosedRequest;
|
||||||
@ -18,6 +18,7 @@ interface ClosedRequestCardProps {
|
|||||||
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
|
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
|
||||||
const priorityConfig = getPriorityConfig(request.priority);
|
const priorityConfig = getPriorityConfig(request.priority);
|
||||||
const statusConfig = getStatusConfig(request.status);
|
const statusConfig = getStatusConfig(request.status);
|
||||||
|
const stateConfig = getWorkflowStateConfig(request.workflowState || 'CLOSED');
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
const StatusIcon = statusConfig.icon;
|
const StatusIcon = statusConfig.icon;
|
||||||
|
|
||||||
@ -50,6 +51,12 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
|||||||
<StatusIcon className="w-3.5 h-3.5 mr-1" />
|
<StatusIcon className="w-3.5 h-3.5 mr-1" />
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${stateConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
|
||||||
|
>
|
||||||
|
{stateConfig.label}
|
||||||
|
</Badge>
|
||||||
{request.department && (
|
{request.department && (
|
||||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
|
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
|
||||||
{request.department}
|
{request.department}
|
||||||
|
|||||||
@ -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')}>
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export interface ClosedRequest {
|
|||||||
displayId?: string;
|
displayId?: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: 'rejected' | 'closed';
|
status: 'rejected' | 'closed' | 'approved';
|
||||||
priority: 'express' | 'standard';
|
priority: 'express' | 'standard';
|
||||||
initiator: { name: string; avatar: string };
|
initiator: { name: string; avatar: string };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -18,6 +18,7 @@ export interface ClosedRequest {
|
|||||||
totalLevels?: number;
|
totalLevels?: number;
|
||||||
completedLevels?: number;
|
completedLevels?: number;
|
||||||
templateType?: string; // Template type for badge display
|
templateType?: string; // Template type for badge display
|
||||||
|
workflowState?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClosedRequestsProps {
|
export interface ClosedRequestsProps {
|
||||||
|
|||||||
@ -38,6 +38,14 @@ export function getStatusConfig(status: string): StatusConfig {
|
|||||||
label: 'Closed',
|
label: 'Closed',
|
||||||
description: 'Request finalized and archived'
|
description: 'Request finalized and archived'
|
||||||
};
|
};
|
||||||
|
case 'approved':
|
||||||
|
return {
|
||||||
|
color: 'bg-green-100 text-green-800 border-green-200',
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-green-600',
|
||||||
|
label: 'Approved',
|
||||||
|
description: 'Request was approved'
|
||||||
|
};
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
return {
|
return {
|
||||||
color: 'bg-red-100 text-red-800 border-red-300',
|
color: 'bg-red-100 text-red-800 border-red-300',
|
||||||
@ -57,3 +65,25 @@ export function getStatusConfig(status: string): StatusConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWorkflowStateConfig(state: string) {
|
||||||
|
const s = (state || '').toUpperCase();
|
||||||
|
switch (s) {
|
||||||
|
case 'CLOSED':
|
||||||
|
return {
|
||||||
|
color: 'bg-slate-100 text-slate-800 border-slate-200',
|
||||||
|
label: 'closed'
|
||||||
|
};
|
||||||
|
case 'DRAFT':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
|
label: 'draft'
|
||||||
|
};
|
||||||
|
case 'OPEN':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||||
|
label: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
|||||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed',
|
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed' | 'approved',
|
||||||
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
|
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
|
||||||
initiator: {
|
initiator: {
|
||||||
name: r.initiator?.displayName || r.initiator?.email || '—',
|
name: r.initiator?.displayName || r.initiator?.email || '—',
|
||||||
@ -29,6 +29,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
|||||||
totalLevels: r.totalLevels || 0,
|
totalLevels: r.totalLevels || 0,
|
||||||
completedLevels: r.summary?.approvedLevels || 0,
|
completedLevels: r.summary?.approvedLevels || 0,
|
||||||
templateType: r.templateType || r.template_type, // Template type for badge display
|
templateType: r.templateType || r.template_type, // Template type for badge display
|
||||||
|
workflowState: r.workflowState || r.workflow_state,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { FileText, AlertCircle } from 'lucide-react';
|
import { FileText, AlertCircle } from 'lucide-react';
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
import { sanitizeHTML } from '@/utils/sanitizer';
|
|
||||||
|
|
||||||
interface AdminRequestReviewStepProps {
|
interface AdminRequestReviewStepProps {
|
||||||
template: RequestTemplate;
|
template: RequestTemplate;
|
||||||
@ -48,7 +47,7 @@ export function AdminRequestReviewStep({
|
|||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
|
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
|
||||||
<div
|
<div
|
||||||
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
|
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
|
||||||
dangerouslySetInnerHTML={{ __html: sanitizeHTML(formData.description) }}
|
dangerouslySetInnerHTML={{ __html: formData.description }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ import {
|
|||||||
validateApproversForSubmission,
|
validateApproversForSubmission,
|
||||||
} from '../utils/payloadBuilders';
|
} from '../utils/payloadBuilders';
|
||||||
import {
|
import {
|
||||||
|
createAndSubmitWorkflow,
|
||||||
|
updateAndSubmitWorkflow,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
updateWorkflowRequest,
|
updateWorkflowRequest,
|
||||||
} from '../services/createRequestService';
|
} from '../services/createRequestService';
|
||||||
@ -57,15 +59,14 @@ export function useCreateRequestSubmission({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing && editRequestId) {
|
if (isEditing && editRequestId) {
|
||||||
// Update existing workflow with isDraft: false (Submit)
|
// Update existing workflow
|
||||||
const updatePayload = buildUpdatePayload(
|
const updatePayload = buildUpdatePayload(
|
||||||
formData,
|
formData,
|
||||||
user,
|
user,
|
||||||
documentsToDelete,
|
documentsToDelete
|
||||||
false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateWorkflowRequest(
|
await updateAndSubmitWorkflow(
|
||||||
editRequestId,
|
editRequestId,
|
||||||
updatePayload,
|
updatePayload,
|
||||||
documents,
|
documents,
|
||||||
@ -84,15 +85,14 @@ export function useCreateRequestSubmission({
|
|||||||
template: selectedTemplate,
|
template: selectedTemplate,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new workflow with isDraft: false (Submit)
|
// Create new workflow
|
||||||
const createPayload = buildCreatePayload(
|
const createPayload = buildCreatePayload(
|
||||||
formData,
|
formData,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
user,
|
user
|
||||||
false
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await createWorkflow(createPayload, documents);
|
const result = await createAndSubmitWorkflow(createPayload, documents);
|
||||||
|
|
||||||
// Show toast after backend confirmation
|
// Show toast after backend confirmation
|
||||||
toast.success('Request Submitted Successfully!', {
|
toast.success('Request Submitted Successfully!', {
|
||||||
@ -133,13 +133,13 @@ export function useCreateRequestSubmission({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing && editRequestId) {
|
if (isEditing && editRequestId) {
|
||||||
// Update existing draft with isDraft: true
|
// Update existing draft
|
||||||
const updatePayload = buildUpdatePayload(
|
const updatePayload = buildUpdatePayload(
|
||||||
formData,
|
formData,
|
||||||
user,
|
user,
|
||||||
documentsToDelete,
|
documentsToDelete
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
(updatePayload as any).isDraft = true;
|
||||||
|
|
||||||
await updateWorkflowRequest(
|
await updateWorkflowRequest(
|
||||||
editRequestId,
|
editRequestId,
|
||||||
@ -159,13 +159,13 @@ export function useCreateRequestSubmission({
|
|||||||
template: selectedTemplate,
|
template: selectedTemplate,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new draft with isDraft: true
|
// Create new draft
|
||||||
const createPayload = buildCreatePayload(
|
const createPayload = buildCreatePayload(
|
||||||
formData,
|
formData,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
user,
|
user
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
(createPayload as any).isDraft = true;
|
||||||
|
|
||||||
const result = await createWorkflow(createPayload, documents);
|
const result = await createWorkflow(createPayload, documents);
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkflowMultipart,
|
createWorkflowMultipart,
|
||||||
|
submitWorkflow,
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
updateWorkflowMultipart,
|
updateWorkflowMultipart,
|
||||||
} from '@/services/workflowApi';
|
} from '@/services/workflowApi';
|
||||||
@ -13,7 +14,7 @@ import {
|
|||||||
} from '../types/createRequest.types';
|
} from '../types/createRequest.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new workflow (supports both draft and direct submission via isDraft flag)
|
* Create a new workflow
|
||||||
*/
|
*/
|
||||||
export async function createWorkflow(
|
export async function createWorkflow(
|
||||||
payload: CreateWorkflowPayload,
|
payload: CreateWorkflowPayload,
|
||||||
@ -28,7 +29,7 @@ export async function createWorkflow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing workflow (supports both draft and direct submission via isDraft flag)
|
* Update an existing workflow
|
||||||
*/
|
*/
|
||||||
export async function updateWorkflowRequest(
|
export async function updateWorkflowRequest(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@ -50,3 +51,30 @@ export async function updateWorkflowRequest(
|
|||||||
await updateWorkflow(requestId, payload);
|
await updateWorkflow(requestId, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a workflow
|
||||||
|
*/
|
||||||
|
export async function submitWorkflowRequest(requestId: string): Promise<void> {
|
||||||
|
await submitWorkflow(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAndSubmitWorkflow(
|
||||||
|
payload: CreateWorkflowPayload,
|
||||||
|
documents: File[]
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
// Pass isDraft: false (or omit) to trigger backend auto-submit
|
||||||
|
const res = await createWorkflow({ ...payload, isDraft: false }, documents);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAndSubmitWorkflow(
|
||||||
|
requestId: string,
|
||||||
|
payload: UpdateWorkflowPayload,
|
||||||
|
documents: File[],
|
||||||
|
documentsToDelete: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
// Pass isDraft: false (or omit) to trigger backend auto-submit
|
||||||
|
await updateWorkflowRequest(requestId, { ...payload, isDraft: false }, documents, documentsToDelete);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,9 +17,16 @@ import { buildApprovalLevels } from './approvalLevelBuilders';
|
|||||||
export function buildCreatePayload(
|
export function buildCreatePayload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
selectedTemplate: RequestTemplate | null,
|
selectedTemplate: RequestTemplate | null,
|
||||||
_user: any,
|
_user: any
|
||||||
isDraft: boolean = false
|
|
||||||
): CreateWorkflowPayload {
|
): CreateWorkflowPayload {
|
||||||
|
// Filter out spectators who are also approvers (backend will handle validation)
|
||||||
|
const approverEmails = new Set(
|
||||||
|
(formData.approvers || []).map((a: any) => a?.email?.toLowerCase()).filter(Boolean)
|
||||||
|
);
|
||||||
|
const filteredSpectators = (formData.spectators || []).filter(
|
||||||
|
(s: any) => s?.email && !approverEmails.has(s.email.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templateId: selectedTemplate?.id || null,
|
templateId: selectedTemplate?.id || null,
|
||||||
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
|
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
|
||||||
@ -31,17 +38,16 @@ export function buildCreatePayload(
|
|||||||
userId: a?.userId || '',
|
userId: a?.userId || '',
|
||||||
email: a?.email || '',
|
email: a?.email || '',
|
||||||
name: a?.name,
|
name: a?.name,
|
||||||
tat: a?.tat || 24,
|
tat: a?.tat || '',
|
||||||
tatType: a?.tatType || 'hours',
|
tatType: a?.tatType || 'hours',
|
||||||
})),
|
})),
|
||||||
spectators: (formData.spectators || []).map((s: any) => ({
|
spectators: filteredSpectators.map((s: any) => ({
|
||||||
userId: s?.userId || '',
|
userId: s?.userId || '',
|
||||||
name: s?.name || '',
|
name: s?.name || '',
|
||||||
email: s?.email || '',
|
email: s?.email || '',
|
||||||
})),
|
})),
|
||||||
ccList: [], // Auto-generated by backend
|
ccList: [], // Auto-generated by backend
|
||||||
participants: [], // Auto-generated by backend from approvers and spectators
|
participants: [], // Auto-generated by backend from approvers and spectators
|
||||||
isDraft,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,8 +58,7 @@ export function buildCreatePayload(
|
|||||||
export function buildUpdatePayload(
|
export function buildUpdatePayload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
_user: any,
|
_user: any,
|
||||||
documentsToDelete: string[],
|
documentsToDelete: string[]
|
||||||
isDraft: boolean = false
|
|
||||||
): UpdateWorkflowPayload {
|
): UpdateWorkflowPayload {
|
||||||
const approvalLevels = buildApprovalLevels(
|
const approvalLevels = buildApprovalLevels(
|
||||||
formData.approvers || [],
|
formData.approvers || [],
|
||||||
@ -67,7 +72,6 @@ export function buildUpdatePayload(
|
|||||||
approvalLevels,
|
approvalLevels,
|
||||||
participants: [], // Auto-generated by backend from approval levels
|
participants: [], // Auto-generated by backend from approval levels
|
||||||
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
||||||
isDraft,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,3 +112,4 @@ export function validateApproversForSubmission(
|
|||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,8 +71,8 @@ export function AdminKPICards({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 2: Pending and Closed */}
|
{/* Row 2: Pending and Paused */}
|
||||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Pending"
|
label="Pending"
|
||||||
value={kpis?.requestVolume.openRequests || 0}
|
value={kpis?.requestVolume.openRequests || 0}
|
||||||
@ -84,21 +84,7 @@ export function AdminKPICards({
|
|||||||
onKPIClick({ ...getFilterParams(), status: 'pending' });
|
onKPIClick({ ...getFilterParams(), status: 'pending' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
{kpis?.requestVolume.pausedRequests !== undefined && (
|
||||||
label="Closed"
|
|
||||||
value={kpis?.requestVolume.closedRequests || 0}
|
|
||||||
bgColor="bg-gray-50"
|
|
||||||
textColor="text-gray-600"
|
|
||||||
testId="stat-closed"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onKPIClick({ ...getFilterParams(), status: 'closed' });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/* Row 3: Paused (if available) */}
|
|
||||||
{kpis?.requestVolume.pausedRequests !== undefined && (
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Paused"
|
label="Paused"
|
||||||
value={kpis.requestVolume.pausedRequests || 0}
|
value={kpis.requestVolume.pausedRequests || 0}
|
||||||
@ -110,8 +96,8 @@ export function AdminKPICards({
|
|||||||
onKPIClick({ ...getFilterParams(), status: 'paused' });
|
onKPIClick({ ...getFilterParams(), status: 'paused' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</KPICard>
|
</KPICard>
|
||||||
|
|
||||||
{/* SLA Compliance */}
|
{/* SLA Compliance */}
|
||||||
|
|||||||
@ -33,7 +33,8 @@ export function CriticalAlertsSection({
|
|||||||
}: CriticalAlertsSectionProps) {
|
}: CriticalAlertsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="lg:col-span-2 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden h-full"
|
className="lg:col-span-2 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden"
|
||||||
|
style={{ height: '100%' }}
|
||||||
data-testid="critical-alerts-section"
|
data-testid="critical-alerts-section"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
|
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
|
||||||
@ -59,7 +60,8 @@ export function CriticalAlertsSection({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent
|
<CardContent
|
||||||
className={`overflow-y-auto flex-1 p-4 ${pagination.totalPages > 1 ? 'max-h-[calc(100%-140px)]' : 'max-h-[calc(100%-80px)]'}`}
|
className="overflow-y-auto flex-1 p-4"
|
||||||
|
style={{ maxHeight: pagination.totalPages > 1 ? 'calc(100% - 140px)' : 'calc(100% - 80px)' }}
|
||||||
>
|
>
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
{breachedRequests.length === 0 ? (
|
{breachedRequests.length === 0 ? (
|
||||||
|
|||||||
@ -84,7 +84,11 @@ export function PriorityDistributionReport({
|
|||||||
fill="#1f2937"
|
fill="#1f2937"
|
||||||
textAnchor={x > cx ? 'start' : 'end'}
|
textAnchor={x > cx ? 'start' : 'end'}
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
className="text-sm font-semibold pointer-events-none"
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{`${name}: ${percentage}%`}
|
{`${name}: ${percentage}%`}
|
||||||
</text>
|
</text>
|
||||||
@ -98,13 +102,13 @@ export function PriorityDistributionReport({
|
|||||||
onNavigate(`requests?priority=${data.priority}`);
|
onNavigate(`requests?priority=${data.priority}`);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer"
|
style={{ cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
{priorityDistribution.map((priority, index) => (
|
{priorityDistribution.map((priority, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'}
|
fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'}
|
||||||
className="cursor-pointer"
|
style={{ cursor: 'pointer' }}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
|
|||||||
@ -40,7 +40,8 @@ export function RecentActivitySection({
|
|||||||
}: RecentActivitySectionProps) {
|
}: RecentActivitySectionProps) {
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="lg:col-span-1 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden h-full"
|
className="lg:col-span-1 shadow-md hover:shadow-lg transition-shadow flex flex-col overflow-hidden"
|
||||||
|
style={{ height: '100%' }}
|
||||||
data-testid="recent-activity-section"
|
data-testid="recent-activity-section"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
|
<CardHeader className="pb-3 sm:pb-4 flex-shrink-0">
|
||||||
@ -72,7 +73,8 @@ export function RecentActivitySection({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent
|
<CardContent
|
||||||
className={`overflow-y-auto flex-1 p-4 ${pagination.totalPages > 1 ? 'max-h-[calc(100%-140px)]' : 'max-h-[calc(100%-80px)]'}`}
|
className="overflow-y-auto flex-1 p-4"
|
||||||
|
style={{ maxHeight: pagination.totalPages > 1 ? 'calc(100% - 140px)' : 'calc(100% - 80px)' }}
|
||||||
>
|
>
|
||||||
<div className="space-y-2 sm:space-y-3">
|
<div className="space-y-2 sm:space-y-3">
|
||||||
{recentActivity.length === 0 ? (
|
{recentActivity.length === 0 ? (
|
||||||
|
|||||||
@ -62,7 +62,7 @@ export function TATBreachReport({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="destructive" className="text-sm font-medium self-start sm:self-auto">
|
<Badge variant="destructive" className="text-sm font-medium self-start sm:self-auto">
|
||||||
{pagination.totalRecords} {pagination.totalRecords === 1 ? 'Breach' : 'Breaches'}
|
{breachedRequests.length} {breachedRequests.length === 1 ? 'Breach' : 'Breaches'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -164,10 +164,11 @@ export function TATBreachReport({
|
|||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-xs font-medium ${req.priority === 'express'
|
className={`text-xs font-medium ${
|
||||||
|
req.priority === 'express'
|
||||||
? 'bg-orange-100 text-orange-800 border-orange-200'
|
? 'bg-orange-100 text-orange-800 border-orange-200'
|
||||||
: 'bg-blue-100 text-blue-800 border-blue-200'
|
: 'bg-blue-100 text-blue-800 border-blue-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{req.priority}
|
{req.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import { Progress } from '@/components/ui/progress';
|
|||||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||||
import { UpcomingDeadline } from '@/services/dashboard.service';
|
import { UpcomingDeadline } from '@/services/dashboard.service';
|
||||||
import { Pagination } from '@/components/common/Pagination';
|
import { Pagination } from '@/components/common/Pagination';
|
||||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
import { formatBreachTime } from '../../utils/dashboardCalculations';
|
||||||
|
|
||||||
interface UpcomingDeadlinesSectionProps {
|
interface UpcomingDeadlinesSectionProps {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@ -67,9 +67,8 @@ export function UpcomingDeadlinesSection({
|
|||||||
<span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span>
|
<span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-xs ${
|
className={`text-xs ${deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
|
||||||
deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{deadline.priority}
|
{deadline.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -85,9 +84,8 @@ export function UpcomingDeadlinesSection({
|
|||||||
<div className="text-right flex-shrink-0">
|
<div className="text-right flex-shrink-0">
|
||||||
<p className="text-xs text-muted-foreground">TAT Used</p>
|
<p className="text-xs text-muted-foreground">TAT Used</p>
|
||||||
<p
|
<p
|
||||||
className={`text-base sm:text-lg font-bold ${
|
className={`text-base sm:text-lg font-bold ${tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
|
||||||
tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tatPercentage.toFixed(0)}%
|
{tatPercentage.toFixed(0)}%
|
||||||
</p>
|
</p>
|
||||||
@ -96,13 +94,12 @@ export function UpcomingDeadlinesSection({
|
|||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Progress
|
<Progress
|
||||||
value={tatPercentage}
|
value={tatPercentage}
|
||||||
className={`h-1.5 sm:h-2 ${
|
className={`h-1.5 sm:h-2 ${tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
|
||||||
tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
|
}`}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-muted-foreground">
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
<span>{formatHoursMinutes(elapsedHours)} elapsed</span>
|
<span>{formatBreachTime(elapsedHours)} elapsed</span>
|
||||||
<span>{formatHoursMinutes(remainingHours)} left</span>
|
<span>{formatBreachTime(Math.abs(remainingHours))} {remainingHours < 0 ? 'overdue' : 'left'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export function UserKPICards({
|
|||||||
testId="kpi-my-requests"
|
testId="kpi-my-requests"
|
||||||
onClick={() => onKPIClick(getFilterParams())}
|
onClick={() => onKPIClick(getFilterParams())}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
<div className="grid grid-cols-2 gap-1.5 sm:gap-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Approved"
|
label="Approved"
|
||||||
value={kpis?.requestVolume.approvedRequests || 0}
|
value={kpis?.requestVolume.approvedRequests || 0}
|
||||||
@ -115,17 +115,6 @@ export function UserKPICards({
|
|||||||
onKPIClick({ ...getFilterParams(), status: 'rejected' });
|
onKPIClick({ ...getFilterParams(), status: 'rejected' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
|
||||||
label="Closed"
|
|
||||||
value={kpis?.requestVolume.closedRequests || 0}
|
|
||||||
bgColor="bg-blue-50"
|
|
||||||
textColor="text-blue-600"
|
|
||||||
testId="stat-user-closed"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onKPIClick({ ...getFilterParams(), status: 'closed' });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</KPICard>
|
</KPICard>
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
priorityFilter: filters.priorityFilter,
|
priorityFilter: filters.priorityFilter,
|
||||||
templateTypeFilter: filters.templateTypeFilter,
|
templateTypeFilter: filters.templateTypeFilter,
|
||||||
|
lifecycleFilter: filters.lifecycleFilter,
|
||||||
});
|
});
|
||||||
const hasInitialFetchRun = useRef(false);
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
@ -49,6 +50,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
|
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
|
||||||
});
|
});
|
||||||
hasInitialFetchRun.current = true;
|
hasInitialFetchRun.current = true;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -63,19 +65,21 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
prev.searchTerm !== filters.searchTerm ||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
prev.statusFilter !== filters.statusFilter ||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
prev.priorityFilter !== filters.priorityFilter ||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
prev.templateTypeFilter !== filters.templateTypeFilter;
|
prev.templateTypeFilter !== filters.templateTypeFilter ||
|
||||||
|
prev.lifecycleFilter !== filters.lifecycleFilter;
|
||||||
|
|
||||||
if (!hasChanged) return; // No actual change, skip
|
if (!hasChanged) return; // No actual change, skip
|
||||||
|
|
||||||
// Debounce search
|
// Debounce search
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
filters.setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
fetchRef.current(1, {
|
fetchRef.current(1, {
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
});
|
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
// Update previous values
|
// Update previous values
|
||||||
prevFiltersRef.current = {
|
prevFiltersRef.current = {
|
||||||
@ -83,12 +87,13 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
priorityFilter: filters.priorityFilter,
|
priorityFilter: filters.priorityFilter,
|
||||||
templateTypeFilter: filters.templateTypeFilter,
|
templateTypeFilter: filters.templateTypeFilter,
|
||||||
|
lifecycleFilter: filters.lifecycleFilter,
|
||||||
};
|
};
|
||||||
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]);
|
||||||
|
|
||||||
// State for backend stats (calculated from entire dataset via SQL queries)
|
// State for backend stats (calculated from entire dataset via SQL queries)
|
||||||
const [backendStats, setBackendStats] = useState<{
|
const [backendStats, setBackendStats] = useState<{
|
||||||
@ -131,7 +136,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
undefined, // approverType
|
undefined, // approverType
|
||||||
filters.searchTerm || undefined,
|
filters.searchTerm || undefined,
|
||||||
undefined, // slaCompliance
|
undefined, // slaCompliance
|
||||||
true // viewAsUser - treat as normal user even if admin
|
true, // viewAsUser - treat as normal user even if admin
|
||||||
|
filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined // lifecycle
|
||||||
);
|
);
|
||||||
|
|
||||||
setBackendStats({
|
setBackendStats({
|
||||||
@ -149,7 +155,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingStats(false);
|
setLoadingStats(false);
|
||||||
}
|
}
|
||||||
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter]); // Exclude statusFilter - stats don't change when only status changes
|
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]); // Exclude statusFilter - stats don't change when only status changes
|
||||||
|
|
||||||
// Fetch stats when filters change (excluding status filter)
|
// Fetch stats when filters change (excluding status filter)
|
||||||
// Stats should reflect priority and search filters, but NOT status filter
|
// Stats should reflect priority and search filters, but NOT status filter
|
||||||
@ -160,7 +166,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
}, filters.searchTerm ? 500 : 0);
|
}, filters.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
|
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
|
||||||
|
|
||||||
// Handle dynamic requests (fallback until API loads)
|
// Handle dynamic requests (fallback until API loads)
|
||||||
const convertedDynamicRequests = transformRequests(dynamicRequests);
|
const convertedDynamicRequests = transformRequests(dynamicRequests);
|
||||||
@ -204,6 +210,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
|
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -243,6 +250,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
onStatusChange={filters.setStatusFilter}
|
onStatusChange={filters.setStatusFilter}
|
||||||
onPriorityChange={filters.setPriorityFilter}
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
||||||
|
lifecycleFilter={filters.lifecycleFilter}
|
||||||
|
onLifecycleChange={filters.setLifecycleFilter}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
|
|||||||
@ -12,21 +12,25 @@ interface MyRequestsFiltersProps {
|
|||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
templateTypeFilter: string;
|
templateTypeFilter: string;
|
||||||
|
lifecycleFilter: string;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onStatusChange: (value: string) => void;
|
onStatusChange: (value: string) => void;
|
||||||
onPriorityChange: (value: string) => void;
|
onPriorityChange: (value: string) => void;
|
||||||
onTemplateTypeChange: (value: string) => void;
|
onTemplateTypeChange: (value: string) => void;
|
||||||
|
onLifecycleChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MyRequestsFilters({
|
export function MyRequestsFilters({
|
||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
// templateTypeFilter,
|
||||||
|
lifecycleFilter, // Destructure new prop
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onTemplateTypeChange,
|
// onTemplateTypeChange,
|
||||||
|
onLifecycleChange, // Destructure new prop
|
||||||
}: MyRequestsFiltersProps) {
|
}: MyRequestsFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200" data-testid="my-requests-filters">
|
<Card className="border-gray-200" data-testid="my-requests-filters">
|
||||||
@ -44,6 +48,21 @@ export function MyRequestsFilters({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
||||||
|
{/* Lifecycle Filter */}
|
||||||
|
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
|
||||||
|
<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"
|
||||||
|
data-testid="lifecycle-filter"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Lifecycle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Requests</SelectItem>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||||
<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"
|
||||||
@ -58,7 +77,6 @@ export function MyRequestsFilters({
|
|||||||
<SelectItem value="paused">Paused</SelectItem>
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@ -76,7 +94,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"
|
||||||
@ -88,7 +106,7 @@ export function MyRequestsFilters({
|
|||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* My Requests Stats Section Component
|
* My Requests Stats Section Component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
|
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit } from 'lucide-react';
|
||||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||||
import { MyRequestsStats } from '../types/myRequests.types';
|
import { MyRequestsStats } from '../types/myRequests.types';
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="Total"
|
label="Total"
|
||||||
value={stats.total}
|
value={stats.total}
|
||||||
@ -90,18 +90,6 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
|||||||
testId="stat-draft"
|
testId="stat-draft"
|
||||||
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined}
|
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
label="Closed"
|
|
||||||
value={stats.closed}
|
|
||||||
icon={Archive}
|
|
||||||
iconColor="text-purple-600"
|
|
||||||
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
|
|
||||||
textColor="text-purple-700"
|
|
||||||
valueColor="text-purple-900"
|
|
||||||
testId="stat-closed"
|
|
||||||
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
|
import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { MyRequest } from '../types/myRequests.types';
|
import { MyRequest } from '../types/myRequests.types';
|
||||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -17,28 +17,22 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|||||||
const stripHtmlTags = (html: string): string => {
|
const stripHtmlTags = (html: string): string => {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
|
|
||||||
// 1. Replace block-level tags with a space to avoid merging words (e.g. </div><div> -> " ")
|
// Check if we're in a browser environment
|
||||||
// This preserves readability for the card preview
|
if (typeof document === 'undefined') {
|
||||||
let text = html.replace(/<(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|tfoot|ul|video)[^>]*>/gi, ' ');
|
// Fallback for SSR: use regex to strip HTML tags
|
||||||
|
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Replace <br> with space
|
// Create a temporary div to parse HTML
|
||||||
text = text.replace(/<br\s*\/?>/gi, ' ');
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
// 3. Strip all other tags
|
// Get text content (automatically strips HTML tags)
|
||||||
text = text.replace(/<[^>]*>/g, '');
|
let text = tempDiv.textContent || tempDiv.innerText || '';
|
||||||
|
|
||||||
// 4. Clean up extra whitespace
|
// Clean up extra whitespace
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
// 5. Basic HTML entity decoding for common characters
|
|
||||||
text = text
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -50,6 +44,7 @@ interface RequestCardProps {
|
|||||||
|
|
||||||
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
||||||
const statusConfig = getStatusConfig(request.status);
|
const statusConfig = getStatusConfig(request.status);
|
||||||
|
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
|
||||||
const priorityConfig = getPriorityConfig(request.priority);
|
const priorityConfig = getPriorityConfig(request.priority);
|
||||||
const StatusIcon = statusConfig.icon;
|
const StatusIcon = statusConfig.icon;
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
@ -85,6 +80,15 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
<StatusIcon className="w-3 h-3 mr-1" />
|
<StatusIcon className="w-3 h-3 mr-1" />
|
||||||
<span className="capitalize">{request.status}</span>
|
<span className="capitalize">{request.status}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{stateConfig.label.toLowerCase() !== request.status.toLowerCase() && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
|
||||||
|
data-testid="state-badge"
|
||||||
|
>
|
||||||
|
<span className="capitalize">{stateConfig.label}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{(request.pauseInfo?.isPaused || (request as any).isPaused) && (
|
{(request.pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ interface UseMyRequestsOptions {
|
|||||||
status?: string;
|
status?: string;
|
||||||
priority?: string;
|
priority?: string;
|
||||||
templateType?: string;
|
templateType?: string;
|
||||||
|
lifecycle?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +30,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchMyRequests = useCallback(
|
const fetchMyRequests = useCallback(
|
||||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string }) => {
|
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string, lifecycle?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -43,6 +44,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
|
|||||||
status: filters?.status,
|
status: filters?.status,
|
||||||
priority: filters?.priority,
|
priority: filters?.priority,
|
||||||
templateType: filters?.templateType,
|
templateType: filters?.templateType,
|
||||||
|
lifecycle: filters?.lifecycle,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import {
|
|||||||
setPriorityFilter as setPriorityFilterAction,
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
setTemplateTypeFilter as setTemplateTypeFilterAction,
|
setTemplateTypeFilter as setTemplateTypeFilterAction,
|
||||||
setCurrentPage as setCurrentPageAction,
|
setCurrentPage as setCurrentPageAction,
|
||||||
|
setLifecycleFilter as setLifecycleFilterAction,
|
||||||
clearFilters as clearFiltersAction,
|
clearFilters as clearFiltersAction,
|
||||||
} from '../redux/myRequestsSlice';
|
} from '../redux/myRequestsSlice';
|
||||||
|
|
||||||
@ -25,7 +26,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
// Get filters from Redux
|
// Get filters from Redux
|
||||||
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage } = useAppSelector((state) => state.myRequests);
|
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage, lifecycleFilter } = useAppSelector((state) => state.myRequests);
|
||||||
|
|
||||||
// Create setters that dispatch Redux actions
|
// Create setters that dispatch Redux actions
|
||||||
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
@ -33,6 +34,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
|
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
|
||||||
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
|
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
|
||||||
|
|
||||||
const getFilters = useCallback((): MyRequestsFilters => {
|
const getFilters = useCallback((): MyRequestsFilters => {
|
||||||
return {
|
return {
|
||||||
@ -40,8 +42,9 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
priority: priorityFilter,
|
priority: priorityFilter,
|
||||||
templateType: templateTypeFilter,
|
templateType: templateTypeFilter,
|
||||||
|
lifecycle: lifecycleFilter,
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter]);
|
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter]);
|
||||||
|
|
||||||
// Debounced filter change handler
|
// Debounced filter change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -68,7 +71,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
clearTimeout(debounceTimeoutRef.current);
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, onFiltersChange, getFilters, debounceMs]);
|
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter, onFiltersChange, getFilters, debounceMs]);
|
||||||
|
|
||||||
const resetFilters = useCallback(() => {
|
const resetFilters = useCallback(() => {
|
||||||
dispatch(clearFiltersAction());
|
dispatch(clearFiltersAction());
|
||||||
@ -80,11 +83,13 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
|||||||
priorityFilter,
|
priorityFilter,
|
||||||
templateTypeFilter,
|
templateTypeFilter,
|
||||||
currentPage,
|
currentPage,
|
||||||
|
lifecycleFilter,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
setLifecycleFilter,
|
||||||
getFilters,
|
getFilters,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface MyRequestsFiltersState {
|
|||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
templateTypeFilter: string;
|
templateTypeFilter: string;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
lifecycleFilter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: MyRequestsFiltersState = {
|
const initialState: MyRequestsFiltersState = {
|
||||||
@ -14,6 +15,7 @@ const initialState: MyRequestsFiltersState = {
|
|||||||
priorityFilter: 'all',
|
priorityFilter: 'all',
|
||||||
templateTypeFilter: 'all',
|
templateTypeFilter: 'all',
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
|
lifecycleFilter: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
const myRequestsSlice = createSlice({
|
const myRequestsSlice = createSlice({
|
||||||
@ -37,12 +39,16 @@ const myRequestsSlice = createSlice({
|
|||||||
setCurrentPage: (state, action: PayloadAction<number>) => {
|
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||||
state.currentPage = action.payload;
|
state.currentPage = action.payload;
|
||||||
},
|
},
|
||||||
|
setLifecycleFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.lifecycleFilter = action.payload;
|
||||||
|
},
|
||||||
clearFilters: (state) => {
|
clearFilters: (state) => {
|
||||||
state.searchTerm = '';
|
state.searchTerm = '';
|
||||||
state.statusFilter = 'all';
|
state.statusFilter = 'all';
|
||||||
state.priorityFilter = 'all';
|
state.priorityFilter = 'all';
|
||||||
state.templateTypeFilter = 'all';
|
state.templateTypeFilter = 'all';
|
||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
|
state.lifecycleFilter = 'all';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -53,6 +59,7 @@ export const {
|
|||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setTemplateTypeFilter,
|
setTemplateTypeFilter,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
setLifecycleFilter,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
} = myRequestsSlice.actions;
|
} = myRequestsSlice.actions;
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface MyRequest {
|
|||||||
approverLevel?: string;
|
approverLevel?: string;
|
||||||
templateType?: string;
|
templateType?: string;
|
||||||
workflowType?: string;
|
workflowType?: string;
|
||||||
|
workflowState?: string;
|
||||||
templateName?: string;
|
templateName?: string;
|
||||||
pauseInfo?: {
|
pauseInfo?: {
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
@ -41,6 +42,7 @@ export interface MyRequestsFilters {
|
|||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
templateType?: string;
|
templateType?: string;
|
||||||
|
lifecycle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaginationState {
|
export interface PaginationState {
|
||||||
|
|||||||
@ -87,3 +87,25 @@ export function getStatusConfig(status: string): StatusConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWorkflowStateConfig(state: string) {
|
||||||
|
const s = (state || '').toUpperCase();
|
||||||
|
switch (s) {
|
||||||
|
case 'CLOSED':
|
||||||
|
return {
|
||||||
|
color: 'bg-slate-100 text-slate-800 border-slate-200',
|
||||||
|
label: 'closed',
|
||||||
|
};
|
||||||
|
case 'DRAFT':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
|
label: 'draft',
|
||||||
|
};
|
||||||
|
case 'OPEN':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||||
|
label: 'open',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ export function transformRequest(req: any): MyRequest {
|
|||||||
: '—',
|
: '—',
|
||||||
templateType: req.templateType || req.template_type,
|
templateType: req.templateType || req.template_type,
|
||||||
workflowType: req.workflowType || req.workflow_type,
|
workflowType: req.workflowType || req.workflow_type,
|
||||||
|
workflowState: req.workflowState || req.workflow_state,
|
||||||
templateName: req.templateName || req.template_name,
|
templateName: req.templateName || req.template_name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export function QuickActionsSidebar({
|
|||||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||||
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
||||||
const isClosed = request?.status === 'closed';
|
const isClosed = apiRequest?.workflowState === 'CLOSED' || request?.status === 'closed';
|
||||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||||
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
|
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
|
||||||
const currentUserId = currentUserIdProp || (user as any)?.userId || '';
|
const currentUserId = currentUserIdProp || (user as any)?.userId || '';
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react';
|
import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react';
|
||||||
import { getPriorityConfig, getStatusConfig } from '@/utils/requestDetailHelpers';
|
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '@/utils/requestDetailHelpers';
|
||||||
import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
|
import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
|
||||||
|
|
||||||
interface RequestDetailHeaderProps {
|
interface RequestDetailHeaderProps {
|
||||||
@ -32,6 +32,7 @@ export function RequestDetailHeader({
|
|||||||
}: RequestDetailHeaderProps) {
|
}: RequestDetailHeaderProps) {
|
||||||
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
|
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
|
||||||
const statusConfig = getStatusConfig(request?.status || 'pending');
|
const statusConfig = getStatusConfig(request?.status || 'pending');
|
||||||
|
const stateConfig = getWorkflowStateConfig(request?.workflowState || (request?.status === 'DRAFT' ? 'DRAFT' : 'OPEN'));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
|
||||||
@ -77,6 +78,15 @@ export function RequestDetailHeader({
|
|||||||
>
|
>
|
||||||
{statusConfig.label}
|
{statusConfig.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{stateConfig.label.toLowerCase() !== (request?.status || '').toLowerCase() && (
|
||||||
|
<Badge
|
||||||
|
className={`${stateConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
|
||||||
|
variant="outline"
|
||||||
|
data-testid="state-badge"
|
||||||
|
>
|
||||||
|
{stateConfig.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{/* Template Type Badge */}
|
{/* Template Type Badge */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const workflowType = request?.workflowType || request?.workflow_type;
|
const workflowType = request?.workflowType || request?.workflow_type;
|
||||||
@ -120,7 +130,7 @@ export function RequestDetailHeader({
|
|||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Share Summary Button - Only show for closed requests if user is initiator */}
|
{/* Share Summary Button - Only show for closed requests if user is initiator */}
|
||||||
{onShareSummary && isInitiator && request?.status?.toLowerCase() === 'closed' && (
|
{onShareSummary && isInitiator && request?.workflowState?.toLowerCase() === 'closed' && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -157,9 +167,8 @@ export function RequestDetailHeader({
|
|||||||
|
|
||||||
{/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
|
{/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
|
||||||
{slaData !== undefined && (
|
{slaData !== undefined && (
|
||||||
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
|
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
||||||
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
}`} data-testid="sla-section">
|
||||||
}`} data-testid="sla-section">
|
|
||||||
<SLAProgressBar
|
<SLAProgressBar
|
||||||
sla={slaData}
|
sla={slaData}
|
||||||
requestStatus={request.status}
|
requestStatus={request.status}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ interface OverviewTabProps {
|
|||||||
generationAttempts?: number;
|
generationAttempts?: number;
|
||||||
generationFailed?: boolean;
|
generationFailed?: boolean;
|
||||||
maxAttemptsReached?: boolean;
|
maxAttemptsReached?: boolean;
|
||||||
|
isClosed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewTab({
|
export function OverviewTab({
|
||||||
@ -57,6 +58,7 @@ export function OverviewTab({
|
|||||||
generationAttempts = 0,
|
generationAttempts = 0,
|
||||||
generationFailed = false,
|
generationFailed = false,
|
||||||
maxAttemptsReached = false,
|
maxAttemptsReached = false,
|
||||||
|
isClosed = false,
|
||||||
}: OverviewTabProps) {
|
}: OverviewTabProps) {
|
||||||
void _onPause; // Marked as intentionally unused - available for future use
|
void _onPause; // Marked as intentionally unused - available for future use
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@ -184,10 +186,7 @@ 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>
|
||||||
<FormattedDescription
|
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
|
||||||
content={pauseInfo.pauseReason}
|
|
||||||
className="text-sm text-gray-900 mt-1"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -304,7 +303,7 @@ export function OverviewTab({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Read-Only Conclusion Remark */}
|
{/* Read-Only Conclusion Remark */}
|
||||||
{request.status === 'closed' && request.conclusionRemark && (
|
{isClosed && request.conclusionRemark && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-200">
|
<CardHeader className="bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-200">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
|
|||||||
@ -163,14 +163,7 @@ 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>
|
||||||
{approver.remarks ? (
|
<p className="text-sm text-gray-700">{approver.remarks || '—'}</p>
|
||||||
<FormattedDescription
|
|
||||||
content={approver.remarks}
|
|
||||||
className="text-sm text-gray-700"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-gray-700">—</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ 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';
|
||||||
@ -69,6 +70,7 @@ 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);
|
||||||
@ -77,15 +79,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
|
||||||
@ -98,7 +100,6 @@ 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;
|
||||||
@ -162,7 +163,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
}).length;
|
}).length;
|
||||||
const closed = filteredData.filter((r: any) => {
|
const closed = filteredData.filter((r: any) => {
|
||||||
const status = (r.status || '').toString().toUpperCase();
|
const status = (r.status || '').toString().toUpperCase();
|
||||||
return status === 'CLOSED';
|
const state = (r.workflowState || '').toString().toUpperCase();
|
||||||
|
return (status === 'CLOSED' || state === 'CLOSED') && status !== 'APPROVED' && status !== 'REJECTED';
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
setBackendStats({
|
setBackendStats({
|
||||||
@ -184,7 +186,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,
|
||||||
filtersWithoutStatus?.templateType,
|
undefined, // templateType
|
||||||
filtersWithoutStatus?.department,
|
filtersWithoutStatus?.department,
|
||||||
filtersWithoutStatus?.initiator,
|
filtersWithoutStatus?.initiator,
|
||||||
filtersWithoutStatus?.approver,
|
filtersWithoutStatus?.approver,
|
||||||
@ -225,6 +227,20 @@ 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);
|
||||||
@ -317,7 +333,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDepartments();
|
fetchDepartments();
|
||||||
}, [fetchDepartments]);
|
fetchUsers();
|
||||||
|
}, [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
|
||||||
@ -380,6 +397,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
dateRange: filters.dateRange,
|
dateRange: filters.dateRange,
|
||||||
customStartDate: filters.customStartDate,
|
customStartDate: filters.customStartDate,
|
||||||
customEndDate: filters.customEndDate,
|
customEndDate: filters.customEndDate,
|
||||||
|
lifecycleFilter: filters.lifecycleFilter,
|
||||||
isOrgLevel,
|
isOrgLevel,
|
||||||
});
|
});
|
||||||
const hasInitialFetchRun = useRef(false);
|
const hasInitialFetchRun = useRef(false);
|
||||||
@ -410,6 +428,7 @@ export function Requests({ 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 ||
|
||||||
|
prev.lifecycleFilter !== filters.lifecycleFilter ||
|
||||||
prev.isOrgLevel !== isOrgLevel;
|
prev.isOrgLevel !== isOrgLevel;
|
||||||
|
|
||||||
if (!hasChanged) return;
|
if (!hasChanged) return;
|
||||||
@ -431,6 +450,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
dateRange: filters.dateRange,
|
dateRange: filters.dateRange,
|
||||||
customStartDate: filters.customStartDate,
|
customStartDate: filters.customStartDate,
|
||||||
customEndDate: filters.customEndDate,
|
customEndDate: filters.customEndDate,
|
||||||
|
lifecycleFilter: filters.lifecycleFilter,
|
||||||
isOrgLevel,
|
isOrgLevel,
|
||||||
};
|
};
|
||||||
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
@ -450,7 +470,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
filters.approverFilterType,
|
filters.approverFilterType,
|
||||||
filters.dateRange,
|
filters.dateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate
|
filters.customEndDate,
|
||||||
|
filters.lifecycleFilter
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
@ -537,8 +558,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Primary Filters */}
|
{/* Primary Filters */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||||
<div className="relative md:col-span-3 lg:col-span-1">
|
<div className="relative md:col-span-2 lg:col-span-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search requests..."
|
placeholder="Search requests..."
|
||||||
@ -549,6 +570,17 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Select value={filters.lifecycleFilter} onValueChange={filters.setLifecycleFilter}>
|
||||||
|
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
|
||||||
|
<SelectValue placeholder="Lifecycle" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Requests</SelectItem>
|
||||||
|
<SelectItem value="open">Open</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
|
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
|
||||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
<SelectTrigger className="h-10" data-testid="status-filter">
|
||||||
<SelectValue placeholder="All Status" />
|
<SelectValue placeholder="All Status" />
|
||||||
@ -559,7 +591,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<SelectItem value="paused">Paused</SelectItem>
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@ -574,7 +605,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
{/* <Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
||||||
<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>
|
||||||
@ -583,7 +614,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<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> */}
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={filters.departmentFilter}
|
value={filters.departmentFilter}
|
||||||
@ -634,7 +665,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search initiator..."
|
placeholder="Use @ to search initiator..."
|
||||||
value={initiatorSearch.searchQuery}
|
value={initiatorSearch.searchQuery}
|
||||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
@ -704,7 +735,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search approver..."
|
placeholder="Use @ to search approver..."
|
||||||
value={approverSearch.searchQuery}
|
value={approverSearch.searchQuery}
|
||||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ 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';
|
||||||
@ -95,6 +96,7 @@ 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);
|
||||||
@ -103,15 +105,15 @@ 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
|
||||||
@ -178,6 +180,20 @@ 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);
|
||||||
@ -237,7 +253,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// Initial fetch
|
// Initial fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchDepartments();
|
fetchDepartments();
|
||||||
}, [fetchDepartments]);
|
fetchUsers();
|
||||||
|
}, [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
|
||||||
@ -310,6 +327,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
dateRange: filters.dateRange,
|
dateRange: filters.dateRange,
|
||||||
customStartDate: filters.customStartDate,
|
customStartDate: filters.customStartDate,
|
||||||
customEndDate: filters.customEndDate,
|
customEndDate: filters.customEndDate,
|
||||||
|
lifecycleFilter: filters.lifecycleFilter,
|
||||||
});
|
});
|
||||||
const hasInitialFetchRun = useRef(false);
|
const hasInitialFetchRun = useRef(false);
|
||||||
|
|
||||||
@ -338,7 +356,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
prev.approverFilterType !== filters.approverFilterType ||
|
prev.approverFilterType !== filters.approverFilterType ||
|
||||||
prev.dateRange !== filters.dateRange ||
|
prev.dateRange !== filters.dateRange ||
|
||||||
prev.customStartDate !== filters.customStartDate ||
|
prev.customStartDate !== filters.customStartDate ||
|
||||||
prev.customEndDate !== filters.customEndDate;
|
prev.customEndDate !== filters.customEndDate ||
|
||||||
|
prev.lifecycleFilter !== filters.lifecycleFilter;
|
||||||
|
|
||||||
if (!hasChanged) return;
|
if (!hasChanged) return;
|
||||||
|
|
||||||
@ -359,6 +378,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
dateRange: filters.dateRange,
|
dateRange: filters.dateRange,
|
||||||
customStartDate: filters.customStartDate,
|
customStartDate: filters.customStartDate,
|
||||||
customEndDate: filters.customEndDate,
|
customEndDate: filters.customEndDate,
|
||||||
|
lifecycleFilter: filters.lifecycleFilter,
|
||||||
};
|
};
|
||||||
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
@ -376,7 +396,9 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
filters.approverFilterType,
|
filters.approverFilterType,
|
||||||
filters.dateRange,
|
filters.dateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate
|
filters.customStartDate,
|
||||||
|
filters.customEndDate,
|
||||||
|
filters.lifecycleFilter
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
@ -460,6 +482,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
{/* Filters - Plug-and-play pattern */}
|
{/* Filters - Plug-and-play pattern */}
|
||||||
<UserAllRequestsFiltersComponent
|
<UserAllRequestsFiltersComponent
|
||||||
searchTerm={filters.searchTerm}
|
searchTerm={filters.searchTerm}
|
||||||
|
lifecycleFilter={filters.lifecycleFilter}
|
||||||
statusFilter={filters.statusFilter}
|
statusFilter={filters.statusFilter}
|
||||||
priorityFilter={filters.priorityFilter}
|
priorityFilter={filters.priorityFilter}
|
||||||
templateTypeFilter={filters.templateTypeFilter}
|
templateTypeFilter={filters.templateTypeFilter}
|
||||||
@ -477,6 +500,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
initiatorSearch={initiatorSearch}
|
initiatorSearch={initiatorSearch}
|
||||||
approverSearch={approverSearch}
|
approverSearch={approverSearch}
|
||||||
onSearchChange={filters.setSearchTerm}
|
onSearchChange={filters.setSearchTerm}
|
||||||
|
onLifecycleChange={filters.setLifecycleFilter}
|
||||||
onStatusChange={filters.setStatusFilter}
|
onStatusChange={filters.setStatusFilter}
|
||||||
onPriorityChange={filters.setPriorityFilter}
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
|
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
|
||||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
|
||||||
import type { ConvertedRequest } from '../types/requests.types';
|
import type { ConvertedRequest } from '../types/requests.types';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
@ -16,28 +16,22 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
|||||||
const stripHtmlTags = (html: string): string => {
|
const stripHtmlTags = (html: string): string => {
|
||||||
if (!html) return '';
|
if (!html) return '';
|
||||||
|
|
||||||
// 1. Replace block-level tags with a space to avoid merging words (e.g. </div><div> -> " ")
|
// Check if we're in a browser environment
|
||||||
// This preserves readability for the card preview
|
if (typeof document === 'undefined') {
|
||||||
let text = html.replace(/<(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|tfoot|ul|video)[^>]*>/gi, ' ');
|
// Fallback for SSR: use regex to strip HTML tags
|
||||||
|
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Replace <br> with space
|
// Create a temporary div to parse HTML
|
||||||
text = text.replace(/<br\s*\/?>/gi, ' ');
|
const tempDiv = document.createElement('div');
|
||||||
|
tempDiv.innerHTML = html;
|
||||||
|
|
||||||
// 3. Strip all other tags
|
// Get text content (automatically strips HTML tags)
|
||||||
text = text.replace(/<[^>]*>/g, '');
|
let text = tempDiv.textContent || tempDiv.innerText || '';
|
||||||
|
|
||||||
// 4. Clean up extra whitespace
|
// Clean up extra whitespace
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
text = text.replace(/\s+/g, ' ').trim();
|
||||||
|
|
||||||
// 5. Basic HTML entity decoding for common characters
|
|
||||||
text = text
|
|
||||||
.replace(/ /g, ' ')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
|
|
||||||
return text;
|
return text;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -49,6 +43,7 @@ interface RequestCardProps {
|
|||||||
|
|
||||||
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
||||||
const statusConfig = getStatusConfig(request.status);
|
const statusConfig = getStatusConfig(request.status);
|
||||||
|
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
|
||||||
const priorityConfig = getPriorityConfig(request.priority);
|
const priorityConfig = getPriorityConfig(request.priority);
|
||||||
const StatusIcon = statusConfig.icon;
|
const StatusIcon = statusConfig.icon;
|
||||||
const PriorityIcon = priorityConfig.icon;
|
const PriorityIcon = priorityConfig.icon;
|
||||||
@ -84,6 +79,15 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
<StatusIcon className="w-3 h-3 mr-1" />
|
<StatusIcon className="w-3 h-3 mr-1" />
|
||||||
<span className="capitalize">{request.status}</span>
|
<span className="capitalize">{request.status}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{stateConfig.label.toLowerCase() !== (request.status || '').toLowerCase() && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
|
||||||
|
data-testid="state-badge"
|
||||||
|
>
|
||||||
|
<span className="capitalize">{stateConfig.label}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* Displays statistics cards for requests with click handlers to filter
|
* Displays statistics cards for requests with click handlers to filter
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FileText, Clock, Pause, CheckCircle, XCircle, Archive } from 'lucide-react';
|
import { FileText, Clock, Pause, CheckCircle, XCircle } from 'lucide-react';
|
||||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||||
import type { RequestStats } from '../types/requests.types';
|
import type { RequestStats } from '../types/requests.types';
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="Total"
|
label="Total"
|
||||||
value={stats.total}
|
value={stats.total}
|
||||||
@ -80,18 +80,6 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
|||||||
testId="stat-rejected"
|
testId="stat-rejected"
|
||||||
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
|
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsCard
|
|
||||||
label="Closed"
|
|
||||||
value={stats.closed}
|
|
||||||
icon={Archive}
|
|
||||||
iconColor="text-purple-600"
|
|
||||||
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
|
|
||||||
textColor="text-purple-700"
|
|
||||||
valueColor="text-purple-900"
|
|
||||||
testId="stat-closed"
|
|
||||||
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import {
|
|||||||
setCustomEndDate as setCustomEndDateAction,
|
setCustomEndDate as setCustomEndDateAction,
|
||||||
setShowCustomDatePicker as setShowCustomDatePickerAction,
|
setShowCustomDatePicker as setShowCustomDatePickerAction,
|
||||||
setCurrentPage as setCurrentPageAction,
|
setCurrentPage as setCurrentPageAction,
|
||||||
|
setLifecycleFilter as setLifecycleFilterAction,
|
||||||
clearFilters as clearFiltersAction,
|
clearFilters as clearFiltersAction,
|
||||||
} from '../redux/requestsSlice';
|
} from '../redux/requestsSlice';
|
||||||
|
|
||||||
@ -44,6 +45,7 @@ export function useRequestsFilters() {
|
|||||||
customEndDate,
|
customEndDate,
|
||||||
showCustomDatePicker,
|
showCustomDatePicker,
|
||||||
currentPage,
|
currentPage,
|
||||||
|
lifecycleFilter,
|
||||||
} = useAppSelector((state) => state.requests);
|
} = useAppSelector((state) => state.requests);
|
||||||
|
|
||||||
// Create setters that dispatch Redux actions
|
// Create setters that dispatch Redux actions
|
||||||
@ -61,6 +63,7 @@ export function useRequestsFilters() {
|
|||||||
const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]);
|
const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]);
|
||||||
const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]);
|
const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]);
|
||||||
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
|
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
|
||||||
|
|
||||||
const getFilters = useCallback((): RequestFilters => {
|
const getFilters = useCallback((): RequestFilters => {
|
||||||
return {
|
return {
|
||||||
@ -73,6 +76,7 @@ export function useRequestsFilters() {
|
|||||||
initiator: initiatorFilter !== 'all' ? initiatorFilter : undefined,
|
initiator: initiatorFilter !== 'all' ? initiatorFilter : undefined,
|
||||||
approver: approverFilter !== 'all' ? approverFilter : undefined,
|
approver: approverFilter !== 'all' ? approverFilter : undefined,
|
||||||
approverType: approverFilter !== 'all' ? approverFilterType : undefined,
|
approverType: approverFilter !== 'all' ? approverFilterType : undefined,
|
||||||
|
lifecycle: lifecycleFilter !== 'all' ? lifecycleFilter : undefined,
|
||||||
dateRange,
|
dateRange,
|
||||||
startDate: customStartDate,
|
startDate: customStartDate,
|
||||||
endDate: customEndDate
|
endDate: customEndDate
|
||||||
@ -87,6 +91,7 @@ export function useRequestsFilters() {
|
|||||||
initiatorFilter,
|
initiatorFilter,
|
||||||
approverFilter,
|
approverFilter,
|
||||||
approverFilterType,
|
approverFilterType,
|
||||||
|
lifecycleFilter, // Ensure lifecycleFilter is in dependencies
|
||||||
dateRange,
|
dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate
|
customEndDate
|
||||||
@ -128,6 +133,7 @@ export function useRequestsFilters() {
|
|||||||
departmentFilter !== 'all' ||
|
departmentFilter !== 'all' ||
|
||||||
initiatorFilter !== 'all' ||
|
initiatorFilter !== 'all' ||
|
||||||
approverFilter !== 'all' ||
|
approverFilter !== 'all' ||
|
||||||
|
lifecycleFilter !== 'all' ||
|
||||||
dateRange !== 'all' ||
|
dateRange !== 'all' ||
|
||||||
customStartDate ||
|
customStartDate ||
|
||||||
customEndDate
|
customEndDate
|
||||||
@ -147,6 +153,7 @@ export function useRequestsFilters() {
|
|||||||
dateRange,
|
dateRange,
|
||||||
customStartDate,
|
customStartDate,
|
||||||
customEndDate,
|
customEndDate,
|
||||||
|
lifecycleFilter,
|
||||||
showCustomDatePicker,
|
showCustomDatePicker,
|
||||||
currentPage,
|
currentPage,
|
||||||
hasActiveFilters,
|
hasActiveFilters,
|
||||||
@ -165,6 +172,7 @@ export function useRequestsFilters() {
|
|||||||
setCustomEndDate,
|
setCustomEndDate,
|
||||||
setShowCustomDatePicker,
|
setShowCustomDatePicker,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
setLifecycleFilter,
|
||||||
// Helpers
|
// Helpers
|
||||||
getFilters,
|
getFilters,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
|
|||||||
@ -4,44 +4,30 @@
|
|||||||
|
|
||||||
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({ filterValue, onFilterChange, source = 'default' }: UseUserSearchOptions) {
|
export function useUserSearch({ allUsers, filterValue, onFilterChange }: 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 details if we only have the ID (filterValue)
|
// Initialize selected user from filter value
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchUserDetail() {
|
if (filterValue !== 'all' && allUsers.length > 0) {
|
||||||
if (filterValue !== 'all' && !selectedUser) {
|
const user = allUsers.find(u => u.userId === filterValue);
|
||||||
try {
|
if (user) {
|
||||||
// Fetch specific user details by ID
|
setSelectedUser(user);
|
||||||
const user = await userApi.getUserById(filterValue);
|
setSearchQuery(user.displayName || user.email);
|
||||||
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(() => {
|
||||||
@ -59,28 +45,23 @@ export function useUserSearch({ filterValue, onFilterChange, source = 'default'
|
|||||||
clearTimeout(searchTimer.current);
|
clearTimeout(searchTimer.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!query || query.trim().length < 2) {
|
if (!query || !query.startsWith('@') || query.trim().length < 2) {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(() => {
|
||||||
setSearching(true);
|
const searchLower = query.slice(1).toLowerCase().trim();
|
||||||
try {
|
const filtered = allUsers.filter((user) => {
|
||||||
const response = await userApi.searchUsers(query.trim(), 10, source);
|
const email = (user.email || '').toLowerCase();
|
||||||
const users = response.data?.data || [];
|
const displayName = (user.displayName || '').toLowerCase();
|
||||||
setSearchResults(users);
|
return email.includes(searchLower) || displayName.includes(searchLower);
|
||||||
setShowResults(users.length > 0);
|
});
|
||||||
} catch (err) {
|
setSearchResults(filtered.slice(0, 10));
|
||||||
console.error('Search API failed:', err);
|
setShowResults(filtered.length > 0);
|
||||||
setSearchResults([]);
|
}, 300);
|
||||||
setShowResults(false);
|
}, [allUsers]);
|
||||||
} 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);
|
||||||
@ -103,7 +84,6 @@ export function useUserSearch({ filterValue, onFilterChange, source = 'default'
|
|||||||
searchResults,
|
searchResults,
|
||||||
showResults,
|
showResults,
|
||||||
selectedUser,
|
selectedUser,
|
||||||
searching,
|
|
||||||
handleSearch,
|
handleSearch,
|
||||||
handleSelect,
|
handleSelect,
|
||||||
handleClear,
|
handleClear,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export interface RequestsFiltersState {
|
|||||||
customEndDate?: Date;
|
customEndDate?: Date;
|
||||||
showCustomDatePicker: boolean;
|
showCustomDatePicker: boolean;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
lifecycleFilter: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: RequestsFiltersState = {
|
const initialState: RequestsFiltersState = {
|
||||||
@ -33,6 +34,7 @@ const initialState: RequestsFiltersState = {
|
|||||||
customEndDate: undefined,
|
customEndDate: undefined,
|
||||||
showCustomDatePicker: false,
|
showCustomDatePicker: false,
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
|
lifecycleFilter: 'all',
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestsSlice = createSlice({
|
const requestsSlice = createSlice({
|
||||||
@ -81,6 +83,9 @@ const requestsSlice = createSlice({
|
|||||||
setCurrentPage: (state, action: PayloadAction<number>) => {
|
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||||
state.currentPage = action.payload;
|
state.currentPage = action.payload;
|
||||||
},
|
},
|
||||||
|
setLifecycleFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.lifecycleFilter = action.payload;
|
||||||
|
},
|
||||||
clearFilters: (state) => {
|
clearFilters: (state) => {
|
||||||
state.searchTerm = '';
|
state.searchTerm = '';
|
||||||
state.statusFilter = 'all';
|
state.statusFilter = 'all';
|
||||||
@ -96,6 +101,7 @@ const requestsSlice = createSlice({
|
|||||||
state.customEndDate = undefined;
|
state.customEndDate = undefined;
|
||||||
state.showCustomDatePicker = false;
|
state.showCustomDatePicker = false;
|
||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
|
state.lifecycleFilter = 'all';
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -115,6 +121,7 @@ export const {
|
|||||||
setCustomEndDate,
|
setCustomEndDate,
|
||||||
setShowCustomDatePicker,
|
setShowCustomDatePicker,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
setLifecycleFilter,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
} = requestsSlice.actions;
|
} = requestsSlice.actions;
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,7 @@ export async function fetchRequestsData({
|
|||||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
||||||
|
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||||
|
|
||||||
// Fetch paginated data for list display (with status filter)
|
// Fetch paginated data for list display (with status filter)
|
||||||
const pageResult = await workflowApi.listWorkflows({
|
const pageResult = await workflowApi.listWorkflows({
|
||||||
@ -81,60 +82,61 @@ export async function fetchRequestsData({
|
|||||||
totalPages: pagination.totalPages || 1
|
totalPages: pagination.totalPages || 1
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
|
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
|
||||||
// This shows ALL requests where user is involved:
|
// This shows ALL requests where user is involved:
|
||||||
// - As initiator (created the request)
|
// - As initiator (created the request)
|
||||||
// - As approver (in any approval level)
|
// - As approver (in any approval level)
|
||||||
// - As participant/spectator
|
// - As participant/spectator
|
||||||
const backendFilters: any = {};
|
const backendFilters: any = {};
|
||||||
if (filters?.search) backendFilters.search = filters.search;
|
if (filters?.search) backendFilters.search = filters.search;
|
||||||
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
|
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
|
||||||
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
|
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
|
||||||
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
|
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
|
||||||
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
|
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
|
||||||
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
|
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
|
||||||
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
|
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
|
||||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
||||||
|
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||||
|
|
||||||
// Fetch paginated data using endpoint for regular users
|
// Fetch paginated data using endpoint for regular users
|
||||||
// This endpoint includes all requests where user is initiator, approver, or participant
|
// This endpoint includes all requests where user is initiator, approver, or participant
|
||||||
const pageResult = await workflowApi.listParticipantRequests({
|
const pageResult = await workflowApi.listParticipantRequests({
|
||||||
page,
|
page,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
...backendFilters
|
...backendFilters
|
||||||
});
|
});
|
||||||
|
|
||||||
let pageData: any[] = [];
|
let pageData: any[] = [];
|
||||||
if (Array.isArray(pageResult?.data)) {
|
if (Array.isArray(pageResult?.data)) {
|
||||||
pageData = pageResult.data;
|
pageData = pageResult.data;
|
||||||
} else if (Array.isArray(pageResult)) {
|
} else if (Array.isArray(pageResult)) {
|
||||||
pageData = pageResult;
|
pageData = pageResult;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out drafts (backend should handle this, but double-check)
|
|
||||||
const nonDraftData = pageData.filter((req: any) => {
|
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
||||||
return reqStatus !== 'DRAFT';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get pagination info from backend response
|
|
||||||
const pagination = pageResult?.pagination || {
|
|
||||||
page,
|
|
||||||
limit: itemsPerPage,
|
|
||||||
total: nonDraftData.length,
|
|
||||||
totalPages: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
data: nonDraftData, // Paginated data for list
|
|
||||||
allData: [], // Stats come from backend stats API for user-level too
|
|
||||||
filteredData: nonDraftData, // This is the data for the current page, already filtered
|
|
||||||
pagination: pagination
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter out drafts (backend should handle this, but double-check)
|
||||||
|
const nonDraftData = pageData.filter((req: any) => {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
return reqStatus !== 'DRAFT';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get pagination info from backend response
|
||||||
|
const pagination = pageResult?.pagination || {
|
||||||
|
page,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
total: nonDraftData.length,
|
||||||
|
totalPages: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: nonDraftData, // Paginated data for list
|
||||||
|
allData: [], // Stats come from backend stats API for user-level too
|
||||||
|
filteredData: nonDraftData, // This is the data for the current page, already filtered
|
||||||
|
pagination: pagination
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<any[]> {
|
export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<any[]> {
|
||||||
|
|||||||
@ -41,6 +41,7 @@ export async function fetchUserParticipantRequestsData({
|
|||||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||||
|
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||||
|
|
||||||
// Use single optimized endpoint - listParticipantRequests now includes initiator requests
|
// Use single optimized endpoint - listParticipantRequests now includes initiator requests
|
||||||
// Only fetch the requested page (10 records) for optimal performance
|
// Only fetch the requested page (10 records) for optimal performance
|
||||||
@ -113,6 +114,7 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
|||||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||||
|
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||||
|
|
||||||
// Fetch all pages using the single optimized endpoint
|
// Fetch all pages using the single optimized endpoint
|
||||||
while (hasMore && currentPage <= maxPages) {
|
while (hasMore && currentPage <= maxPages) {
|
||||||
@ -150,4 +152,3 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
|||||||
|
|
||||||
return allPages;
|
return allPages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export interface RequestFilters {
|
|||||||
dateRange?: DateRange;
|
dateRange?: DateRange;
|
||||||
startDate?: Date;
|
startDate?: Date;
|
||||||
endDate?: Date;
|
endDate?: Date;
|
||||||
|
lifecycle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestStats {
|
export interface RequestStats {
|
||||||
@ -64,6 +65,7 @@ export interface ConvertedRequest {
|
|||||||
approverLevel: string;
|
approverLevel: string;
|
||||||
templateType?: string;
|
templateType?: string;
|
||||||
workflowType?: string;
|
workflowType?: string;
|
||||||
|
workflowState?: string;
|
||||||
templateName?: string;
|
templateName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -68,3 +68,25 @@ export const getStatusConfig = (status: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getWorkflowStateConfig = (state: string) => {
|
||||||
|
const s = (state || '').toUpperCase();
|
||||||
|
switch (s) {
|
||||||
|
case 'CLOSED':
|
||||||
|
return {
|
||||||
|
color: 'bg-slate-100 text-slate-800 border-slate-200',
|
||||||
|
label: 'closed'
|
||||||
|
};
|
||||||
|
case 'DRAFT':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
|
label: 'draft'
|
||||||
|
};
|
||||||
|
case 'OPEN':
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||||
|
label: 'open'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -18,11 +18,11 @@ export function transformRequest(req: any): ConvertedRequest {
|
|||||||
if (currentApproverObj) {
|
if (currentApproverObj) {
|
||||||
// Handle object format: { name, email, approverName, approverEmail, etc. }
|
// Handle object format: { name, email, approverName, approverEmail, etc. }
|
||||||
currentApprover = currentApproverObj.name ||
|
currentApprover = currentApproverObj.name ||
|
||||||
currentApproverObj.approverName ||
|
currentApproverObj.approverName ||
|
||||||
currentApproverObj.displayName ||
|
currentApproverObj.displayName ||
|
||||||
currentApproverObj.email ||
|
currentApproverObj.email ||
|
||||||
currentApproverObj.approverEmail ||
|
currentApproverObj.approverEmail ||
|
||||||
'—';
|
'—';
|
||||||
} else if (req.approvals && Array.isArray(req.approvals) && req.approvals.length > 0) {
|
} else if (req.approvals && Array.isArray(req.approvals) && req.approvals.length > 0) {
|
||||||
// For completed requests, show the last approver (final approver)
|
// For completed requests, show the last approver (final approver)
|
||||||
// For active requests, find the current pending/in-progress approver
|
// For active requests, find the current pending/in-progress approver
|
||||||
@ -34,11 +34,11 @@ export function transformRequest(req: any): ConvertedRequest {
|
|||||||
if (activeApproval) {
|
if (activeApproval) {
|
||||||
// Active request - show current approver
|
// Active request - show current approver
|
||||||
currentApprover = activeApproval.approverName ||
|
currentApprover = activeApproval.approverName ||
|
||||||
activeApproval.approver?.name ||
|
activeApproval.approver?.name ||
|
||||||
activeApproval.approver?.displayName ||
|
activeApproval.approver?.displayName ||
|
||||||
activeApproval.approverEmail ||
|
activeApproval.approverEmail ||
|
||||||
activeApproval.approver?.email ||
|
activeApproval.approver?.email ||
|
||||||
'—';
|
'—';
|
||||||
} else {
|
} else {
|
||||||
// Completed request - show final approver (last one in the array, or highest level)
|
// Completed request - show final approver (last one in the array, or highest level)
|
||||||
const sortedApprovals = [...req.approvals].sort((a: any, b: any) => {
|
const sortedApprovals = [...req.approvals].sort((a: any, b: any) => {
|
||||||
@ -49,11 +49,11 @@ export function transformRequest(req: any): ConvertedRequest {
|
|||||||
const finalApproval = sortedApprovals[0];
|
const finalApproval = sortedApprovals[0];
|
||||||
if (finalApproval) {
|
if (finalApproval) {
|
||||||
currentApprover = finalApproval.approverName ||
|
currentApprover = finalApproval.approverName ||
|
||||||
finalApproval.approver?.name ||
|
finalApproval.approver?.name ||
|
||||||
finalApproval.approver?.displayName ||
|
finalApproval.approver?.displayName ||
|
||||||
finalApproval.approverEmail ||
|
finalApproval.approverEmail ||
|
||||||
finalApproval.approver?.email ||
|
finalApproval.approver?.email ||
|
||||||
'—';
|
'—';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -90,7 +90,7 @@ export function transformRequest(req: any): ConvertedRequest {
|
|||||||
displayId: req.requestNumber || req.request_number || req.id,
|
displayId: req.requestNumber || req.request_number || req.id,
|
||||||
title: req.title,
|
title: req.title,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
status: status.toLowerCase().replace('_','-'),
|
status: status.toLowerCase().replace('_', '-'),
|
||||||
priority: priority,
|
priority: priority,
|
||||||
department: req.department || req.initiator?.department,
|
department: req.department || req.initiator?.department,
|
||||||
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
||||||
@ -99,6 +99,7 @@ export function transformRequest(req: any): ConvertedRequest {
|
|||||||
approverLevel: approverLevel,
|
approverLevel: approverLevel,
|
||||||
templateType: req.templateType || req.template_type,
|
templateType: req.templateType || req.template_type,
|
||||||
workflowType: req.workflowType || req.workflow_type,
|
workflowType: req.workflowType || req.workflow_type,
|
||||||
|
workflowState: req.workflowState || req.workflow_state,
|
||||||
templateName: req.templateName || req.template_name
|
templateName: req.templateName || req.template_name
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
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,13 +19,10 @@ 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);
|
||||||
@ -200,14 +197,14 @@ 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" />
|
||||||
<span className="hidden sm:inline">Templates</span>
|
<span className="hidden sm:inline">Templates</span>
|
||||||
<span className="sm:hidden">Templates</span>
|
<span className="sm:hidden">Templates</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger> */}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Fixed width container to prevent layout shifts */}
|
{/* Fixed width container to prevent layout shifts */}
|
||||||
@ -274,18 +271,9 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<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 mb-4">
|
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
|
||||||
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>
|
||||||
@ -336,9 +324,6 @@ export function Settings() {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
||||||
{/* Additional Settings if needed */}
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -506,18 +491,9 @@ export function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<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 mb-4">
|
<p className="text-sm text-gray-600 text-center">Security settings will be available soon</p>
|
||||||
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>
|
||||||
@ -569,8 +545,6 @@ export function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional sections if needed */}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user