Compare commits

..

No commits in common. "main" and "dealer_claim" have entirely different histories.

160 changed files with 7834 additions and 18787 deletions

View File

@ -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

View File

@ -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>
<!-- Preload critical fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Ensure proper icon rendering and layout -->
<style>
/* Ensure Lucide icons render properly */
svg {
display: inline-block;
vertical-align: middle;
}
/* Fix for icon alignment in buttons */
button svg {
flex-shrink: 0;
}
/* 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>
<head>
<meta charset="UTF-8" />
<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>
<!-- Preload essential fonts and icons -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -1,4 +0,0 @@
User-agent: *
Disallow: /api/
Sitemap: https://reflow.royalenfield.com/sitemap.xml

View File

@ -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>

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, useNavigate, Outlet } from 'react-router-dom'; import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
import { PageLayout } from '@/components/layout/PageLayout'; import { PageLayout } from '@/components/layout/PageLayout';
import { Dashboard } from '@/pages/Dashboard'; import { Dashboard } from '@/pages/Dashboard';
import { OpenRequests } from '@/pages/OpenRequests'; import { OpenRequests } from '@/pages/OpenRequests';
@ -10,7 +10,6 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
import { WorkNotes } from '@/pages/WorkNotes'; import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest'; import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard'; import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { MyRequests } from '@/pages/MyRequests'; import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests'; import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests'; import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -18,21 +17,16 @@ 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 { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
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 { TokenManager } from '@/utils/tokenManager';
interface AppProps { interface AppProps {
onLogout?: () => void; onLogout?: () => void;
@ -43,7 +37,7 @@ interface AppProps {
function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) { function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = hasManagementAccess(user); const isAdmin = hasManagementAccess(user);
// Render separate screens based on user role // Render separate screens based on user role
// Admin/Management users see all organization requests // Admin/Management users see all organization requests
// Regular users see only their participant requests (approver/spectator, NOT initiator) // Regular users see only their participant requests (approver/spectator, NOT initiator)
@ -54,43 +48,6 @@ function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) =
} }
} }
// Component to conditionally render Dashboard or DealerDashboard based on user job title
function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: string) => void; onNewRequest?: () => void }) {
const [isDealer, setIsDealer] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
useEffect(() => {
try {
const userData = TokenManager.getUserData();
setIsDealer(userData?.jobTitle === 'Dealer');
} catch (error) {
console.error('[App] Error checking dealer status:', error);
setIsDealer(false);
} finally {
setIsLoading(false);
}
}, []);
if (isLoading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center gap-4">
<div className="w-8 h-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
// Render dealer-specific dashboard if user is a dealer
if (isDealer) {
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
}
// Render regular dashboard for all other users
return <Dashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
}
// Main Application Routes Component // Main Application Routes Component
function AppRoutes({ onLogout }: AppProps) { function AppRoutes({ onLogout }: AppProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -152,11 +109,12 @@ function AppRoutes({ onLogout }: AppProps) {
} }
}; };
const handleViewRequest = (requestId: string, requestTitle?: string, status?: string, request?: any) => { const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string, request?: any) => {
setSelectedRequestId(requestId); setSelectedRequestId(requestId);
setSelectedRequestTitle(requestTitle || 'Unknown Request'); setSelectedRequestTitle(requestTitle || 'Unknown Request');
// Use global navigation utility for consistent routing // Use global navigation utility for consistent routing
const { navigateToRequest } = await import('@/utils/requestNavigation');
navigateToRequest({ navigateToRequest({
requestId, requestId,
requestTitle, requestTitle,
@ -183,18 +141,11 @@ function AppRoutes({ onLogout }: AppProps) {
} }
return; return;
} }
// If requestData has backendId, it means it came from the API flow (CreateRequest component) // Regular custom request submission
// The hook already shows the toast, so we just navigate
if (requestData.backendId) {
navigate('/my-requests');
return;
}
// 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 = {
id: requestId, id: requestId,
@ -222,21 +173,21 @@ function AppRoutes({ onLogout }: AppProps) {
avatar: 'CU' avatar: 'CU'
}, },
department: requestData.department || 'General', department: requestData.department || 'General',
createdAt: new Date().toLocaleDateString('en-US', { createdAt: new Date().toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
hour12: true hour12: true
}), }),
updatedAt: new Date().toLocaleDateString('en-US', { updatedAt: new Date().toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
hour12: true hour12: true
}), }),
dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
submittedDate: new Date().toISOString(), submittedDate: new Date().toISOString(),
@ -246,7 +197,7 @@ function AppRoutes({ onLogout }: AppProps) {
// Extract name from email if name is not available // Extract name from email if name is not available
const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`; const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`;
const approverEmail = approver?.email || ''; const approverEmail = approver?.email || '';
return { return {
step: index + 1, step: index + 1,
approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`, approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`,
@ -271,28 +222,32 @@ function AppRoutes({ onLogout }: AppProps) {
}; };
}), }),
auditTrail: [ auditTrail: [
{ {
type: 'created', type: 'created',
action: 'Request Created', action: 'Request Created',
details: `Custom request "${requestData.title}" created`, details: `Custom request "${requestData.title}" created`,
user: 'Current User', user: 'Current User',
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }) timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
}, },
{ {
type: 'assignment', type: 'assignment',
action: 'Assigned to Approver', action: 'Assigned to Approver',
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`, details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`,
user: 'System', user: 'System',
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }) timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
} }
], ],
tags: requestData.tags || ['custom-request'] tags: requestData.tags || ['custom-request']
}; };
// Add to dynamic requests // Add to dynamic requests
setDynamicRequests([...dynamicRequests, newCustomRequest]); setDynamicRequests([...dynamicRequests, newCustomRequest]);
navigate('/my-requests'); navigate('/my-requests');
toast.success('Request Submitted Successfully!', {
description: `Your request "${requestData.title}" (${requestId}) has been created and sent for approval.`,
duration: 5000,
});
}; };
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => { const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
@ -309,7 +264,7 @@ function AppRoutes({ onLogout }: AppProps) {
duration: 5000, duration: 5000,
}); });
} }
setApprovalAction(null); setApprovalAction(null);
resolve(true); resolve(true);
}, 1000); }, 1000);
@ -342,7 +297,7 @@ function AppRoutes({ onLogout }: AppProps) {
// Call API to create claim request // Call API to create claim request
const response = await createClaimRequest(payload); const response = await createClaimRequest(payload);
// Validate response - ensure request was actually created successfully // Validate response - ensure request was actually created successfully
if (!response || !response.request) { if (!response || !response.request) {
throw new Error('Invalid response from server: Request object not found'); throw new Error('Invalid response from server: Request object not found');
@ -376,11 +331,11 @@ function AppRoutes({ onLogout }: AppProps) {
} }
} catch (error: any) { } catch (error: any) {
console.error('[App] Error creating claim request:', error); console.error('[App] Error creating claim request:', error);
// Check for manager-related errors // Check for manager-related errors
const errorData = error?.response?.data; const errorData = error?.response?.data;
const errorCode = errorData?.code || errorData?.error?.code; const errorCode = errorData?.code || errorData?.error?.code;
if (errorCode === 'NO_MANAGER_FOUND') { if (errorCode === 'NO_MANAGER_FOUND') {
// Show modal for no manager found // Show modal for no manager found
setManagerModalData({ setManagerModalData({
@ -391,7 +346,7 @@ function AppRoutes({ onLogout }: AppProps) {
setManagerModalOpen(true); setManagerModalOpen(true);
return; return;
} }
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') { if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
// Show modal with manager list for selection // Show modal with manager list for selection
const managers = errorData?.managers || errorData?.error?.managers || []; const managers = errorData?.managers || errorData?.error?.managers || [];
@ -404,171 +359,343 @@ function AppRoutes({ onLogout }: AppProps) {
setManagerModalOpen(true); setManagerModalOpen(true);
return; return;
} }
// Other errors - show toast // Other errors - show toast
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request'; const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
toast.error('Failed to Submit Claim Request', { toast.error('Failed to Submit Claim Request', {
description: errorMessage, description: errorMessage,
}); });
} }
// 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 (
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background"> <div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes> <Routes>
{/* Auth Callback - Unified callback for both OKTA and Tanflow */} {/* Auth Callback - Must be before other routes */}
<Route <Route
path="/login/callback" path="/login/callback"
element={<AuthCallback />} element={<AuthCallback />}
/> />
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */} {/* Dashboard */}
<Route <Route
path="/" path="/"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </PageLayout>
} }
/> />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </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 />
}
/>
{/* Open Requests */} {/* Open Requests */}
<Route <Route
path="/open-requests" path="/open-requests"
element={ element={
<PageLayout currentPage="open-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="open-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<OpenRequests onViewRequest={handleViewRequest} /> <OpenRequests onViewRequest={handleViewRequest} />
</PageLayout> </PageLayout>
} }
/> />
{/* Closed Requests */} {/* Closed Requests */}
<Route <Route
path="/closed-requests" path="/closed-requests"
element={ element={
<PageLayout currentPage="closed-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="closed-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<ClosedRequests onViewRequest={handleViewRequest} /> <ClosedRequests onViewRequest={handleViewRequest} />
</PageLayout> </PageLayout>
} }
/> />
{/* Shared Summaries */} {/* Shared Summaries */}
<Route <Route
path="/shared-summaries" path="/shared-summaries"
element={ element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaries /> <SharedSummaries />
</PageLayout> </PageLayout>
} }
/> />
{/* Shared Summary Detail */} {/* Shared Summary Detail */}
<Route <Route
path="/shared-summaries/:sharedSummaryId" path="/shared-summaries/:sharedSummaryId"
element={ element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaryDetail /> <SharedSummaryDetail />
</PageLayout> </PageLayout>
} }
/> />
{/* My Requests */} {/* My Requests */}
<Route <Route
path="/my-requests" path="/my-requests"
element={ element={
<PageLayout currentPage="my-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="my-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<MyRequests onViewRequest={handleViewRequest} dynamicRequests={dynamicRequests} /> <MyRequests onViewRequest={handleViewRequest} dynamicRequests={dynamicRequests} />
</PageLayout> </PageLayout>
} }
/> />
{/* Requests - Separate screens for Admin and Regular Users */} {/* Requests - Separate screens for Admin and Regular Users */}
<Route <Route
path="/requests" path="/requests"
element={ element={
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<RequestsRoute onViewRequest={handleViewRequest} /> <RequestsRoute onViewRequest={handleViewRequest} />
</PageLayout> </PageLayout>
} }
/> />
{/* Approver Performance - Detailed Performance Analysis */} {/* Approver Performance - Detailed Performance Analysis */}
<Route <Route
path="/approver-performance" path="/approver-performance"
element={ element={
<PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<ApproverPerformance /> <ApproverPerformance />
</PageLayout> </PageLayout>
} }
/> />
{/* Request Detail - requestId will be read from URL params */} {/* Request Detail - requestId will be read from URL params */}
<Route <Route
path="/request/:requestId" path="/request/:requestId"
element={ element={
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<RequestDetail <RequestDetail
requestId="" requestId=""
onBack={handleBack} onBack={handleBack}
dynamicRequests={dynamicRequests} dynamicRequests={dynamicRequests}
/> />
</PageLayout> </PageLayout>
} }
/> />
{/* Work Notes - Dedicated Full-Screen Page */} {/* Work Notes - Dedicated Full-Screen Page */}
<Route <Route
path="/work-notes/:requestId" path="/work-notes/:requestId"
element={<WorkNotes />} element={<WorkNotes />}
/> />
{/* New Request (Custom) */} {/* New Request (Custom) */}
<Route <Route
path="/new-request" path="/new-request"
element={ element={
<CreateRequest <CreateRequest
onBack={handleBack} onBack={handleBack}
onSubmit={handleNewRequestSubmit} onSubmit={handleNewRequestSubmit}
/> />
} }
/> />
{/* Edit Draft Request */} {/* Edit Draft Request */}
<Route <Route
path="/edit-request/:requestId" path="/edit-request/:requestId"
element={ element={
<CreateRequest <CreateRequest
onBack={handleBack} onBack={handleBack}
@ -576,76 +703,72 @@ function AppRoutes({ onLogout }: AppProps) {
requestId={undefined} // Will be read from URL params requestId={undefined} // Will be read from URL params
isEditMode={true} isEditMode={true}
/> />
} }
/> />
{/* Claim Management Wizard */} {/* Claim Management Wizard */}
<Route <Route
path="/claim-management" path="/claim-management"
element={ element={
<ClaimManagementWizard <ClaimManagementWizard
onBack={handleBack} onBack={handleBack}
onSubmit={handleClaimManagementSubmit} onSubmit={handleClaimManagementSubmit}
/> />
} }
/> />
{/* Profile */} {/* Profile */}
<Route <Route
path="/profile" path="/profile"
element={ element={
<PageLayout currentPage="profile" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="profile" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Profile /> <Profile />
</PageLayout> </PageLayout>
} }
/> />
{/* Settings */} {/* Settings */}
<Route <Route
path="/settings" path="/settings"
element={ element={
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Settings /> <Settings />
</PageLayout> </PageLayout>
} }
/>
{/* 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"
element={ element={
<PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Notifications onNavigate={handleNavigate} /> <Notifications onNavigate={handleNavigate} />
</PageLayout> </PageLayout>
} }
/> />
{/* Detailed Reports */} {/* Detailed Reports */}
<Route <Route
path="/detailed-reports" path="/detailed-reports"
element={ element={
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DetailedReports /> <DetailedReports />
</PageLayout> </PageLayout>
} }
/> />
{/* Admin Control Panel */}
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
</Routes> </Routes>
<Toaster <Toaster
position="top-right" position="top-right"
toastOptions={{ toastOptions={{
style: { style: {
@ -699,7 +822,7 @@ interface MainAppProps {
export default function App(props?: MainAppProps) { export default function App(props?: MainAppProps) {
const { onLogout } = props || {}; const { onLogout } = props || {};
return ( return (
<BrowserRouter> <BrowserRouter>
<AppRoutes onLogout={onLogout} /> <AppRoutes onLogout={onLogout} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -8,7 +8,6 @@
// Images // Images
export { default as ReLogo } from './images/Re_Logo.png'; export { default as ReLogo } from './images/Re_Logo.png';
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png'; export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
export { default as LandingPageImage } from './images/landing_page_image.jpg';
// Fonts // Fonts
// Add font exports here when fonts are added to the assets/fonts folder // Add font exports here when fonts are added to the assets/fonts folder

View File

@ -1,464 +0,0 @@
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
FileText,
Plus,
Trash2,
Edit2,
Loader2,
AlertCircle,
CheckCircle,
} from 'lucide-react';
import {
getAllActivityTypes,
createActivityType,
updateActivityType,
deleteActivityType,
ActivityType
} from '@/services/adminApi';
import { toast } from 'sonner';
export function ActivityTypeManager() {
const [activityTypes, setActivityTypes] = useState<ActivityType[]>([]);
const [loading, setLoading] = useState(true);
const [showAddDialog, setShowAddDialog] = useState(false);
const [editingActivityType, setEditingActivityType] = useState<ActivityType | null>(null);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [formData, setFormData] = useState({
title: '',
itemCode: '',
taxationType: '',
sapRefNo: ''
});
useEffect(() => {
loadActivityTypes();
}, []);
const loadActivityTypes = async () => {
try {
setLoading(true);
setError(null);
const data = await getAllActivityTypes(false); // Get all including inactive
setActivityTypes(data);
} catch (err: any) {
const errorMsg = err.response?.data?.error || 'Failed to load activity types';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setLoading(false);
}
};
const handleAdd = () => {
setFormData({
title: '',
itemCode: '',
taxationType: '',
sapRefNo: ''
});
setEditingActivityType(null);
setShowAddDialog(true);
};
const handleEdit = (activityType: ActivityType) => {
setFormData({
title: activityType.title,
itemCode: activityType.itemCode || '',
taxationType: activityType.taxationType || '',
sapRefNo: activityType.sapRefNo || ''
});
setEditingActivityType(activityType);
setShowAddDialog(true);
};
const handleSave = async () => {
try {
setError(null);
if (!formData.title.trim() || !formData.taxationType.trim() || !formData.sapRefNo.trim()) {
setError('Title, Taxation Type, and Claim Document Type (SAP Ref) are required');
toast.error('Please fill in all mandatory fields');
return;
}
const payload: Partial<ActivityType> = {
title: formData.title.trim(),
itemCode: formData.itemCode.trim() || null,
taxationType: formData.taxationType.trim(),
sapRefNo: formData.sapRefNo.trim()
};
if (editingActivityType) {
// Update existing
await updateActivityType(editingActivityType.activityTypeId, payload);
setSuccessMessage('Activity type updated successfully');
toast.success('Activity type updated successfully');
} else {
// Create new
await createActivityType(payload);
setSuccessMessage('Activity type created successfully');
toast.success('Activity type created successfully');
}
await loadActivityTypes();
setShowAddDialog(false);
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
const errorMsg = err.response?.data?.error || 'Failed to save activity type';
setError(errorMsg);
toast.error(errorMsg);
}
};
const handleDelete = async (activityType: ActivityType) => {
if (!confirm(`Delete "${activityType.title}"? This will deactivate the activity type.`)) {
return;
}
try {
setError(null);
await deleteActivityType(activityType.activityTypeId);
setSuccessMessage('Activity type deleted successfully');
toast.success('Activity type deleted successfully');
await loadActivityTypes();
setTimeout(() => setSuccessMessage(null), 3000);
} catch (err: any) {
const errorMsg = err.response?.data?.error || 'Failed to delete activity type';
setError(errorMsg);
toast.error(errorMsg);
}
};
// Filter active and inactive activity types
const activeActivityTypes = activityTypes.filter(at => at.isActive !== false && at.isActive !== undefined);
const inactiveActivityTypes = activityTypes.filter(at => at.isActive === false);
return (
<div className="space-y-6">
{/* Success Message */}
{successMessage && (
<div className="p-4 bg-gradient-to-r from-green-50 to-emerald-50 border border-green-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="p-1.5 bg-green-500 rounded-md">
<CheckCircle className="w-4 h-4 text-white shrink-0" />
</div>
<p className="text-sm font-medium text-green-900">{successMessage}</p>
</div>
)}
{/* Error Message */}
{error && (
<div className="p-4 bg-gradient-to-r from-red-50 to-rose-50 border border-red-300 rounded-md shadow-sm flex items-center gap-3 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="p-1.5 bg-red-500 rounded-md">
<AlertCircle className="w-4 h-4 text-white shrink-0" />
</div>
<p className="text-sm font-medium text-red-900">{error}</p>
<Button
size="sm"
variant="ghost"
onClick={() => setError(null)}
className="ml-auto hover:bg-red-100"
>
Dismiss
</Button>
</div>
)}
{/* Header */}
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg sm:text-xl font-semibold text-slate-900">Activity Types</CardTitle>
<CardDescription className="text-sm">
Manage dealer claim activity types
</CardDescription>
</div>
</div>
<Button
onClick={handleAdd}
className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm"
>
<Plus className="w-4 h-4" />
<span className="hidden xs:inline">Add Activity Type</span>
<span className="xs:hidden">Add</span>
</Button>
</div>
</CardHeader>
</Card>
{/* Activity Types List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
) : activeActivityTypes.length === 0 ? (
<Card className="shadow-lg border-0 rounded-md">
<CardContent className="p-12 text-center">
<div className="p-4 bg-slate-100 rounded-full w-20 h-20 flex items-center justify-center mx-auto mb-4">
<FileText className="w-10 h-10 text-slate-400" />
</div>
<p className="text-slate-700 font-medium text-lg">No activity types found</p>
<p className="text-sm text-slate-500 mt-2 mb-6">Add activity types for dealer claim management</p>
<Button
onClick={handleAdd}
variant="outline"
className="gap-2 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
>
<Plus className="w-4 h-4" />
Add First Activity Type
</Button>
</CardContent>
</Card>
) : (
<div className="space-y-4 sm:space-y-6">
{/* Active Activity Types */}
<Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-3 sm:pb-4 border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">Active Activity Types</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeActivityTypes.length} active type{activeActivityTypes.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<div className="p-2 bg-green-50 rounded-md">
<CheckCircle className="w-4 h-4 text-green-600" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-4">
{activeActivityTypes.map(activityType => (
<div
key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 hover:border-slate-300 transition-all shadow-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<p className="font-semibold text-slate-900 text-sm sm:text-base">{activityType.title}</p>
<Badge variant="outline" className="bg-gradient-to-r from-green-50 to-emerald-50 text-green-800 border-green-300 text-[10px] sm:text-xs font-medium shadow-sm">
Active
</Badge>
</div>
<div className="flex flex-wrap gap-3 text-xs sm:text-sm text-slate-600">
{activityType.itemCode && (
<span className="font-medium">Item Code: <span className="text-slate-900">{activityType.itemCode}</span></span>
)}
{activityType.taxationType && (
<span className="font-medium">Taxation: <span className="text-slate-900">{activityType.taxationType}</span></span>
)}
{activityType.sapRefNo && (
<span className="font-medium">SAP Ref: <span className="text-slate-900">{activityType.sapRefNo}</span></span>
)}
{!activityType.itemCode && !activityType.taxationType && !activityType.sapRefNo && (
<span className="text-slate-500 italic">No additional details</span>
)}
</div>
</div>
<div className="flex items-center gap-2 sm:gap-2 self-end sm:self-auto">
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(activityType)}
className="gap-1.5 hover:bg-blue-50 border border-transparent hover:border-blue-200 text-xs sm:text-sm"
>
<Edit2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Edit</span>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(activityType)}
className="gap-1.5 text-red-600 hover:text-red-700 hover:bg-red-50 border border-transparent hover:border-red-200 text-xs sm:text-sm"
>
<Trash2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Delete</span>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
{/* Inactive Activity Types */}
{inactiveActivityTypes.length > 0 && (
<Card className="shadow-lg border-0 rounded-md border-amber-200">
<CardHeader className="pb-3 sm:pb-4 border-b border-slate-100">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base sm:text-lg font-semibold text-slate-900">Inactive Activity Types</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{inactiveActivityTypes.length} inactive type{inactiveActivityTypes.length !== 1 ? 's' : ''}
</CardDescription>
</div>
<div className="p-2 bg-amber-50 rounded-md">
<AlertCircle className="w-4 h-4 text-amber-600" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-4">
{inactiveActivityTypes.map(activityType => (
<div
key={activityType.activityTypeId}
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4 p-3 sm:p-4 bg-amber-50/50 border border-amber-200 rounded-md hover:bg-amber-50 hover:border-amber-300 transition-all shadow-sm"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<p className="font-semibold text-slate-700 text-sm sm:text-base line-through">{activityType.title}</p>
<Badge variant="outline" className="bg-gradient-to-r from-amber-50 to-orange-50 text-amber-800 border-amber-300 text-[10px] sm:text-xs font-medium shadow-sm">
Inactive
</Badge>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-2 self-end sm:self-auto">
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(activityType)}
className="gap-1.5 hover:bg-blue-50 border border-transparent hover:border-blue-200 text-xs sm:text-sm"
>
<Edit2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden xs:inline">Edit</span>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
{/* Add/Edit Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="sm:max-w-[550px] max-h-[90vh] rounded-lg flex flex-col p-0">
<DialogHeader className="pb-4 border-b border-slate-100 px-6 pt-6 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
<FileText className="w-5 h-5 text-white" />
</div>
<div className="flex-1">
<DialogTitle className="text-xl font-semibold text-slate-900">
{editingActivityType ? 'Edit Activity Type' : 'Add New Activity Type'}
</DialogTitle>
<DialogDescription className="text-sm text-slate-600 mt-1">
{editingActivityType ? 'Update activity type information' : 'Add a new activity type for dealer claim management'}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-5 py-6 px-6 overflow-y-auto flex-1 min-h-0">
{/* Title Field */}
<div className="space-y-2">
<Label htmlFor="title" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Title <span className="text-red-500">*</span>
</Label>
<Input
id="title"
placeholder="e.g., Riders Mania Claims, Legal Claims Reimbursement"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: 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"
/>
<p className="text-xs text-slate-500">Enter the activity type title</p>
</div>
{/* Item Code Field */}
<div className="space-y-2">
<Label htmlFor="itemCode" className="text-sm font-semibold text-slate-900">
Item Code <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Input
id="itemCode"
placeholder="e.g., 1, 2, 3"
value={formData.itemCode}
onChange={(e) => setFormData({ ...formData, itemCode: 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"
/>
<p className="text-xs text-slate-500">Optional item code for the activity type</p>
</div>
{/* Taxation Type Field */}
<div className="space-y-2">
<Label htmlFor="taxationType" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Taxation Type <span className="text-red-500">*</span>
</Label>
<Select
value={formData.taxationType}
onValueChange={(value) => setFormData({ ...formData, taxationType: value })}
>
<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" />
</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>
{/* SAP Reference Number Field */}
<div className="space-y-2">
<Label htmlFor="sapRefNo" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Claim Document Type (SAP Ref) <span className="text-red-500">*</span>
</Label>
<Input
id="sapRefNo"
placeholder="e.g., ZCNS, ZRE"
value={formData.sapRefNo}
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"
/>
<p className="text-xs text-slate-500">Required SAP reference number for CSV generation</p>
</div>
</div>
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
<Button
variant="outline"
onClick={() => setShowAddDialog(false)}
className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
>
Cancel
</Button>
<Button
onClick={handleSave}
disabled={!formData.title.trim() || !formData.taxationType || !formData.sapRefNo.trim()}
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<FileText className="w-4 h-4 mr-2" />
{editingActivityType ? 'Update Activity Type' : 'Add Activity Type'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
export { ConfigurationManager } from './ConfigurationManager'; export { ConfigurationManager } from './ConfigurationManager';
export { HolidayManager } from './HolidayManager'; export { HolidayManager } from './HolidayManager';
export { ActivityTypeManager } from './ActivityTypeManager';

View File

@ -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;

View File

@ -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;
@ -16,26 +15,25 @@ interface FormattedDescriptionProps {
export function FormattedDescription({ content, className }: FormattedDescriptionProps) { export function FormattedDescription({ content, className }: FormattedDescriptionProps) {
const processedContent = React.useMemo(() => { const processedContent = React.useMemo(() => {
if (!content) return ''; if (!content) return '';
// Wrap tables that aren't already wrapped in a scrollable container using regex // Wrap tables that aren't already wrapped in a scrollable container using regex
// Match <table> tags that aren't already inside a .table-wrapper // Match <table> tags that aren't already inside a .table-wrapper
let processed = content; let processed = content;
// Pattern to match table tags that aren't already wrapped // Pattern to match table tags that aren't already wrapped
const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi; const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi;
processed = processed.replace(tablePattern, (match) => { processed = processed.replace(tablePattern, (match) => {
// Check if this table is already wrapped // Check if this table is already wrapped
if (match.includes('table-wrapper')) { if (match.includes('table-wrapper')) {
return match; return match;
} }
// 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;

View File

@ -19,7 +19,6 @@ import { ReLogo } from '@/assets';
import notificationApi, { Notification } from '@/services/notificationApi'; import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { TokenManager } from '@/utils/tokenManager';
interface PageLayoutProps { interface PageLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -36,18 +35,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
// Check if user is a Dealer
const isDealer = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return userData?.jobTitle === 'Dealer';
} catch (error) {
console.error('[PageLayout] Error checking dealer status:', error);
return false;
}
}, []);
// Get user initials for avatar // Get user initials for avatar
const getUserInitials = () => { const getUserInitials = () => {
try { try {
@ -67,28 +55,24 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
return 'U'; return 'U';
} }
}; };
const menuItems = useMemo(() => { const menuItems = useMemo(() => {
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 },
]; ];
// Add remaining menu items (exclude "My Requests" for dealers) // Add remaining menu items
if (!isDealer) {
items.push({ id: 'my-requests', label: 'My Requests', icon: User });
}
items.push( items.push(
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText }, { id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle }, { id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 } { id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
); );
return items; return items;
}, [isDealer]); }, []);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen); setSidebarOpen(!sidebarOpen);
@ -99,7 +83,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Mark as read // Mark as read
if (!notification.isRead) { if (!notification.isRead) {
await notificationApi.markAsRead(notification.notificationId); await notificationApi.markAsRead(notification.notificationId);
setNotifications(prev => setNotifications(prev =>
prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n) prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n)
); );
setUnreadCount(prev => Math.max(0, prev - 1)); setUnreadCount(prev => Math.max(0, prev - 1));
@ -112,14 +96,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
if (requestNumber) { if (requestNumber) {
// Determine which tab to open based on notification type // Determine which tab to open based on notification type
let navigationUrl = `request/${requestNumber}`; let navigationUrl = `request/${requestNumber}`;
// Work note related notifications should open Work Notes tab // Work note related notifications should open Work Notes tab
if (notification.notificationType === 'mention' || if (notification.notificationType === 'mention' ||
notification.notificationType === 'comment' || notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') { notification.notificationType === 'worknote') {
navigationUrl += '?tab=worknotes'; navigationUrl += '?tab=worknotes';
} }
// Navigate to request detail page // Navigate to request detail page
onNavigate(navigationUrl); onNavigate(navigationUrl);
} }
@ -153,7 +137,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
try { try {
const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false }); const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false });
if (!mounted) return; if (!mounted) return;
const notifs = result.data?.notifications || []; const notifs = result.data?.notifications || [];
setNotifications(notifs); setNotifications(notifs);
setUnreadCount(result.data?.unreadCount || 0); setUnreadCount(result.data?.unreadCount || 0);
@ -174,7 +158,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Listen for new notifications // Listen for new notifications
const handleNewNotification = (data: { notification: Notification }) => { const handleNewNotification = (data: { notification: Notification }) => {
if (!mounted) return; if (!mounted) return;
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
setUnreadCount(prev => prev + 1); setUnreadCount(prev => prev + 1);
}; };
@ -215,7 +199,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<div className="min-h-screen flex w-full bg-background"> <div className="min-h-screen flex w-full bg-background">
{/* Mobile Overlay */} {/* Mobile Overlay */}
{sidebarOpen && ( {sidebarOpen && (
<div <div
className="fixed inset-0 bg-black/50 z-40 md:hidden" className="fixed inset-0 bg-black/50 z-40 md:hidden"
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
/> />
@ -239,9 +223,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}> <div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
<div className="p-4 border-b border-gray-800 flex-shrink-0"> <div className="p-4 border-b border-gray-800 flex-shrink-0">
<div className="flex flex-col items-center justify-center"> <div className="flex flex-col items-center justify-center">
<img <img
src={ReLogo} src={ReLogo}
alt="Royal Enfield Logo" alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain" className="h-10 w-auto max-w-[168px] object-contain"
/> />
<p className="text-xs text-gray-400 text-center mt-1 truncate">RE Flow</p> <p className="text-xs text-gray-400 text-center mt-1 truncate">RE Flow</p>
@ -249,44 +233,39 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div> </div>
<div className="p-3 flex-1 overflow-y-auto"> <div className="p-3 flex-1 overflow-y-auto">
<div className="space-y-2"> <div className="space-y-2">
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => ( {menuItems.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => {
if (item.id === 'admin/templates') { onNavigate?.(item.id);
onNavigate?.('admin/templates');
} else {
onNavigate?.(item.id);
}
// Close sidebar on mobile after navigation // Close sidebar on mobile after navigation
if (window.innerWidth < 768) { if (window.innerWidth < 768) {
setSidebarOpen(false); setSidebarOpen(false);
} }
}} }}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
? 'bg-re-green text-white font-medium' currentPage === item.id
: 'text-gray-300 hover:bg-gray-900 hover:text-white' ? 'bg-re-green text-white font-medium'
}`} : 'text-gray-300 hover:bg-gray-900 hover:text-white'
}`}
> >
<item.icon className="w-4 h-4 shrink-0" /> <item.icon className="w-4 h-4 shrink-0" />
<span className="truncate">{item.label}</span> <span className="truncate">{item.label}</span>
</button> </button>
))} ))}
</div> </div>
{/* Quick Action in Sidebar - Right below menu items */} {/* Quick Action in Sidebar - Right below menu items */}
{!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>
@ -296,14 +275,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{/* Header */} {/* Header */}
<header className="h-16 border-b border-gray-200 bg-white flex items-center justify-between px-6 shrink-0"> <header className="h-16 border-b border-gray-200 bg-white flex items-center justify-between px-6 shrink-0">
<div className="flex items-center gap-4 min-w-0 flex-1"> <div className="flex items-center gap-4 min-w-0 flex-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={toggleSidebar} onClick={toggleSidebar}
className="shrink-0 h-10 w-10 sidebar-toggle" className="shrink-0 h-10 w-10 sidebar-toggle"
> >
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />} {sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
</Button> </Button>
{/* Search bar commented out */} {/* Search bar commented out */}
{/* <div className="relative max-w-md flex-1"> {/* <div className="relative max-w-md flex-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" />
@ -315,16 +294,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div> </div>
<div className="flex items-center gap-4 shrink-0"> <div className="flex items-center gap-4 shrink-0">
{!isDealer && ( <Button
<Button onClick={onNewRequest}
onClick={onNewRequest} className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm" size="sm"
size="sm" >
> <Plus className="w-4 h-4" />
<Plus className="w-4 h-4" /> New Request
New Request </Button>
</Button>
)}
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}> <DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -365,8 +342,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{notifications.map((notif) => ( {notifications.map((notif) => (
<div <div
key={notif.notificationId} key={notif.notificationId}
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${!notif.isRead ? 'bg-blue-50' : '' className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${
}`} !notif.isRead ? 'bg-blue-50' : ''
}`}
onClick={() => handleNotificationClick(notif)} onClick={() => handleNotificationClick(notif)}
> >
<div className="flex gap-2"> <div className="flex gap-2">
@ -425,8 +403,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<Settings className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => setShowLogoutDialog(true)} onClick={() => setShowLogoutDialog(true)}
className="text-red-600 focus:text-red-600" className="text-red-600 focus:text-red-600"
> >
<LogOut className="w-4 h-4 mr-2" /> <LogOut className="w-4 h-4 mr-2" />

View File

@ -12,12 +12,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Calendar } from '../ui/calendar'; import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Calendar as CalendarIcon, Calendar as CalendarIcon,
Upload, Upload,
X, X,
FileText, FileText,
Check, Check,
Users Users
@ -150,7 +150,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
onChange={(e) => updateFormData('title', e.target.value)} onChange={(e) => updateFormData('title', e.target.value)}
/> />
</div> </div>
<div> <div>
<Label htmlFor="description">Description *</Label> <Label htmlFor="description">Description *</Label>
<Textarea <Textarea
@ -183,7 +183,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left"> <Button variant="outline" className="w-full justify-start text-left">
<CalendarIcon className="mr-2 h-4 w-4" /> <CalendarIcon className="mr-2 h-4 w-4" />
{formData.slaEndDate ? format(formData.slaEndDate, 'd MMM yyyy') : 'Pick a date'} {formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Pick a date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0"> <PopoverContent className="w-auto p-0">
@ -215,9 +215,9 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label> <Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
</div> </div>
</div> </div>
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground"> <div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
{formData.workflowType === 'sequential' {formData.workflowType === 'sequential'
? 'Approvers will review the request one after another in the order you specify.' ? 'Approvers will review the request one after another in the order you specify.'
: 'All approvers will review the request simultaneously.' : 'All approvers will review the request simultaneously.'
} }
@ -311,7 +311,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableUsers {availableUsers
.filter(user => .filter(user =>
!formData.spectators.find(s => s.id === user.id) && !formData.spectators.find(s => s.id === user.id) &&
!formData.approvers.find(a => a.id === user.id) !formData.approvers.find(a => a.id === user.id)
) )
@ -378,7 +378,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center"> <div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> <Upload className="h-8 w-8 mx-auto mb-2 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"

View File

@ -1,23 +1,21 @@
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { import {
Receipt, Receipt,
Package, Package,
ArrowRight, ArrowRight,
ArrowLeft,
Clock, Clock,
CheckCircle, CheckCircle,
Target, Target,
X,
Sparkles, Sparkles,
Check, Check
AlertCircle
} from 'lucide-react'; } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { TokenManager } from '../../utils/tokenManager';
interface TemplateSelectionModalProps { interface TemplateSelectionModalProps {
open: boolean; open: boolean;
@ -41,8 +39,7 @@ const AVAILABLE_TEMPLATES = [
'Document verification', 'Document verification',
'E-invoice generation', 'E-invoice generation',
'Credit note issuance' 'Credit note issuance'
], ]
disabled: false
}, },
{ {
id: 'vendor-payment', id: 'vendor-payment',
@ -58,32 +55,14 @@ const AVAILABLE_TEMPLATES = [
'Invoice verification', 'Invoice verification',
'Multi-level approvals', 'Multi-level approvals',
'Payment scheduling' 'Payment scheduling'
], ]
disabled: true,
comingSoon: true
} }
]; ];
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) { export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null); const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
const [isDealer, setIsDealer] = useState(false);
// Check if user is a Dealer
useEffect(() => {
const userData = TokenManager.getUserData();
setIsDealer(userData?.jobTitle === 'Dealer');
}, []);
const handleSelect = (templateId: string) => { const handleSelect = (templateId: string) => {
// Don't allow selection if user is a dealer
if (isDealer) {
return;
}
// Don't allow selection if template is disabled
const template = AVAILABLE_TEMPLATES.find(t => t.id === templateId);
if (template?.disabled) {
return;
}
setSelectedTemplate(templateId); setSelectedTemplate(templateId);
}; };
@ -96,7 +75,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent <DialogContent
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0" className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
> >
{/* Accessibility - Hidden Title and Description */} {/* Accessibility - Hidden Title and Description */}
@ -105,20 +84,19 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
Choose from pre-configured templates with predefined workflows and approval chains for faster processing. Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
</DialogDescription> </DialogDescription>
{/* Back arrow button - Top left */} {/* Custom Close button */}
<button <button
onClick={onClose} onClick={onClose}
className="!flex absolute top-6 left-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 items-center justify-center transition-all hover:scale-110" className="absolute top-6 right-6 z-50 w-10 h-10 rounded-full bg-white shadow-lg hover:shadow-xl border border-gray-200 flex items-center justify-center transition-all hover:scale-110"
aria-label="Go back"
> >
<ArrowLeft className="w-5 h-5 text-gray-600" /> <X className="w-5 h-5 text-gray-600" />
</button> </button>
{/* Full Screen Content Container */} {/* Full Screen Content Container */}
<div className="h-full overflow-y-auto"> <div className="h-full overflow-y-auto">
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12"> <div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
{/* Header Section */} {/* Header Section */}
<motion.div <motion.div
initial={{ opacity: 0, y: -20 }} initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
className="text-center mb-12 max-w-3xl" className="text-center mb-12 max-w-3xl"
@ -139,7 +117,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{AVAILABLE_TEMPLATES.map((template, index) => { {AVAILABLE_TEMPLATES.map((template, index) => {
const Icon = template.icon; const Icon = template.icon;
const isSelected = selectedTemplate === template.id; const isSelected = selectedTemplate === template.id;
const isDisabled = isDealer || template.disabled;
return ( return (
<motion.div <motion.div
@ -147,16 +124,15 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }} transition={{ delay: index * 0.1 }}
whileHover={isDisabled ? {} : { scale: 1.03 }} whileHover={{ scale: 1.03 }}
whileTap={isDisabled ? {} : { scale: 0.98 }} whileTap={{ scale: 0.98 }}
> >
<Card <Card
className={`h-full transition-all duration-300 border-2 ${isDisabled className={`cursor-pointer h-full transition-all duration-300 border-2 ${
? 'opacity-50 cursor-not-allowed border-gray-200' isSelected
: isSelected ? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200' : 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg' }`}
}`}
onClick={() => handleSelect(template.id)} onClick={() => handleSelect(template.id)}
> >
<CardHeader className="space-y-4 pb-4"> <CardHeader className="space-y-4 pb-4">
@ -181,22 +157,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
<CardDescription className="text-sm leading-relaxed"> <CardDescription className="text-sm leading-relaxed">
{template.description} {template.description}
</CardDescription> </CardDescription>
{isDealer && (
<div className="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
Not accessible for Dealers
</p>
</div>
)}
{template.comingSoon && !isDealer && (
<div className="mt-3 flex items-start gap-2 p-2 bg-blue-50 border border-blue-200 rounded-lg">
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-blue-800 font-semibold">
Coming Soon
</p>
</div>
)}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="pt-0 space-y-4"> <CardContent className="pt-0 space-y-4">
@ -205,9 +165,9 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{template.category} {template.category}
</Badge> </Badge>
</div> </div>
<Separator /> <Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500"> <div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> <Clock className="w-3.5 h-3.5" />
@ -243,28 +203,29 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }} transition={{ delay: 0.3 }}
className="flex flex-col sm:flex-row justify-center gap-4 mt-4" className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
> >
<Button <Button
variant="outline" variant="outline"
onClick={onClose} onClick={onClose}
size="lg" size="lg"
className="px-8" className="px-8"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleContinue} onClick={handleContinue}
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled} disabled={!selectedTemplate}
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
: 'bg-gray-400 cursor-not-allowed' ? 'bg-blue-600 hover:bg-blue-700'
}`} : 'bg-gray-400'
}`}
> >
Continue with Template Continue with Template
<ArrowRight className="w-4 h-4" /> <ArrowRight className="w-4 h-4" />

View File

@ -1,19 +1,18 @@
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';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '../ui/scroll-area';
import { import {
Send, Send,
Smile, Smile,
Paperclip, Paperclip,
Users, Users,
FileText, FileText,
Download, Download,
Eye, Eye,
MoreHorizontal MoreHorizontal
} from 'lucide-react'; } from 'lucide-react';
@ -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 (
@ -189,7 +187,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<TabsTrigger value="chat">Chat</TabsTrigger> <TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger> <TabsTrigger value="media">Media</TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="chat" className="flex-1 flex flex-col"> <TabsContent value="chat" className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-4 border rounded-lg"> <ScrollArea className="flex-1 p-4 border rounded-lg">
<div className="space-y-4"> <div className="space-y-4">
@ -197,15 +195,16 @@ 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>
)} )}
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}> <div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
{msg.isSystem ? ( {msg.isSystem ? (
<div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground"> <div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground">
@ -223,7 +222,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
{msg.timestamp} {msg.timestamp}
</span> </span>
</div> </div>
<div <div
className="text-sm bg-muted/30 p-3 rounded-lg" className="text-sm bg-muted/30 p-3 rounded-lg"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/> />
@ -301,14 +300,15 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<h4 className="font-medium">Participants</h4> <h4 className="font-medium">Participants</h4>
<Badge variant="outline">{participants.length}</Badge> <Badge variant="outline">{participants.length}</Badge>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{participants.map((participant, index) => ( {participants.map((participant, index) => (
<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>

View File

@ -24,8 +24,6 @@ interface AddApproverModalProps {
requestTitle?: string; requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>; existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
currentLevels?: ApprovalLevelInfo[]; // Current approval levels currentLevels?: ApprovalLevelInfo[]; // Current approval levels
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
export function AddApproverModal({ export function AddApproverModal({
@ -33,9 +31,7 @@ export function AddApproverModal({
onClose, onClose,
onConfirm, onConfirm,
existingParticipants = [], existingParticipants = [],
currentLevels = [], currentLevels = []
maxApprovalLevels,
onPolicyViolation
}: AddApproverModalProps) { }: AddApproverModalProps) {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24); const [tatHours, setTatHours] = useState<number>(24);
@ -144,36 +140,6 @@ export function AddApproverModal({
return; return;
} }
// Validate against maxApprovalLevels policy
// Calculate the new total levels after adding this approver
// If inserting at a level that already exists, levels shift down, so total stays same
// If inserting at a new level (beyond current), total increases
const currentMaxLevel = currentLevels.length > 0
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
: 0;
const newTotalLevels = selectedLevel > currentMaxLevel
? selectedLevel // New level beyond current max
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
if (onPolicyViolation) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
currentValue: newTotalLevels,
maxValue: maxApprovalLevels
}]);
} else {
setValidationModal({
open: true,
type: 'error',
email: '',
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
});
}
return;
}
// Check if user is already a participant // Check if user is already a participant
const existingParticipant = existingParticipants.find( const existingParticipant = existingParticipants.find(
p => (p.email || '').toLowerCase() === emailToAdd p => (p.email || '').toLowerCase() === emailToAdd
@ -428,20 +394,6 @@ export function AddApproverModal({
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down. Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
</p> </p>
{/* Max Approval Levels Note */}
{maxApprovalLevels && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
<p className="text-xs text-blue-800">
Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
{currentLevels.length > 0 && (
<span className="ml-2">
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
</span>
)}
</p>
</div>
)}
{/* Current Levels Display */} {/* Current Levels Display */}
{currentLevels.length > 0 && ( {currentLevels.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -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>
);
}

View File

@ -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;

View File

@ -1,186 +0,0 @@
"use client";
import * as React from "react";
import { format, parse, isValid } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import { cn } from "./utils";
import { Button } from "./button";
import { Calendar } from "./calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "./popover";
export interface CustomDatePickerProps {
/**
* Selected date value as string in YYYY-MM-DD format (for form compatibility)
* or Date object
*/
value?: string | Date | null;
/**
* Callback when date changes. Returns date string in YYYY-MM-DD format
*/
onChange?: (date: string | null) => void;
/**
* Minimum selectable date as string (YYYY-MM-DD) or Date object
*/
minDate?: string | Date | null;
/**
* Maximum selectable date as string (YYYY-MM-DD) or Date object
*/
maxDate?: string | Date | null;
/**
* Placeholder text
*/
placeholderText?: string;
/**
* Whether the date picker is disabled
*/
disabled?: boolean;
/**
* Additional CSS classes
*/
className?: string;
/**
* CSS classes for the wrapper div
*/
wrapperClassName?: string;
/**
* Error state - shows red border
*/
error?: boolean;
/**
* Display format (default: "dd/MM/yyyy")
*/
displayFormat?: string;
/**
* ID for accessibility
*/
id?: string;
}
/**
* Reusable DatePicker component with consistent dd/MM/yyyy format and button trigger.
* Uses native Calendar component wrapped in a Popover.
*/
export function CustomDatePicker({
value,
onChange,
minDate,
maxDate,
placeholderText = "dd/mm/yyyy",
disabled = false,
className,
wrapperClassName,
error = false,
displayFormat = "dd/MM/yyyy",
id,
}: CustomDatePickerProps) {
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
// Convert input value to Date object for Calendar
const selectedDate = React.useMemo(() => {
if (!value) return undefined;
if (value instanceof Date) {
return isValid(value) ? value : undefined;
}
if (typeof value === "string") {
try {
const parsed = parse(value, "yyyy-MM-dd", new Date());
return isValid(parsed) ? parsed : undefined;
} catch (e) {
return undefined;
}
}
return undefined;
}, [value]);
// Convert minDate
const minDateObj = React.useMemo(() => {
if (!minDate) return undefined;
if (minDate instanceof Date) return isValid(minDate) ? minDate : undefined;
if (typeof minDate === "string") {
const parsed = parse(minDate, "yyyy-MM-dd", new Date());
return isValid(parsed) ? parsed : undefined;
}
return undefined;
}, [minDate]);
// Convert maxDate
const maxDateObj = React.useMemo(() => {
if (!maxDate) return undefined;
if (maxDate instanceof Date) return isValid(maxDate) ? maxDate : undefined;
if (typeof maxDate === "string") {
const parsed = parse(maxDate, "yyyy-MM-dd", new Date());
return isValid(parsed) ? parsed : undefined;
}
return undefined;
}, [maxDate]);
const handleSelect = (date: Date | undefined) => {
setIsPopoverOpen(false);
if (!onChange) return;
if (!date) {
onChange(null);
return;
}
// Return YYYY-MM-DD string
onChange(format(date, "yyyy-MM-dd"));
};
return (
<div className={cn("relative", wrapperClassName)}>
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
id={id}
disabled={disabled}
variant="outline"
className={cn(
"w-full justify-start text-left font-normal",
!selectedDate && "text-muted-foreground",
error && "border-destructive ring-destructive/20",
className
)}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{selectedDate ? (
format(selectedDate, displayFormat)
) : (
<span>{placeholderText}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleSelect}
disabled={(date) => {
if (minDateObj && date < minDateObj) return true;
if (maxDateObj && date > maxDateObj) return true;
return false;
}}
initialFocus
/>
</PopoverContent>
</Popover>
</div>
);
}
export default CustomDatePicker;

View File

@ -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]);
@ -70,55 +68,55 @@ export function RichTextEditor({
const cleanWordHTML = React.useCallback((html: string): string => { const cleanWordHTML = React.useCallback((html: string): string => {
// Remove HTML comments (like Word style definitions) // Remove HTML comments (like Word style definitions)
html = html.replace(/<!--[\s\S]*?-->/g, ''); html = html.replace(/<!--[\s\S]*?-->/g, '');
// Remove style tags (Word CSS) // Remove style tags (Word CSS)
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, ''); html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Remove script tags // Remove script tags
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, ''); html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// Remove meta tags // Remove meta tags
html = html.replace(/<meta[^>]*>/gi, ''); html = html.replace(/<meta[^>]*>/gi, '');
// Remove Word-specific classes and attributes // Remove Word-specific classes and attributes
html = html.replace(/\s*class="Mso[^"]*"/gi, ''); html = html.replace(/\s*class="Mso[^"]*"/gi, '');
html = html.replace(/\s*class="mso[^"]*"/gi, ''); html = html.replace(/\s*class="mso[^"]*"/gi, '');
html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, ''); html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, '');
html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, ''); html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, '');
// Remove xmlns attributes // Remove xmlns attributes
html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, ''); html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, '');
// Remove o:p tags (Word paragraph markers) // Remove o:p tags (Word paragraph markers)
html = html.replace(/<\/?o:p[^>]*>/gi, ''); html = html.replace(/<\/?o:p[^>]*>/gi, '');
// Remove v:shapes and other Word-specific elements // Remove v:shapes and other Word-specific elements
html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, ''); html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
html = html.replace(/<v:[^>]*\/>/gi, ''); html = html.replace(/<v:[^>]*\/>/gi, '');
// Clean up empty paragraphs // Clean up empty paragraphs
html = html.replace(/<p[^>]*>\s*<\/p>/gi, ''); html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
html = html.replace(/<div[^>]*>\s*<\/div>/gi, ''); html = html.replace(/<div[^>]*>\s*<\/div>/gi, '');
// Remove excessive whitespace // Remove excessive whitespace
html = html.replace(/\s+/g, ' '); html = html.replace(/\s+/g, ' ');
html = html.trim(); html = html.trim();
return html; return html;
}, []); }, []);
// Handle paste event to preserve formatting // Handle paste event to preserve formatting
const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => { const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault(); e.preventDefault();
const clipboardData = e.clipboardData; const clipboardData = e.clipboardData;
let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain'); let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain');
// Clean Word/Office metadata if HTML // Clean Word/Office metadata if HTML
if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) { if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) {
pastedData = cleanWordHTML(pastedData); pastedData = cleanWordHTML(pastedData);
} }
if (!editorRef.current) return; if (!editorRef.current) return;
const selection = window.getSelection(); const selection = window.getSelection();
@ -133,12 +131,12 @@ export function RichTextEditor({
// Clean and preserve formatting // Clean and preserve formatting
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Process each node to preserve lists, tables, and basic formatting // Process each node to preserve lists, tables, and basic formatting
Array.from(tempDiv.childNodes).forEach((node) => { Array.from(tempDiv.childNodes).forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) { if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement; const element = node as HTMLElement;
// Preserve lists (ul, ol) // Preserve lists (ul, ol)
if (element.tagName === 'UL' || element.tagName === 'OL') { if (element.tagName === 'UL' || element.tagName === 'OL') {
const list = element.cloneNode(true) as HTMLElement; const list = element.cloneNode(true) as HTMLElement;
@ -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');
@ -226,36 +227,36 @@ export function RichTextEditor({
} }
range.insertNode(fragment); range.insertNode(fragment);
// Move cursor to end of inserted content // Move cursor to end of inserted content
range.collapse(false); range.collapse(false);
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]);
// Check active formats (bold, italic, etc.) // Check active formats (bold, italic, etc.)
const checkActiveFormats = React.useCallback(() => { const checkActiveFormats = React.useCallback(() => {
if (!editorRef.current || !isFocused) return; if (!editorRef.current || !isFocused) return;
const formats = new Set<string>(); const formats = new Set<string>();
const selection = window.getSelection(); const selection = window.getSelection();
if (selection && selection.rangeCount > 0) { if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer; const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null; let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) { if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement; element = commonAncestor.parentElement;
} else { } else {
element = commonAncestor as HTMLElement; element = commonAncestor as HTMLElement;
} }
while (element && element !== editorRef.current) { while (element && element !== editorRef.current) {
const tagName = element.tagName.toLowerCase(); const tagName = element.tagName.toLowerCase();
if (tagName === 'strong' || tagName === 'b') formats.add('bold'); if (tagName === 'strong' || tagName === 'b') formats.add('bold');
@ -266,40 +267,40 @@ export function RichTextEditor({
if (tagName === 'h3') formats.add('h3'); if (tagName === 'h3') formats.add('h3');
if (tagName === 'ul') formats.add('ul'); if (tagName === 'ul') formats.add('ul');
if (tagName === 'ol') formats.add('ol'); if (tagName === 'ol') formats.add('ol');
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
if (style.textAlign === 'center') formats.add('center'); if (style.textAlign === 'center') formats.add('center');
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) {
@ -320,15 +321,15 @@ export function RichTextEditor({
// Only reset if we haven't found a highlight yet // Only reset if we haven't found a highlight yet
setCurrentHighlightColor(null); setCurrentHighlightColor(null);
} }
// Check for text color // Check for text color
const textColor = style.color; const textColor = style.color;
// Convert to hex for comparison // Convert to hex for comparison
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 => {
@ -349,23 +350,23 @@ export function RichTextEditor({
setCurrentTextColor(null); setCurrentTextColor(null);
} }
} }
element = element.parentElement; element = element.parentElement;
} }
} }
setActiveFormats(formats); setActiveFormats(formats);
}, [isFocused]); }, [isFocused]);
// Apply formatting command // Apply formatting command
const applyFormat = React.useCallback((command: string, value?: string) => { const applyFormat = React.useCallback((command: string, value?: string) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Restore focus if needed // Restore focus if needed
if (!isFocused) { if (!isFocused) {
editorRef.current.focus(); editorRef.current.focus();
} }
// Save current selection // Save current selection
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -373,15 +374,15 @@ export function RichTextEditor({
editorRef.current.focus(); editorRef.current.focus();
return; return;
} }
// Execute formatting command // Execute formatting command
document.execCommand(command, false, value); document.execCommand(command, false, value);
// 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
setTimeout(checkActiveFormats, 10); setTimeout(checkActiveFormats, 10);
}, [isFocused, onChange, checkActiveFormats]); }, [isFocused, onChange, checkActiveFormats]);
@ -389,12 +390,12 @@ export function RichTextEditor({
// Apply highlight color // Apply highlight color
const applyHighlight = React.useCallback((color: string) => { const applyHighlight = React.useCallback((color: string) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Restore focus if needed // Restore focus if needed
if (!isFocused) { if (!isFocused) {
editorRef.current.focus(); editorRef.current.focus();
} }
// Save current selection // Save current selection
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -402,26 +403,26 @@ export function RichTextEditor({
editorRef.current.focus(); editorRef.current.focus();
return; return;
} }
// Check if this color is already applied by checking the selection's style // Check if this color is already applied by checking the selection's style
let isAlreadyApplied = false; let isAlreadyApplied = false;
if (selection.rangeCount > 0) { if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer; const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null; let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) { if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement; element = commonAncestor.parentElement;
} else { } else {
element = commonAncestor as HTMLElement; element = commonAncestor as HTMLElement;
} }
// Check if the selected element has the same background color // Check if the selected element has the same background color
while (element && element !== editorRef.current) { while (element && element !== editorRef.current) {
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();
@ -445,7 +446,7 @@ export function RichTextEditor({
element = element.parentElement; element = element.parentElement;
} }
} }
// Use backColor command for highlight (background color) // Use backColor command for highlight (background color)
if (color === 'transparent' || isAlreadyApplied) { if (color === 'transparent' || isAlreadyApplied) {
// Remove highlight - use a more aggressive approach to fully remove // Remove highlight - use a more aggressive approach to fully remove
@ -453,10 +454,10 @@ export function RichTextEditor({
if (!range.collapsed) { if (!range.collapsed) {
// Store the range before manipulation // Store the range before manipulation
const contents = range.extractContents(); const contents = range.extractContents();
// Create a new text node or span without background color // Create a new text node or span without background color
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Process extracted contents to remove background colors // Process extracted contents to remove background colors
const processNode = (node: Node) => { const processNode = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) { if (node.nodeType === Node.TEXT_NODE) {
@ -464,14 +465,14 @@ export function RichTextEditor({
} else if (node.nodeType === Node.ELEMENT_NODE) { } else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement; const el = node as HTMLElement;
const newEl = document.createElement(el.tagName.toLowerCase()); const newEl = document.createElement(el.tagName.toLowerCase());
// Copy all attributes except style-related ones // Copy all attributes except style-related ones
Array.from(el.attributes).forEach(attr => { Array.from(el.attributes).forEach(attr => {
if (attr.name !== 'style' && attr.name !== 'class') { if (attr.name !== 'style' && attr.name !== 'class') {
newEl.setAttribute(attr.name, attr.value); newEl.setAttribute(attr.name, attr.value);
} }
}); });
// Process children and copy without background color // Process children and copy without background color
Array.from(el.childNodes).forEach(child => { Array.from(el.childNodes).forEach(child => {
const processed = processNode(child); const processed = processNode(child);
@ -479,27 +480,27 @@ export function RichTextEditor({
newEl.appendChild(processed); newEl.appendChild(processed);
} }
}); });
// Remove background color if present // Remove background color if present
if (el.style.backgroundColor) { if (el.style.backgroundColor) {
newEl.style.backgroundColor = ''; newEl.style.backgroundColor = '';
} }
return newEl; return newEl;
} }
return null; return null;
}; };
Array.from(contents.childNodes).forEach(child => { Array.from(contents.childNodes).forEach(child => {
const processed = processNode(child); const processed = processNode(child);
if (processed) { if (processed) {
fragment.appendChild(processed); fragment.appendChild(processed);
} }
}); });
// Insert the cleaned fragment // Insert the cleaned fragment
range.insertNode(fragment); range.insertNode(fragment);
// Also use execCommand to ensure removal // Also use execCommand to ensure removal
document.execCommand('removeFormat', false); document.execCommand('removeFormat', false);
} else { } else {
@ -522,21 +523,21 @@ export function RichTextEditor({
return; return;
} }
} }
// Clear selection immediately after applying to prevent "sticky" highlight mode // Clear selection immediately after applying to prevent "sticky" highlight mode
const sel = window.getSelection(); const sel = window.getSelection();
if (sel) { if (sel) {
sel.removeAllRanges(); sel.removeAllRanges();
} }
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML)); onChange(editorRef.current.innerHTML);
} }
// Close popover // Close popover
setHighlightColorOpen(false); setHighlightColorOpen(false);
// Refocus editor after a short delay and check formats // Refocus editor after a short delay and check formats
setTimeout(() => { setTimeout(() => {
if (editorRef.current) { if (editorRef.current) {
@ -549,12 +550,12 @@ export function RichTextEditor({
// Apply text color // Apply text color
const applyTextColor = React.useCallback((color: string) => { const applyTextColor = React.useCallback((color: string) => {
if (!editorRef.current) return; if (!editorRef.current) return;
// Restore focus if needed // Restore focus if needed
if (!isFocused) { if (!isFocused) {
editorRef.current.focus(); editorRef.current.focus();
} }
// Save current selection // Save current selection
const selection = window.getSelection(); const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) { if (!selection || selection.rangeCount === 0) {
@ -562,20 +563,20 @@ export function RichTextEditor({
editorRef.current.focus(); editorRef.current.focus();
return; return;
} }
// Check if this color is already applied by checking the selection's style // Check if this color is already applied by checking the selection's style
let isAlreadyApplied = false; let isAlreadyApplied = false;
if (selection.rangeCount > 0) { if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0); const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer; const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null; let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) { if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement; element = commonAncestor.parentElement;
} else { } else {
element = commonAncestor as HTMLElement; element = commonAncestor as HTMLElement;
} }
// Check if the selected element has the same text color // Check if the selected element has the same text color
while (element && element !== editorRef.current) { while (element && element !== editorRef.current) {
const style = window.getComputedStyle(element); const style = window.getComputedStyle(element);
@ -611,7 +612,7 @@ export function RichTextEditor({
element = element.parentElement; element = element.parentElement;
} }
} }
// Use foreColor command for text color // Use foreColor command for text color
if (color === 'transparent' || color === 'default' || isAlreadyApplied) { if (color === 'transparent' || color === 'default' || isAlreadyApplied) {
// Remove text color by removing format or setting to default // Remove text color by removing format or setting to default
@ -632,15 +633,15 @@ export function RichTextEditor({
setCustomTextColor(color); setCustomTextColor(color);
} }
} }
// Update content // Update content
if (editorRef.current) { if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML)); onChange(editorRef.current.innerHTML);
} }
// Close popover // Close popover
setTextColorOpen(false); setTextColorOpen(false);
// Check active formats after a short delay // Check active formats after a short delay
setTimeout(checkActiveFormats, 10); setTimeout(checkActiveFormats, 10);
}, [isFocused, onChange, checkActiveFormats]); }, [isFocused, onChange, checkActiveFormats]);
@ -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,18 +685,18 @@ 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]);
// Handle selection change to update active formats // Handle selection change to update active formats
React.useEffect(() => { React.useEffect(() => {
if (!isFocused) return; if (!isFocused) return;
const handleSelectionChange = () => { const handleSelectionChange = () => {
checkActiveFormats(); checkActiveFormats();
}; };
document.addEventListener('selectionchange', handleSelectionChange); document.addEventListener('selectionchange', handleSelectionChange);
return () => { return () => {
document.removeEventListener('selectionchange', handleSelectionChange); document.removeEventListener('selectionchange', handleSelectionChange);
@ -747,7 +748,7 @@ export function RichTextEditor({
> >
<Underline className="h-4 w-4" /> <Underline className="h-4 w-4" />
</Button> </Button>
{/* Highlight Color Picker */} {/* Highlight Color Picker */}
<Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}> <Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -764,8 +765,8 @@ export function RichTextEditor({
<Highlighter className="h-4 w-4" /> <Highlighter className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-2" className="w-auto p-2"
align="start" align="start"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
// Prevent closing when clicking inside popover // Prevent closing when clicking inside popover
@ -790,7 +791,7 @@ export function RichTextEditor({
> >
<X className="h-4 w-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
</Button> </Button>
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div> <div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div>
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2"> <div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
{HIGHLIGHT_COLORS.map((color) => { {HIGHLIGHT_COLORS.map((color) => {
@ -832,7 +833,7 @@ export function RichTextEditor({
); );
})} })}
</div> </div>
{/* Remove Highlight Button - Standard pattern */} {/* Remove Highlight Button - Standard pattern */}
{currentHighlightColor && currentHighlightColor !== 'transparent' && ( {currentHighlightColor && currentHighlightColor !== 'transparent' && (
<div className="mb-2"> <div className="mb-2">
@ -851,7 +852,7 @@ export function RichTextEditor({
</Button> </Button>
</div> </div>
)} )}
{/* Custom Color Picker */} {/* Custom Color Picker */}
<div className="border-t border-gray-200 pt-2 mt-2"> <div className="border-t border-gray-200 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div> <div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
@ -898,7 +899,7 @@ export function RichTextEditor({
// Get pasted text from clipboard // Get pasted text from clipboard
const pastedText = e.clipboardData.getData('text').trim(); const pastedText = e.clipboardData.getData('text').trim();
e.preventDefault(); e.preventDefault();
// Process after paste event completes // Process after paste event completes
setTimeout(() => { setTimeout(() => {
// Check if it's a valid hex color with # // Check if it's a valid hex color with #
@ -979,7 +980,7 @@ export function RichTextEditor({
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{/* Text Color Picker */} {/* Text Color Picker */}
<Popover open={textColorOpen} onOpenChange={setTextColorOpen}> <Popover open={textColorOpen} onOpenChange={setTextColorOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
@ -996,8 +997,8 @@ export function RichTextEditor({
<Type className="h-4 w-4" /> <Type className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent <PopoverContent
className="w-auto p-2" className="w-auto p-2"
align="start" align="start"
onPointerDownOutside={(e) => { onPointerDownOutside={(e) => {
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@ -1021,7 +1022,7 @@ export function RichTextEditor({
> >
<X className="h-4 w-4 text-gray-500" /> <X className="h-4 w-4 text-gray-500" />
</Button> </Button>
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div> <div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div>
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2"> <div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
{/* Default/Black Color Option - First position (standard) */} {/* Default/Black Color Option - First position (standard) */}
@ -1084,7 +1085,7 @@ export function RichTextEditor({
); );
})} })}
</div> </div>
{/* Remove Text Color Button - Standard pattern */} {/* Remove Text Color Button - Standard pattern */}
{currentTextColor && currentTextColor !== '#000000' && ( {currentTextColor && currentTextColor !== '#000000' && (
<div className="mb-2"> <div className="mb-2">
@ -1103,7 +1104,7 @@ export function RichTextEditor({
</Button> </Button>
</div> </div>
)} )}
{/* Custom Text Color Picker */} {/* Custom Text Color Picker */}
<div className="border-t border-gray-200 pt-2 mt-2"> <div className="border-t border-gray-200 pt-2 mt-2">
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div> <div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>

File diff suppressed because it is too large Load Diff

View File

@ -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';
@ -9,12 +8,12 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import {
Send, Send,
Smile, Smile,
Paperclip, Paperclip,
FileText, FileText,
Download, Download,
Clock, Clock,
Flag, Flag,
X, X,
@ -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 }) => {
@ -105,7 +102,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const filteredMessages = messages.filter(msg => const filteredMessages = messages.filter(msg =>
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) || msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase()) msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
@ -135,7 +132,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
// Realtime updates via Socket.IO // Realtime updates via Socket.IO
useEffect(() => { useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded if (!currentUserId) return; // Wait for currentUserId to be loaded
let joinedId = requestId; let joinedId = requestId;
(async () => { (async () => {
try { try {
@ -143,39 +140,39 @@ 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
joinRequestRoom(s, joinedId, currentUserId || undefined); joinRequestRoom(s, joinedId, currentUserId || undefined);
const noteHandler = (payload: any) => { const noteHandler = (payload: any) => {
const n = payload?.note || payload; const n = payload?.note || payload;
if (!n) return; if (!n) return;
setMessages(prev => { setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) { if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
return prev; return prev;
} }
const userName = n.userName || n.user_name || 'User'; const userName = n.userName || n.user_name || 'User';
const userRole = n.userRole || n.user_role; const userRole = n.userRole || n.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = n.userId || n.user_id; const noteUserId = n.userId || n.user_id;
const newMsg = { const newMsg = {
id: n.noteId || n.note_id || String(Date.now()), id: n.noteId || n.note_id || String(Date.now()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: n.message || '', content: n.message || '',
timestamp: n.createdAt || n.created_at || new Date().toISOString(), timestamp: n.createdAt || n.created_at || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId isCurrentUser: noteUserId === currentUserId
} as any; } as any;
return [...prev, newMsg]; return [...prev, newMsg];
}); });
}; };
@ -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]);
@ -208,20 +205,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userName = m.userName || m.user_name || 'User'; const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || '', content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(), timestamp: m.createdAt || m.created_at || new Date().toISOString(),
}; };
}) : []; }) : [];
setMessages(mapped as any); setMessages(mapped as any);
} catch { } } catch {}
} }
setMessage(''); setMessage('');
setSelectedFiles([]); setSelectedFiles([]);
@ -237,21 +234,21 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id; const noteUserId = m.userId || m.user_id;
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || m.content || '', content: m.message || m.content || '',
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(), timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({ attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
attachmentId: a.attachmentId || a.attachment_id, attachmentId: a.attachmentId || a.attachment_id,
name: a.fileName || a.file_name || a.name, name: a.fileName || a.file_name || a.name,
fileName: a.fileName || a.file_name || a.name, fileName: a.fileName || a.file_name || a.name,
url: a.storageUrl || a.storage_url || a.url || '#', url: a.storageUrl || a.storage_url || a.url || '#',
type: a.fileType || a.file_type || a.type || 'file', type: a.fileType || a.file_type || a.type || 'file',
fileType: a.fileType || a.file_type || a.type || 'file', fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size fileSize: a.fileSize || a.file_size
@ -260,24 +257,24 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} as any; } as any;
}); });
setMessages(mapped); setMessages(mapped);
} catch { } } catch {}
} else { } else {
(async () => { (async () => {
try { try {
const rows = await getWorkNotes(requestId); const rows = await getWorkNotes(requestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => { const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User'; const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role; const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole); const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id; const noteUserId = m.userId || m.user_id;
return { return {
id: m.noteId || m.note_id || m.id || String(Math.random()), id: m.noteId || m.note_id || m.id || String(Math.random()),
user: { user: {
name: userName, name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(), avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole role: participantRole
}, },
content: m.message || '', content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(), timestamp: m.createdAt || m.created_at || new Date().toISOString(),
@ -339,7 +336,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (msg.id === messageId) { if (msg.id === messageId) {
const reactions = msg.reactions || []; const reactions = msg.reactions || [];
const existingReaction = reactions.find(r => r.emoji === emoji); const existingReaction = reactions.find(r => r.emoji === emoji);
if (existingReaction) { if (existingReaction) {
if (existingReaction.users.includes('You')) { if (existingReaction.users.includes('You')) {
existingReaction.users = existingReaction.users.filter(u => u !== 'You'); existingReaction.users = existingReaction.users.filter(u => u !== 'You');
@ -352,7 +349,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} else { } else {
reactions.push({ emoji, users: ['You'] }); reactions.push({ emoji, users: ['You'] });
} }
return { ...msg, reactions }; return { ...msg, reactions };
} }
return msg; return msg;
@ -374,7 +371,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
</div> </div>
</div> </div>
{/* Search Bar */} {/* Search Bar */}
<div className="relative"> <div className="relative">
<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" />
@ -392,20 +389,21 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="space-y-6 max-w-full"> <div className="space-y-6 max-w-full">
{filteredMessages.map((msg) => { {filteredMessages.map((msg) => {
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You'; const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
return ( return (
<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>
)} )}
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}> <div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
{msg.isSystem ? ( {msg.isSystem ? (
<div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full"> <div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full">
@ -437,7 +435,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
{/* Message Content */} {/* Message Content */}
<div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}> <div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
<div <div
className="text-gray-800 leading-relaxed text-base" className="text-gray-800 leading-relaxed text-base"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }} dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/> />
@ -451,72 +449,72 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const fileName = attachment.fileName || attachment.file_name || attachment.name; const fileName = attachment.fileName || attachment.file_name || attachment.name;
const fileType = attachment.fileType || attachment.file_type || attachment.type || ''; const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
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 */}
<Button {/* Preview button for images and PDFs */}
variant="ghost" {attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
size="sm" <Button
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600" variant="ghost"
onClick={async (e) => { 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.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,18 +528,19 @@ 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 ${
? 'bg-blue-100 text-blue-800 border border-blue-200' reaction.users.includes('You')
? '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>
</button> </button>
))} ))}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-7 w-7 p-0 flex-shrink-0" className="h-7 w-7 p-0 flex-shrink-0"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
> >
@ -553,7 +552,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
)} )}
</div> </div>
{!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="bg-blue-500 text-white font-semibold text-sm"> <AvatarFallback className="bg-blue-500 text-white font-semibold text-sm">
@ -649,27 +648,27 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
{/* Left side - Action buttons */} {/* Left side - Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0"> <div className="flex items-center gap-2 flex-shrink-0">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={handleAttachmentClick} onClick={handleAttachmentClick}
title="Attach file" title="Attach file"
> >
<Paperclip className="h-4 w-4" /> <Paperclip className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Add emoji" title="Add emoji"
> >
<Smile className="h-4 w-4" /> <Smile className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600" className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setMessage(prev => prev + '@')} onClick={() => setMessage(prev => prev + '@')}
title="Mention someone" title="Mention someone"
@ -683,8 +682,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<span className="text-xs text-gray-500 whitespace-nowrap"> <span className="text-xs text-gray-500 whitespace-nowrap">
{message.length}/2000 {message.length}/2000
</span> </span>
<Button <Button
onClick={handleSendMessage} onClick={handleSendMessage}
disabled={!message.trim() && selectedFiles.length === 0} disabled={!message.trim() && selectedFiles.length === 0}
className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed" className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
size="sm" size="sm"
@ -696,7 +695,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div> </div>
</div> </div>
</div> </div>
{/* File Preview Modal */} {/* File Preview Modal */}
{previewFile && ( {previewFile && (
<FilePreview <FilePreview

View File

@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react'; import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm'; import { FormData } from '@/hooks/useCreateRequestForm';
import { useMultiUserSearch } from '@/hooks/useUserSearch'; import { useMultiUserSearch } from '@/hooks/useUserSearch';
import { ensureUserExists } from '@/services/userApi'; import { ensureUserExists } from '@/services/userApi';
@ -15,8 +15,6 @@ interface ApprovalWorkflowStepProps {
formData: FormData; formData: FormData;
updateFormData: (field: keyof FormData, value: any) => void; updateFormData: (field: keyof FormData, value: any) => void;
onValidationError: (error: { type: string; email: string; message: string }) => void; onValidationError: (error: { type: string; email: string; message: string }) => void;
systemPolicy: SystemPolicy;
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
} }
/** /**
@ -35,9 +33,7 @@ interface ApprovalWorkflowStepProps {
export function ApprovalWorkflowStep({ export function ApprovalWorkflowStep({
formData, formData,
updateFormData, updateFormData,
onValidationError, onValidationError
systemPolicy,
onPolicyViolation
}: ApprovalWorkflowStepProps) { }: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch(); const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -45,18 +41,18 @@ export function ApprovalWorkflowStep({
useEffect(() => { useEffect(() => {
const approverCount = formData.approverCount || 1; const approverCount = formData.approverCount || 1;
const currentApprovers = formData.approvers || []; const currentApprovers = formData.approvers || [];
// Ensure we have the correct number of approvers // Ensure we have the correct number of approvers
if (currentApprovers.length < approverCount) { if (currentApprovers.length < approverCount) {
const newApprovers = [...currentApprovers]; const newApprovers = [...currentApprovers];
// Fill missing approver slots // Fill missing approver slots
for (let i = currentApprovers.length; i < approverCount; i++) { for (let i = currentApprovers.length; i < approverCount; i++) {
if (!newApprovers[i]) { if (!newApprovers[i]) {
newApprovers[i] = { newApprovers[i] = {
email: '', email: '',
name: '', name: '',
level: i + 1, level: i + 1,
tat: '' as any tat: '' as any
}; };
} }
} }
@ -71,7 +67,7 @@ export function ApprovalWorkflowStep({
const newApprovers = [...formData.approvers]; const newApprovers = [...formData.approvers];
const previousEmail = newApprovers[index]?.email; const previousEmail = newApprovers[index]?.email;
const emailChanged = previousEmail !== value; const emailChanged = previousEmail !== value;
newApprovers[index] = { newApprovers[index] = {
...newApprovers[index], ...newApprovers[index],
email: value, email: value,
@ -94,8 +90,8 @@ export function ApprovalWorkflowStep({
try { try {
// Check for duplicates in other approver slots (excluding current index) // Check for duplicates in other approver slots (excluding current index)
const isDuplicateApprover = formData.approvers?.some( const isDuplicateApprover = formData.approvers?.some(
(approver: any, idx: number) => (approver: any, idx: number) =>
idx !== index && idx !== index &&
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase()) (approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
); );
@ -196,9 +192,9 @@ export function ApprovalWorkflowStep({
<div data-testid="approval-workflow-count-field"> <div data-testid="approval-workflow-count-field">
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label> <Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const currentCount = formData.approverCount || 1; const currentCount = formData.approverCount || 1;
@ -216,35 +212,23 @@ export function ApprovalWorkflowStep({
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display"> <span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
{formData.approverCount || 1} {formData.approverCount || 1}
</span> </span>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={() => {
const currentCount = formData.approverCount || 1; const currentCount = formData.approverCount || 1;
const newCount = currentCount + 1; const newCount = Math.min(10, currentCount + 1);
// Validate against system policy
if (newCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `Cannot add more than ${systemPolicy.maxApprovalLevels} approval levels. Please remove an approver level or contact your administrator.`,
currentValue: newCount,
maxValue: systemPolicy.maxApprovalLevels
}]);
return;
}
updateFormData('approverCount', newCount); updateFormData('approverCount', newCount);
}} }}
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels} disabled={(formData.approverCount || 1) >= 10}
data-testid="approval-workflow-increase-count" data-testid="approval-workflow-increase-count"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
</div> </div>
<p className="text-sm text-gray-600 mt-2"> <p className="text-sm text-gray-600 mt-2">
Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially. Maximum 10 approvers allowed. Each approver will review sequentially.
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -282,13 +266,13 @@ export function ApprovalWorkflowStep({
{Array.from({ length: formData.approverCount || 1 }, (_, index) => { {Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1; const level = index + 1;
const isLast = level === (formData.approverCount || 1); const isLast = level === (formData.approverCount || 1);
// Ensure approver exists (should be initialized by useEffect, but provide fallback) // Ensure approver exists (should be initialized by useEffect, but provide fallback)
const approver = formData.approvers[index] || { const approver = formData.approvers[index] || {
email: '', email: '',
name: '', name: '',
level: level, level: level,
tat: '' as any tat: '' as any
}; };
return ( return (
@ -296,16 +280,18 @@ export function ApprovalWorkflowStep({
<div className="flex justify-center"> <div className="flex justify-center">
<div className="w-px h-6 bg-gray-300"></div> <div className="w-px h-6 bg-gray-300"></div>
</div> </div>
<div className={`p-4 rounded-lg border-2 transition-all ${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 +320,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"

View File

@ -69,7 +69,7 @@ export function DocumentsStep({
// Check file extension // Check file extension
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({ validationErrors.push({
fileName: file.name, fileName: file.name,
@ -111,16 +111,16 @@ export function DocumentsStep({
const type = (doc.fileType || doc.file_type || '').toLowerCase(); const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase(); const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') || return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf'); name.endsWith('.pdf');
} else { } else {
const type = (doc.type || '').toLowerCase(); const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase(); const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') || return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf'); name.endsWith('.pdf');
} }
}; };
@ -160,7 +160,7 @@ export function DocumentsStep({
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" /> <Upload className="h-12 w-12 mx-auto mb-4 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"
@ -172,10 +172,10 @@ export function DocumentsStep({
ref={fileInputRef} ref={fileInputRef}
data-testid="documents-file-input" data-testid="documents-file-input"
/> />
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button" data-testid="documents-browse-button"
> >
@ -206,7 +206,7 @@ export function DocumentsStep({
const docId = doc.documentId || doc.document_id || ''; const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId); const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null; if (isDeleted) return null;
return ( return (
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}> <div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -222,9 +222,9 @@ export function DocumentsStep({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canPreview(doc, true) && ( {canPreview(doc, true) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onPreviewDocument(doc, true)} onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`} data-testid={`documents-existing-${docId}-preview`}
> >
@ -276,9 +276,9 @@ export function DocumentsStep({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canPreview(file, false) && ( {canPreview(file, false) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onPreviewDocument(file, false)} onClick={() => onPreviewDocument(file, false)}
data-testid={`documents-new-${index}-preview`} data-testid={`documents-new-${index}-preview`}
> >

View File

@ -1,19 +1,15 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, Flame, Target, TrendingUp, FolderOpen, ArrowLeft, Info } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, Info, Flame, Target, TrendingUp } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
interface TemplateSelectionStepProps { interface TemplateSelectionStepProps {
templates: RequestTemplate[]; templates: RequestTemplate[];
selectedTemplate: RequestTemplate | null; selectedTemplate: RequestTemplate | null;
onSelectTemplate: (template: RequestTemplate) => void; onSelectTemplate: (template: RequestTemplate) => void;
adminTemplates?: RequestTemplate[];
} }
const getPriorityIcon = (priority: string) => { const getPriorityIcon = (priority: string) => {
@ -25,48 +21,21 @@ const getPriorityIcon = (priority: string) => {
} }
}; };
/**
* Component: TemplateSelectionStep
*
* Purpose: Step 1 - Template selection for request creation
*
* Features:
* - Displays available templates
* - Shows template details when selected
* - Test IDs for testing
*/
export function TemplateSelectionStep({ export function TemplateSelectionStep({
templates, templates,
selectedTemplate, selectedTemplate,
onSelectTemplate, onSelectTemplate
adminTemplates = []
}: TemplateSelectionStepProps) { }: TemplateSelectionStepProps) {
const [viewMode, setViewMode] = useState<'main' | 'admin'>('main');
const navigate = useNavigate();
const handleTemplateClick = (template: RequestTemplate) => {
if (template.id === 'admin-templates-category') {
setViewMode('admin');
} else {
if (viewMode === 'admin') {
// If selecting an actual admin template, redirect to dedicated flow
navigate(`/create-admin-request/${template.id}`);
} else {
// Default behavior for standard templates
onSelectTemplate(template);
}
}
};
const displayTemplates = viewMode === 'main'
? [
...templates,
// {
// id: 'admin-templates-category',
// name: 'Admin Templates',
// description: 'Browse standardized request workflows created by your organization administrators',
// category: 'Organization',
// icon: FolderOpen,
// estimatedTime: 'Variable',
// commonApprovers: [],
// suggestedSLA: 0,
// priority: 'medium',
// fields: {}
// } as any
]
: adminTemplates;
return ( return (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@ -78,154 +47,100 @@ export function TemplateSelectionStep({
{/* Header Section */} {/* Header Section */}
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header"> <div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title"> <h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
{viewMode === 'main' ? 'Choose Your Request Type' : 'Organization Templates'} Choose Your Request Type
</h1> </h1>
<p className="text-lg text-gray-600" data-testid="template-selection-description"> <p className="text-lg text-gray-600" data-testid="template-selection-description">
{viewMode === 'main' Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.
? 'Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.'
: 'Select a pre-configured workflow template defined by your organization.'}
</p> </p>
</div> </div>
{viewMode === 'admin' && (
<div className="w-full max-w-6xl mb-6 flex justify-start">
<Button variant="ghost" className="gap-2" onClick={() => setViewMode('main')}>
<ArrowLeft className="w-4 h-4" />
Back to All Types
</Button>
</div>
)}
{/* Template Cards Grid */} {/* Template Cards Grid */}
<div <div
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8" className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
data-testid="template-selection-grid" data-testid="template-selection-grid"
> >
{displayTemplates.length === 0 && viewMode === 'admin' ? ( {templates.map((template) => (
<div className="col-span-full text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200"> <motion.div
<FolderOpen className="w-12 h-12 mx-auto mb-3 text-gray-300" /> key={template.id}
<p>No admin templates available yet.</p> whileHover={{ scale: 1.03 }}
</div> whileTap={{ scale: 0.98 }}
) : ( transition={{ type: "spring", stiffness: 300, damping: 20 }}
displayTemplates.map((template) => { data-testid={`template-card-${template.id}`}
const isComingSoon = false; >
const isDisabled = isComingSoon; <Card
const isCategoryCard = template.id === 'admin-templates-category'; className={`cursor-pointer h-full transition-all duration-300 border-2 ${
// const isCustomCard = template.id === 'custom'; selectedTemplate?.id === template.id
const isSelected = selectedTemplate?.id === template.id; ? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
return ( }`}
<motion.div onClick={() => onSelectTemplate(template)}
key={template.id} data-testid={`template-card-${template.id}-clickable`}
whileHover={!isDisabled ? { scale: 1.03 } : {}} >
whileTap={!isDisabled ? { scale: 0.98 } : {}} <CardHeader className="space-y-4 pb-4">
transition={{ type: "spring", stiffness: 300, damping: 20 }} <div className="flex items-start justify-between">
data-testid={`template-card-${template.id}`} <div
> className={`w-14 h-14 rounded-xl flex items-center justify-center ${
<Card selectedTemplate?.id === template.id
className={`h-full transition-all duration-300 border-2 ${isDisabled ? 'bg-blue-100'
? 'border-gray-200 bg-gray-50/50 opacity-85 cursor-not-allowed' : 'bg-gray-100'
: isSelected
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
: isCategoryCard
? 'border-blue-200 bg-blue-50/30 hover:border-blue-400 hover:shadow-lg cursor-pointer'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg cursor-pointer'
}`} }`}
onClick={!isDisabled ? () => handleTemplateClick(template) : undefined} data-testid={`template-card-${template.id}-icon`}
data-testid={`template-card-${template.id}-clickable`} >
> <template.icon
<CardHeader className="space-y-4 pb-4"> className={`w-7 h-7 ${
<div className="flex items-start justify-between"> selectedTemplate?.id === template.id
<div ? 'text-blue-600'
className={`w-14 h-14 rounded-xl flex items-center justify-center ${isSelected : 'text-gray-600'
? 'bg-blue-100' }`}
: isCategoryCard />
? 'bg-blue-100' </div>
: 'bg-gray-100' {selectedTemplate?.id === template.id && (
}`} <motion.div
data-testid={`template-card-${template.id}-icon`} initial={{ scale: 0 }}
> animate={{ scale: 1 }}
<template.icon transition={{ type: "spring", stiffness: 500, damping: 15 }}
className={`w-7 h-7 ${isSelected data-testid={`template-card-${template.id}-selected-indicator`}
? 'text-blue-600'
: isCategoryCard
? 'text-blue-600'
: 'text-gray-600'
}`}
/>
</div>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
data-testid={`template-card-${template.id}-selected-indicator`}
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<Check className="w-5 h-5 text-white" />
</div>
</motion.div>
)}
</div>
<div className="text-left">
<div className="flex items-start justify-between gap-2 mb-2">
<CardTitle className="text-xl" data-testid={`template-card-${template.id}-name`}>
{template.name}
</CardTitle>
{isComingSoon && (
<Badge
variant="outline"
className="text-xs bg-yellow-100 text-yellow-700 border-yellow-300 font-semibold"
data-testid={`template-card-${template.id}-coming-soon-badge`}
>
Coming Soon
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
{template.category}
</Badge>
{getPriorityIcon(template.priority)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<p
className="text-sm text-gray-600 leading-relaxed line-clamp-2"
data-testid={`template-card-${template.id}-description`}
> >
{template.description} <div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
</p> <Check className="w-5 h-5 text-white" />
{!isCategoryCard && (
<>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
<Clock className="w-3.5 h-3.5" />
<span>{template.estimatedTime}</span>
</div>
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
<Users className="w-3.5 h-3.5" />
<span>{template.commonApprovers?.length || 0} approvers</span>
</div>
</div>
</>
)}
{isCategoryCard && (
<div className="pt-2">
<p className="text-xs text-blue-600 font-medium flex items-center gap-1">
Click to browse templates &rarr;
</p>
</div> </div>
)} </motion.div>
</CardContent> )}
</Card> </div>
</motion.div> <div className="text-left">
); <CardTitle className="text-xl mb-2" data-testid={`template-card-${template.id}-name`}>
}) {template.name}
)} </CardTitle>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
{template.category}
</Badge>
{getPriorityIcon(template.priority)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<p
className="text-sm text-gray-600 leading-relaxed line-clamp-2"
data-testid={`template-card-${template.id}-description`}
>
{template.description}
</p>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
<Clock className="w-3.5 h-3.5" />
<span>{template.estimatedTime}</span>
</div>
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
<Users className="w-3.5 h-3.5" />
<span>{template.commonApprovers.length} approvers</span>
</div>
</div>
</CardContent>
</Card>
</motion.div>
))}
</div> </div>
{/* Template Details Card */} {/* Template Details Card */}
@ -250,7 +165,7 @@ export function TemplateSelectionStep({
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla"> <div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla">
<Label className="text-blue-900 font-semibold">Suggested SLA</Label> <Label className="text-blue-900 font-semibold">Suggested SLA</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} hours</p> <p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} days</p>
</div> </div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority"> <div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
<Label className="text-blue-900 font-semibold">Priority Level</Label> <Label className="text-blue-900 font-semibold">Priority Level</Label>
@ -265,22 +180,18 @@ export function TemplateSelectionStep({
</div> </div>
</div> </div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers"> <div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
<Label className="text-blue-900 font-semibold">Approvers</Label> <Label className="text-blue-900 font-semibold">Common Approvers</Label>
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
{selectedTemplate.commonApprovers?.length > 0 ? ( {selectedTemplate.commonApprovers.map((approver, index) => (
selectedTemplate.commonApprovers.map((approver, index) => ( <Badge
<Badge key={`${selectedTemplate.id}-approver-${index}-${approver}`}
key={`${selectedTemplate.id}-approver-${index}-${approver}`} variant="outline"
variant="outline" className="border-blue-300 text-blue-700 bg-white"
className="border-blue-300 text-blue-700 bg-white" data-testid={`template-details-approver-${index}`}
data-testid={`template-details-approver-${index}`} >
> {approver}
{approver} </Badge>
</Badge> ))}
))
) : (
<span className="text-sm text-gray-500 italic">No specific approvers defined</span>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -19,20 +19,17 @@ interface WizardStepperProps {
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) { export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
const progressPercentage = Math.round((currentStep / totalSteps) * 100); const progressPercentage = Math.round((currentStep / totalSteps) * 100);
// Use a narrower container for fewer steps to avoid excessive spacing
const containerMaxWidth = stepNames.length <= 3 ? 'max-w-xl' : 'max-w-6xl';
return ( return (
<div <div
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0" className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
data-testid="wizard-stepper" data-testid="wizard-stepper"
> >
<div className={`${containerMaxWidth} mx-auto`}> <div className="max-w-6xl mx-auto">
{/* Mobile: Current step indicator only */} {/* Mobile: Current step indicator only */}
<div className="block sm:hidden" data-testid="wizard-stepper-mobile"> <div className="block sm:hidden" data-testid="wizard-stepper-mobile">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold" className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold"
data-testid="wizard-stepper-mobile-current-step" data-testid="wizard-stepper-mobile-current-step"
> >
@ -54,11 +51,11 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
</div> </div>
</div> </div>
{/* Progress bar */} {/* Progress bar */}
<div <div
className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden" className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden"
data-testid="wizard-stepper-mobile-progress-bar" data-testid="wizard-stepper-mobile-progress-bar"
> >
<div <div
className="bg-green-600 h-full transition-all duration-300" className="bg-green-600 h-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }} style={{ width: `${progressPercentage}%` }}
data-testid="wizard-stepper-mobile-progress-fill" data-testid="wizard-stepper-mobile-progress-fill"
@ -68,16 +65,17 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
{/* Desktop: Full step indicator */} {/* Desktop: Full step indicator */}
<div className="hidden sm:block" data-testid="wizard-stepper-desktop"> <div className="hidden sm:block" data-testid="wizard-stepper-desktop">
<div className="flex items-center justify-center gap-4 mb-2" data-testid="wizard-stepper-desktop-steps"> <div className="flex items-center justify-between mb-2" data-testid="wizard-stepper-desktop-steps">
{stepNames.map((_, index) => ( {stepNames.map((_, index) => (
<div key={index} className="flex items-center flex-1 last:flex-none" data-testid={`wizard-stepper-desktop-step-${index + 1}`}> <div key={index} className="flex items-center" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
<div <div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 ${index + 1 < currentStep className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
? 'bg-green-500 text-white' index + 1 < currentStep
: index + 1 === currentStep ? 'bg-green-600 text-white'
? 'bg-green-500 text-white ring-2 ring-green-500/30 ring-offset-1' : index + 1 === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600' : 'bg-gray-200 text-gray-600'
}`} }`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`} data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
> >
{index + 1 < currentStep ? ( {index + 1 < currentStep ? (
@ -87,24 +85,26 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
)} )}
</div> </div>
{index < stepNames.length - 1 && ( {index < stepNames.length - 1 && (
<div <div
className={`flex-1 h-0.5 mx-2 ${index + 1 < currentStep ? 'bg-green-500' : 'bg-gray-200' className={`w-8 md:w-12 lg:w-16 h-1 mx-1 md:mx-2 ${
}`} index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
}`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`} data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
/> />
)} )}
</div> </div>
))} ))}
</div> </div>
<div <div
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2 px-1" className="hidden lg:flex justify-between text-xs text-gray-600 mt-2"
data-testid="wizard-stepper-desktop-labels" data-testid="wizard-stepper-desktop-labels"
> >
{stepNames.map((step, index) => ( {stepNames.map((step, index) => (
<span <span
key={index} key={index}
className={`${index + 1 === currentStep ? 'font-semibold text-green-600' : '' className={`${
}`} index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
}`}
data-testid={`wizard-stepper-desktop-label-${index + 1}`} data-testid={`wizard-stepper-desktop-label-${index + 1}`}
> >
{step} {step}

View File

@ -6,7 +6,7 @@ export interface DocumentData {
documentId: string; documentId: string;
name: string; name: string;
fileType: string; fileType: string;
size?: string; size: string;
sizeBytes?: number; sizeBytes?: number;
uploadedBy?: string; uploadedBy?: string;
uploadedAt: string; uploadedAt: string;
@ -48,9 +48,7 @@ export function DocumentCard({
{document.name} {document.name}
</p> </p>
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}> <p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
{document.size && <span>{document.size} </span>} {document.size} Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
{formatDateTime(document.uploadedAt)}
</p> </p>
</div> </div>
</div> </div>

View File

@ -9,7 +9,6 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react'; import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
import { TokenManager, isTokenExpired } from '../utils/tokenManager'; import { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi'; import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
import { tanflowLogout } from '../services/tanflowAuth';
interface User { interface User {
userId?: string; userId?: string;
@ -73,15 +72,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout) // PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
const logoutFlag = sessionStorage.getItem('__logout_in_progress__'); const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__'); const forceLogout = sessionStorage.getItem('__force_logout__');
if (logoutFlag === 'true' || forceLogout === 'true') { if (logoutFlag === 'true' || forceLogout === 'true') {
// Remove flags // Remove flags
sessionStorage.removeItem('__logout_in_progress__'); sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__'); sessionStorage.removeItem('__force_logout__');
// Clear all tokens one more time (aggressive) // Clear all tokens one more time (aggressive)
TokenManager.clearAll(); TokenManager.clearAll();
// Also manually clear everything // Also manually clear everything
try { try {
localStorage.clear(); localStorage.clear();
@ -89,81 +88,71 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} catch (e) { } catch (e) {
console.error('Error clearing storage:', e); console.error('Error clearing storage:', e);
} }
// Set unauthenticated state // Set unauthenticated state
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setIsLoading(false); setIsLoading(false);
setError(null); setError(null);
return; return;
} }
// PRIORITY 2: Check if URL has logout parameter (from redirect) // PRIORITY 2: Check if URL has logout parameter (from redirect)
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) { if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
TokenManager.clearAll(); TokenManager.clearAll();
// Clear auth provider flag and logout-related flags
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__');
sessionStorage.removeItem('tanflow_logged_out');
localStorage.clear(); localStorage.clear();
// Don't clear sessionStorage completely - we might need logout flags sessionStorage.clear();
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setIsLoading(false); setIsLoading(false);
// Clean URL but preserve logout flags if they exist (for prompt=login) // Clean URL but preserve okta_logged_out flag if it exists (for prompt=login)
const cleanParams = new URLSearchParams(); const cleanParams = new URLSearchParams();
if (urlParams.has('okta_logged_out')) { if (urlParams.has('okta_logged_out')) {
cleanParams.set('okta_logged_out', 'true'); cleanParams.set('okta_logged_out', 'true');
} }
if (urlParams.has('tanflow_logged_out')) {
cleanParams.set('tanflow_logged_out', 'true');
}
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/'; const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
window.history.replaceState({}, document.title, newUrl); window.history.replaceState({}, document.title, newUrl);
return; return;
} }
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first // PRIORITY 3: Skip auth check if on callback page - let callback handler process first
// This is 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') {
// Don't check auth status here - let the callback handler do its job // Don't check auth status here - let the callback handler do its job
// The callback handler will set isAuthenticated after successful token exchange // The callback handler will set isAuthenticated after successful token exchange
return; return;
} }
// PRIORITY 4: Check authentication status // PRIORITY 4: Check authentication status
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData(); const userData = TokenManager.getUserData();
const hasAuthData = token || refreshToken || userData; const hasAuthData = token || refreshToken || userData;
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS) // Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production: Always verify with server (cookies are sent automatically) // In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first // In development: Check local auth data first
if (isProductionMode) { if (isProductionMode) {
// 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);
setIsLoading(false); setIsLoading(false);
return; return;
} }
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out // PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) { if (!isLoggingOut) {
checkAuthStatus(); checkAuthStatus();
@ -211,7 +200,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Handle callback from OAuth redirect // Handle callback from OAuth redirect
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev) // Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
const callbackProcessedRef = useRef(false); const callbackProcessedRef = useRef(false);
useEffect(() => { useEffect(() => {
// Skip if already processed or not on callback page // Skip if already processed or not on callback page
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') { if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
@ -219,57 +208,24 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} }
const handleCallback = async () => { const handleCallback = async () => {
const urlParams = new URLSearchParams(window.location.search);
// Check if this is a logout redirect (from Tanflow post-logout redirect)
// If it has logout parameters but no code, it's a logout redirect, not a login callback
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
// This is a logout redirect, not a login callback
// Redirect to home page - the mount useEffect will handle logout cleanup
console.log('🚪 Logout redirect detected in callback, redirecting to home');
// Extract the logout flags from current URL
const logoutFlags = new URLSearchParams();
if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true');
if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true');
if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString());
const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now();
window.location.replace(redirectUrl);
return;
}
// Mark as processed immediately to prevent duplicate calls // Mark as processed immediately to prevent duplicate calls
callbackProcessedRef.current = true; callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code'); const code = urlParams.get('code');
const errorParam = urlParams.get('error'); const errorParam = urlParams.get('error');
// Clean URL immediately to prevent re-running on re-renders // Clean URL immediately to prevent re-running on re-renders
window.history.replaceState({}, document.title, '/login/callback'); window.history.replaceState({}, document.title, '/login/callback');
// Detect provider from sessionStorage
const authProvider = sessionStorage.getItem('auth_provider');
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
if (authProvider === 'tanflow') {
// Clear the provider flag and let TanflowCallback handle it
// Reset ref so TanflowCallback can process
callbackProcessedRef.current = false;
return;
}
// Handle OKTA callback (default)
if (errorParam) { if (errorParam) {
setError(new Error(`Authentication error: ${errorParam}`)); setError(new Error(`Authentication error: ${errorParam}`));
setIsLoading(false); setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return; return;
} }
if (!code) { if (!code) {
setIsLoading(false); setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return; return;
} }
@ -277,21 +233,18 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(true); setIsLoading(true);
setIsAuthenticated(false); setIsAuthenticated(false);
setError(null); setError(null);
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request // IMPORTANT: redirectUri must match the one used in initial Okta authorization request
// This is the frontend callback URL, NOT the backend URL // This is the frontend callback URL, NOT the backend URL
// Backend will use this same URI when exchanging code with Okta // Backend will use this same URI when exchanging code with Okta
const redirectUri = `${window.location.origin}/login/callback`; const redirectUri = `${window.location.origin}/login/callback`;
const result = await exchangeCodeForTokens(code, redirectUri); const result = await exchangeCodeForTokens(code, redirectUri);
setUser(result.user); setUser(result.user);
setIsAuthenticated(true); setIsAuthenticated(true);
setError(null); setError(null);
// Clear provider flag after successful authentication
sessionStorage.removeItem('auth_provider');
// Clean URL after success // Clean URL after success
window.history.replaceState({}, document.title, '/'); window.history.replaceState({}, document.title, '/');
} catch (err: any) { } catch (err: any) {
@ -299,8 +252,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setError(err); setError(err);
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
// Clear provider flag on error
sessionStorage.removeItem('auth_provider');
// Reset ref on error so user can retry if needed // Reset ref on error so user can retry if needed
callbackProcessedRef.current = false; callbackProcessedRef.current = false;
} finally { } finally {
@ -317,17 +268,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(false); setIsLoading(false);
return; return;
} }
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
setIsLoading(true); setIsLoading(true);
// 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();
// Try to get current user from server - this validates the httpOnly cookie // Try to get current user from server - this validates the httpOnly cookie
try { try {
const userData = await getCurrentUser(); const userData = await getCurrentUser();
@ -368,8 +319,8 @@ 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,27 +405,24 @@ 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';
const scope = 'openid profile email'; const scope = 'openid profile email';
const state = Math.random().toString(36).substring(7); const state = Math.random().toString(36).substring(7);
// Store provider type to identify OKTA callback
sessionStorage.setItem('auth_provider', 'okta');
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication // Check if we're coming from a logout - if so, add prompt=login to force re-authentication
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out'); const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out');
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` + let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
`client_id=${clientId}&` + `client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` + `redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=${responseType}&` + `response_type=${responseType}&` +
`scope=${encodeURIComponent(scope)}&` + `scope=${encodeURIComponent(scope)}&` +
`state=${state}`; `state=${state}`;
// Add prompt=login if coming from logout to force re-authentication // Add prompt=login if coming from logout to force re-authentication
// This ensures Okta requires login even if a session still exists // This ensures Okta requires login even if a session still exists
if (isAfterLogout) { if (isAfterLogout) {
@ -490,84 +438,50 @@ 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 // Okta logout endpoint works better with id_token_hint to properly end the session
const idToken = TokenManager.getIdToken(); // Note: Currently not used but kept for future Okta integration
void TokenManager.getIdToken();
// 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
const authProvider = sessionStorage.getItem('auth_provider') ||
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
'okta'; // Default to OKTA if unknown
// Set logout flag to prevent auto-authentication after redirect // Set logout flag to prevent auto-authentication after redirect
// This must be set BEFORE clearing storage so it survives // This must be set BEFORE clearing storage so it survives
sessionStorage.setItem('__logout_in_progress__', 'true'); sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true'); sessionStorage.setItem('__force_logout__', 'true');
setIsLoggingOut(true); setIsLoggingOut(true);
// Reset auth state FIRST to prevent any re-authentication // Reset auth state FIRST to prevent any re-authentication
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setError(null); setError(null);
setIsLoading(true); // Set loading to prevent checkAuthStatus from running setIsLoading(true); // Set loading to prevent checkAuthStatus from running
// Call backend logout API to clear server-side session and httpOnly cookies // Call backend logout API to clear server-side session and httpOnly cookies
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies // IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
try { try {
await logoutApi(); await logoutApi();
console.log('🚪 Backend logout API called successfully');
} catch (err) { } catch (err) {
console.error('🚪 Logout API error:', err); console.error('🚪 Logout API error:', err);
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared'); console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
// Continue with logout even if API call fails // Continue with logout even if API call fails
} }
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens // Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout)
// Clear tokens but preserve logout flags
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__'); const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__'); const forceLogout = sessionStorage.getItem('__force_logout__');
const storedAuthProvider = sessionStorage.getItem('auth_provider');
// Use TokenManager.clearAll() but then restore logout flags
// Clear all tokens EXCEPT id_token (we need it for provider logout)
// Note: We'll clear id_token after provider logout
// Clear tokens (but we'll restore id_token if needed)
TokenManager.clearAll(); TokenManager.clearAll();
// Restore logout flags and id_token immediately after clearAll // Restore logout flags immediately after clearAll
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress); if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout); if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
if (idToken) {
TokenManager.setIdToken(idToken); // Restore id_token for provider logout
}
if (storedAuthProvider) {
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
}
// Small delay to ensure sessionStorage is written before redirect // Small delay to ensure sessionStorage is written before redirect
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
// Handle provider-specific logout // Redirect directly to login page with flags
if (authProvider === 'tanflow' && idToken) {
console.log('🚪 Initiating Tanflow logout...');
// Tanflow logout - redirect to Tanflow logout endpoint
// This will clear Tanflow session and redirect back to our app
try {
tanflowLogout(idToken);
// tanflowLogout will redirect, so we don't need to do anything else here
return;
} catch (tanflowLogoutError) {
console.error('🚪 Tanflow logout error:', tanflowLogoutError);
// Fall through to default logout flow
}
}
// OKTA logout or fallback: Clear auth_provider and redirect to login page with flags
console.log('🚪 Using OKTA logout flow or fallback');
sessionStorage.removeItem('auth_provider');
// Clear id_token now since we're not using provider logout
if (idToken) {
TokenManager.clearAll(); // Clear id_token too
}
// The okta_logged_out flag will trigger prompt=login in the login() function // The okta_logged_out flag will trigger prompt=login in the login() function
// This forces re-authentication even if Okta session still exists // This forces re-authentication even if Okta session still exists
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`; const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
@ -590,7 +504,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const getAccessTokenSilently = async (): Promise<string | null> => { const getAccessTokenSilently = async (): Promise<string | null> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production mode, tokens are in httpOnly cookies // In production mode, tokens are in httpOnly cookies
// We can't access them directly, but API calls will include them automatically // We can't access them directly, but API calls will include them automatically
if (isProductionMode) { if (isProductionMode) {
@ -599,7 +513,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
if (isAuthenticated) { if (isAuthenticated) {
return 'cookie-based-auth'; // Placeholder - actual auth via cookies return 'cookie-based-auth'; // Placeholder - actual auth via cookies
} }
// Try to refresh the session // Try to refresh the session
try { try {
await refreshTokenSilently(); await refreshTokenSilently();
@ -608,8 +522,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return null; return null;
} }
} }
// 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;
@ -626,17 +540,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const refreshTokenSilently = async (): Promise<void> => { const refreshTokenSilently = async (): Promise<void> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production'; const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
const newToken = await refreshAccessToken(); const newToken = await refreshAccessToken();
// In production, refresh might not return token (it's in httpOnly cookie) // In production, refresh might not return token (it's in httpOnly cookie)
// but if the call succeeded, the session is valid // but if the call succeeded, the session is valid
if (isProductionMode) { if (isProductionMode) {
// Session refreshed via cookies // Session refreshed via cookies
return; return;
} }
if (newToken) { if (newToken) {
// Token refreshed successfully (development mode) // Token refreshed successfully (development mode)
return; return;
@ -672,7 +586,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',

View File

@ -1,172 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, Flame, Target, CheckCircle, XCircle, X } from 'lucide-react';
interface ClosedRequestsFiltersProps {
searchTerm: string;
priorityFilter: string;
statusFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
activeFiltersCount: number;
onSearchChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onStatusChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
}
/**
* Standard Closed Requests Filters Component
*
* Used for regular users (non-dealers).
* Includes: Search, Priority, Status (Closure Type), Template Type, and Sort filters.
*/
export function StandardClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
templateTypeFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
onTemplateTypeChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
}: ClosedRequestsFiltersProps) {
return (
<Card className="shadow-lg border-0" data-testid="closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="closed-requests-clear-filters"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white transition-colors"
data-testid="closed-requests-search"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<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-priority-filter">
<SelectValue placeholder="All Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}>
<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-status-filter">
<SelectValue placeholder="Closure Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Closures</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Closed After Approval</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Closed After Rejection</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<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">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<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-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="closed-requests-sort-order"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,161 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, Flame, Target } from 'lucide-react';
interface RequestsFiltersProps {
searchTerm: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusFilterChange: (value: string) => void;
onPriorityFilterChange: (value: string) => void;
onTemplateTypeFilterChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Standard Requests Filters Component
*
* Used for regular users (non-dealers).
* Includes: Search, Status, Priority, Template Type, and Sort filters.
*/
export function StandardRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
templateTypeFilter,
sortBy,
sortOrder,
onSearchChange,
onStatusFilterChange,
onPriorityFilterChange,
onTemplateTypeFilterChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
}: RequestsFiltersProps) {
return (
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 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 transition-colors"
/>
</div>
<Select value={priorityFilter} onValueChange={onPriorityFilterChange}>
<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 Priorities" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priorities</SelectItem>
<SelectItem value="express">
<div className="flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-600" />
<span>Express</span>
</div>
</SelectItem>
<SelectItem value="standard">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-blue-600" />
<span>Standard</span>
</div>
</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}>
<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 Statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending (In Approval)</SelectItem>
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
</SelectContent>
</Select>
<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">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
<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="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,457 +0,0 @@
/**
* Standard User All Requests Filters Component
*
* Full filters for regular users (non-dealers).
* Includes: Search, Status, Priority, Template Type, Department, SLA Compliance,
* Initiator, Approver, and Date Range filters.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker';
interface StandardUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
departmentFilter: string;
slaComplianceFilter: string;
initiatorFilter: string;
approverFilter: string;
approverFilterType: 'current' | 'any';
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// Departments
departments: string[];
loadingDepartments: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onDepartmentChange: (value: string) => void;
onSlaComplianceChange: (value: string) => void;
onInitiatorChange?: (value: string) => void;
onApproverChange?: (value: string) => void;
onApproverTypeChange?: (value: 'current' | 'any') => void;
onDateRangeChange: (value: DateRange) => void;
onCustomStartDateChange?: (date: Date | undefined) => void;
onCustomEndDateChange?: (date: Date | undefined) => void;
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
export function StandardUserAllRequestsFilters({
searchTerm,
statusFilter,
priorityFilter,
templateTypeFilter,
departmentFilter,
slaComplianceFilter,
initiatorFilter: _initiatorFilter,
approverFilter,
approverFilterType,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
departments,
loadingDepartments,
initiatorSearch,
approverSearch,
onSearchChange,
onStatusChange,
onPriorityChange,
onTemplateTypeChange,
onDepartmentChange,
onSlaComplianceChange,
onInitiatorChange: _onInitiatorChange,
onApproverChange: _onApproverChange,
onApproverTypeChange,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onClearFilters,
hasActiveFilters,
}: StandardUserAllRequestsFiltersProps) {
return (
<Card className="border-gray-200 shadow-md" data-testid="user-all-requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
{hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 h-10"
data-testid="search-input"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={priorityFilter} onValueChange={onPriorityChange}>
<SelectTrigger className="h-10" data-testid="priority-filter">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</Select>
<Select
value={departmentFilter}
onValueChange={onDepartmentChange}
disabled={loadingDepartments || departments.length === 0}
>
<SelectTrigger className="h-10" data-testid="department-filter">
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={slaComplianceFilter} onValueChange={onSlaComplianceChange}>
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
<SelectValue placeholder="All SLA Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA Status</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{approverFilter !== 'all' && onApproverTypeChange && (
<Select
value={approverFilterType}
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<CustomDatePicker
value={customStartDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomStartDateChange?.(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange?.(date);
}
} else {
onCustomStartDateChange?.(undefined);
}
}}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<CustomDatePicker
value={customEndDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomEndDateChange?.(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange?.(date);
}
} else {
onCustomEndDateChange?.(undefined);
}
}}
minDate={customStartDate || undefined}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply Range
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange?.(false);
onCustomStartDateChange?.(undefined);
onCustomEndDateChange?.(undefined);
onDateRangeChange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -23,10 +23,5 @@ export { CreateRequest as CustomCreateRequest } from './components/request-creat
// Request Detail Screen (Complete standalone screen) // Request Detail Screen (Complete standalone screen)
export { CustomRequestDetail } from './pages/RequestDetail'; export { CustomRequestDetail } from './pages/RequestDetail';
// Filters
export { StandardRequestsFilters } from './components/RequestsFilters';
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
// Re-export types // Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types'; export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -37,8 +37,6 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useConclusionRemark } from '@/hooks/useConclusionRemark'; import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { useModalManager } from '@/hooks/useModalManager'; import { useModalManager } from '@/hooks/useModalManager';
import { downloadDocument } from '@/services/workflowApi'; import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
// Custom Request Components (import from index to get properly aliased exports) // Custom Request Components (import from index to get properly aliased exports)
import { CustomOverviewTab, CustomWorkflowTab } from '../index'; import { CustomOverviewTab, CustomWorkflowTab } from '../index';
@ -114,24 +112,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
const [showPauseModal, setShowPauseModal] = useState(false); const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false); const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const { user } = useAuth(); const { user } = useAuth();
// Custom hooks // Custom hooks
@ -197,37 +177,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
aiGenerated, aiGenerated,
handleGenerateConclusion, handleGenerateConclusion,
handleFinalizeConclusion, handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached,
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal); } = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Auto-switch tab when URL query parameter changes // Auto-switch tab when URL query parameter changes
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -281,7 +232,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
}; };
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator; const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
const isClosed = request?.status === 'closed'; const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// Fetch summary details if request is closed // Fetch summary details if request is closed
useEffect(() => { useEffect(() => {
@ -513,9 +464,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
currentUserIsApprover={!!currentApprovalLevel} currentUserIsApprover={!!currentApprovalLevel}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId} pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId} currentUserId={(user as any)?.userId}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/> />
</TabsContent> </TabsContent>
@ -573,8 +521,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
isSpectator={isSpectator} isSpectator={isSpectator}
currentLevels={currentLevels} currentLevels={currentLevels}
onAddApprover={handleAddApprover} onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
</TabsContent> </TabsContent>
</div> </div>
@ -664,8 +610,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
actionStatus={actionStatus} actionStatus={actionStatus}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal} setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal} setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal} setShowAddApproverModal={setShowAddApproverModal}
@ -684,19 +628,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
downloadDocument={downloadDocument} downloadDocument={downloadDocument}
documentPolicy={documentPolicy} documentPolicy={documentPolicy}
/> />
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</> </>
); );
} }

View File

@ -1,142 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X, CheckCircle, XCircle } from 'lucide-react';
interface DealerClosedRequestsFiltersProps {
searchTerm: string;
statusFilter?: string;
priorityFilter?: string;
templateTypeFilter?: string;
sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusChange?: (value: string) => void;
onPriorityChange?: (value: string) => void;
onTemplateTypeChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Dealer Closed Requests Filters Component
*
* Simplified filters for dealer users viewing closed requests.
* Only includes: Search, Status (closure type), and Sort filters.
* Removes: Priority and Template Type filters.
*/
export function DealerClosedRequestsFilters({
searchTerm,
statusFilter = 'all',
sortBy,
sortOrder,
onSearchChange,
onStatusChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
...rest // Accept but ignore other props for interface compatibility
}: DealerClosedRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="shadow-lg border-0" data-testid="dealer-closed-requests-filters">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
data-testid="dealer-closed-requests-clear-filters"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Dealer-specific filters - Search, Status (Closure Type), and Sort */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 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 transition-colors"
data-testid="dealer-closed-requests-search"
/>
</div>
{onStatusChange && (
<Select value={statusFilter} onValueChange={onStatusChange}>
<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" data-testid="dealer-closed-requests-status-filter">
<SelectValue placeholder="Closure Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Closures</SelectItem>
<SelectItem value="approved">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" />
<span>Closed After Approval</span>
</div>
</SelectItem>
<SelectItem value="rejected">
<div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" />
<span>Closed After Rejection</span>
</div>
</SelectItem>
</SelectContent>
</Select>
)}
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
<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" data-testid="dealer-closed-requests-sort-by">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={onSortOrderChange}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
data-testid="dealer-closed-requests-sort-order"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,114 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Filter, Search, SortAsc, SortDesc, X } from 'lucide-react';
interface DealerRequestsFiltersProps {
searchTerm: string;
statusFilter?: string;
priorityFilter?: string;
templateTypeFilter?: string;
sortBy: 'created' | 'due' | 'priority' | 'sla';
sortOrder: 'asc' | 'desc';
onSearchChange: (value: string) => void;
onStatusFilterChange?: (value: string) => void;
onPriorityFilterChange?: (value: string) => void;
onTemplateTypeFilterChange?: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority' | 'sla') => void;
onSortOrderChange: (value: 'asc' | 'desc') => void;
onClearFilters: () => void;
activeFiltersCount: number;
}
/**
* Dealer Requests Filters Component
*
* Simplified filters for dealer users.
* Only includes: Search and Sort filters (no status, priority, or template type).
*/
export function DealerRequestsFilters({
searchTerm,
sortBy,
sortOrder,
onSearchChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
activeFiltersCount,
...rest // Accept but ignore other props for interface compatibility
}: DealerRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="shadow-lg border-0">
<CardHeader className="pb-3 sm:pb-4 px-3 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 sm:gap-3">
<div className="p-1.5 sm:p-2 bg-blue-100 rounded-lg">
<Filter className="h-4 w-4 sm:h-5 sm:w-5 text-blue-600" />
</div>
<div>
<CardTitle className="text-base sm:text-lg">Filters & Search</CardTitle>
<CardDescription className="text-xs sm:text-sm">
{activeFiltersCount > 0 && (
<span className="text-blue-600 font-medium">
{activeFiltersCount} filter{activeFiltersCount > 1 ? 's' : ''} active
</span>
)}
</CardDescription>
</div>
</div>
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={onClearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
</div>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Dealer-specific filters - Only Search and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
<Input
placeholder="Search requests, IDs..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 sm:pl-10 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 transition-colors"
/>
</div>
<div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
<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="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due">Due Date</SelectItem>
<SelectItem value="created">Date Created</SelectItem>
<SelectItem value="priority">Priority</SelectItem>
<SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => onSortOrderChange(sortOrder === 'asc' ? 'desc' : 'asc')}
className="px-2 sm:px-3 h-9 sm:h-10 md:h-11"
>
{sortOrder === 'asc' ? <SortAsc className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> : <SortDesc className="w-3.5 h-3.5 sm:w-4 sm:h-4" />}
</Button>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,389 +0,0 @@
/**
* Dealer User All Requests Filters Component
*
* Simplified filters for dealer users viewing their all requests.
* Only includes: Search, Status, Initiator, Approver, and Date Range filters.
* Removes: Priority, Template Type, Department, and SLA Compliance filters.
*/
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
import type { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker';
interface DealerUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
statusFilter: string;
priorityFilter?: string;
templateTypeFilter?: string;
departmentFilter?: string;
slaComplianceFilter?: string;
initiatorFilter: string;
approverFilter: string;
approverFilterType: 'current' | 'any';
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
searchResults: Array<{ userId: string; email: string; displayName?: string }>;
showResults: boolean;
handleSearch: (query: string) => void;
handleSelect: (user: { userId: string; email: string; displayName?: string }) => void;
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onInitiatorChange?: (value: string) => void;
onApproverChange?: (value: string) => void;
onApproverTypeChange?: (value: 'current' | 'any') => void;
onDateRangeChange: (value: DateRange) => void;
onCustomStartDateChange?: (date: Date | undefined) => void;
onCustomEndDateChange?: (date: Date | undefined) => void;
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
export function DealerUserAllRequestsFilters({
searchTerm,
statusFilter,
initiatorFilter,
approverFilter,
approverFilterType,
dateRange,
customStartDate,
customEndDate,
showCustomDatePicker,
initiatorSearch,
approverSearch,
onSearchChange,
onStatusChange,
onInitiatorChange,
onApproverChange,
onApproverTypeChange,
onDateRangeChange,
onCustomStartDateChange,
onCustomEndDateChange,
onShowCustomDatePickerChange,
onApplyCustomDate,
onClearFilters,
hasActiveFilters,
...rest // Accept but ignore other props for interface compatibility
}: DealerUserAllRequestsFiltersProps) {
void rest; // Explicitly mark as unused
return (
<Card className="border-gray-200 shadow-md" data-testid="dealer-user-all-requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Filters</h3>
{hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={onClearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters - Only Search and Status for dealers */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10 h-10"
data-testid="dealer-search-input"
/>
</div>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="dealer-status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="dealer-initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{approverFilter !== 'all' && onApproverTypeChange && (
<Select
value={approverFilterType}
onValueChange={(value: 'current' | 'any') => onApproverTypeChange(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="dealer-approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={(value) => onDateRangeChange(value as DateRange)}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Time</SelectItem>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="last7days">Last 7 Days</SelectItem>
<SelectItem value="last30days">Last 30 Days</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<CustomDatePicker
value={customStartDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomStartDateChange?.(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange?.(date);
}
} else {
onCustomStartDateChange?.(undefined);
}
}}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<CustomDatePicker
value={customEndDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomEndDateChange?.(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange?.(date);
}
} else {
onCustomEndDateChange?.(undefined);
}
}}
minDate={customStartDate || undefined}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
onShowCustomDatePickerChange?.(false);
onCustomStartDateChange?.(undefined);
onCustomEndDateChange?.(undefined);
onDateRangeChange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -1,10 +1,9 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, 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 { RichTextEditor } from '@/components/ui/rich-text-editor'; import { Textarea } from '@/components/ui/textarea';
import { FormattedDescription } from '@/components/common/FormattedDescription';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
@ -24,34 +23,34 @@ import {
Info, Info,
FileText, FileText,
Users, Users,
XCircle,
Loader2,
} 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, getDealerByCode, 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 { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep)
const CLAIM_STEPS = [
{ level: 1, name: 'Dealer Proposal Submission', description: 'Dealer submits proposal documents', defaultTat: 72, isAuto: false, approverType: 'dealer' },
{ level: 2, name: 'Requestor Evaluation', description: 'Initiator evaluates dealer proposal', defaultTat: 48, isAuto: false, approverType: 'initiator' },
{ level: 3, name: 'Department Lead Approval', description: 'Department lead approves and blocks IO budget', defaultTat: 72, isAuto: false, approverType: 'manual' },
{ level: 4, name: 'Activity Creation', description: 'System auto-processes activity creation', defaultTat: 1, isAuto: true, approverType: 'system' },
{ level: 5, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' },
{ level: 6, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' },
{ level: 7, name: 'E-Invoice Generation', description: 'System generates e-invoice via DMS', defaultTat: 1, isAuto: true, approverType: 'system' },
{ level: 8, name: 'Credit Note Confirmation', description: 'System/Finance processes credit note confirmation', defaultTat: 48, isAuto: true, approverType: 'system' },
];
interface ClaimManagementWizardProps { interface ClaimManagementWizardProps {
onBack?: () => void; onBack?: () => void;
onSubmit?: (claimData: any) => void; onSubmit?: (claimData: any) => void;
} }
const CLAIM_TYPES = [
'Riders Mania Claims',
'Marketing Cost Bike to Vendor',
'Media Bike Service',
'ARAI Motorcycle Liquidation',
'ARAI Certification STA Approval CNR',
'Procurement of Spares/Apparel/GMA for Events',
'Fuel for Media Bike Used for Event',
'Motorcycle Buyback and Goodwill Support',
'Liquidation of Used Motorcycle',
'Motorcycle Registration CNR (Owned or Gifted by RE)',
'Legal Claims Reimbursement',
'Service Camp Claims',
'Corporate Claims Institutional Sales PDI'
];
const STEP_NAMES = [ const STEP_NAMES = [
'Claim Details', 'Claim Details',
'Approver Selection', 'Approver Selection',
@ -61,86 +60,9 @@ const STEP_NAMES = [
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) { export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const { user } = useAuth(); const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [verifyingDealer, setVerifyingDealer] = useState(false); const [dealers, setDealers] = useState<DealerInfo[]>([]);
const [dealerSearchResults, setDealerSearchResults] = useState<DealerInfo[]>([]); const [loadingDealers, setLoadingDealers] = useState(true);
const [dealerSearchLoading, setDealerSearchLoading] = useState(false);
const [dealerSearchInput, setDealerSearchInput] = useState('');
const dealerSearchTimer = useRef<any>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const submitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// System policy state
const [systemPolicy, setSystemPolicy] = useState({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const [activityTypes, setActivityTypes] = useState<ActivityType[]>([]);
const [loadingActivityTypes, setLoadingActivityTypes] = useState(true);
// Load activity types from API on mount
useEffect(() => {
const loadActivityTypes = async () => {
try {
setLoadingActivityTypes(true);
const types = await getActivityTypes();
setActivityTypes(types);
} catch (error) {
console.error('Failed to load activity types:', error);
toast.error('Failed to load activity types. Please refresh the page.');
} finally {
setLoadingActivityTypes(false);
}
};
loadActivityTypes();
}, []);
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (submitTimeoutRef.current) {
clearTimeout(submitTimeoutRef.current);
}
};
}, []);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
activityName: '', activityName: '',
activityType: '', activityType: '',
@ -163,68 +85,32 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
level: number; level: number;
tat?: number | string; tat?: number | string;
tatType?: 'hours' | 'days'; tatType?: 'hours' | 'days';
isAdditional?: boolean;
insertAfterLevel?: number;
stepName?: string;
originalStepLevel?: number;
}> }>
}); });
const totalSteps = STEP_NAMES.length; const totalSteps = STEP_NAMES.length;
// Handle dealer search input with debouncing // Fetch dealers from API on component mount
const handleDealerSearchInputChange = (value: string) => { useEffect(() => {
setDealerSearchInput(value); const fetchDealers = async () => {
setLoadingDealers(true);
// Clear previous timer
if (dealerSearchTimer.current) {
clearTimeout(dealerSearchTimer.current);
}
// If input is empty, clear results
if (!value || value.trim().length < 2) {
setDealerSearchResults([]);
setDealerSearchLoading(false);
return;
}
// Set loading state
setDealerSearchLoading(true);
// Debounce search
dealerSearchTimer.current = setTimeout(async () => {
try { try {
const result = await searchExternalDealerByCode(value); const fetchedDealers = await fetchDealersFromAPI();
if (result) { setDealers(fetchedDealers);
// 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); toast.error('Failed to load dealer list.');
setDealerSearchResults([]); console.error('Error fetching dealers:', error);
} finally { } finally {
setDealerSearchLoading(false); setLoadingDealers(false);
} }
}, 300); };
}; fetchDealers();
}, []);
const updateFormData = (field: string, value: any) => { const updateFormData = (field: string, value: any) => {
setFormData(prev => { setFormData(prev => {
const updated = { ...prev, [field]: value }; const updated = { ...prev, [field]: value };
// Validate period dates // Validate period dates
if (field === 'periodStartDate') { if (field === 'periodStartDate') {
// If start date is selected and end date exists, validate end date // If start date is selected and end date exists, validate end date
@ -241,7 +127,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
return prev; return prev;
} }
} }
return updated; return updated;
}); });
}; };
@ -249,20 +135,17 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const isStepValid = () => { const isStepValid = () => {
switch (currentStep) { switch (currentStep) {
case 1: case 1:
return formData.activityName && return formData.activityName &&
formData.activityType && formData.activityType &&
formData.dealerCode && formData.dealerCode &&
formData.dealerName && formData.dealerName &&
formData.activityDate && formData.activityDate &&
formData.location && formData.location &&
formData.requestDescription; formData.requestDescription;
case 2: case 2:
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance) // Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level const step3Approver = approvers.find((a: any) => a.level === 3);
const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
);
// Step 8 is now a system step, no validation needed // Step 8 is now a system step, no validation needed
return step3Approver?.email && step3Approver?.userId && step3Approver?.tat; return step3Approver?.email && step3Approver?.userId && step3Approver?.tat;
case 3: case 3:
@ -278,16 +161,13 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Show specific error messages for step 2 (approver selection) // Show specific error messages for step 2 (approver selection)
if (currentStep === 2) { if (currentStep === 2) {
const approvers = formData.approvers || []; const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level const step3Approver = approvers.find((a: any) => a.level === 3);
const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
);
const missingSteps: string[] = []; const missingSteps: string[] = [];
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) { if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
missingSteps.push('Department Lead Approval'); missingSteps.push('Step 3: Department Lead Approval');
} }
if (missingSteps.length > 0) { if (missingSteps.length > 0) {
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`); toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
} else { } else {
@ -308,161 +188,43 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
} }
}; };
const handleDealerSelect = async (selectedDealer: DealerInfo) => { const handleDealerChange = async (dealerCode: string) => {
// Verify dealer is logged in const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
setVerifyingDealer(true); if (selectedDealer) {
try { updateFormData('dealerCode', dealerCode);
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode); updateFormData('dealerName', selectedDealer.dealerName);
updateFormData('dealerEmail', selectedDealer.email || '');
if (!verifiedDealer.isLoggedIn) { updateFormData('dealerPhone', selectedDealer.phone || '');
toast.error(
`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`,
{ duration: 5000 }
);
// Clear the selection
setDealerSearchInput('');
setDealerSearchResults([]);
updateFormData('dealerCode', '');
updateFormData('dealerName', '');
updateFormData('dealerEmail', '');
updateFormData('dealerPhone', '');
updateFormData('dealerAddress', '');
setVerifyingDealer(false);
return;
}
// Dealer is logged in, update form data
updateFormData('dealerCode', verifiedDealer.dealerCode);
updateFormData('dealerName', verifiedDealer.dealerName || verifiedDealer.displayName);
updateFormData('dealerEmail', verifiedDealer.email || '');
updateFormData('dealerPhone', verifiedDealer.phone || '');
updateFormData('dealerAddress', ''); // Address not available in API response updateFormData('dealerAddress', ''); // Address not available in API response
// Clear search input and results // Try to fetch full dealer info from API
setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName); try {
setDealerSearchResults([]); const fullDealerInfo = await getDealerByCode(dealerCode);
if (fullDealerInfo) {
toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`); updateFormData('dealerEmail', fullDealerInfo.email || selectedDealer.email || '');
} catch (error: any) { updateFormData('dealerPhone', fullDealerInfo.phone || selectedDealer.phone || '');
const errorMessage = 'Dealer is not mapped to the system' }
toast.error(errorMessage, { duration: 5000 }); } catch (error) {
// Clear the selection // Ignore error, use basic info from list
setDealerSearchInput(''); console.debug('Could not fetch full dealer info:', error);
setDealerSearchResults([]); }
updateFormData('dealerCode', '');
updateFormData('dealerName', '');
updateFormData('dealerEmail', '');
updateFormData('dealerPhone', '');
updateFormData('dealerAddress', '');
} finally {
setVerifyingDealer(false);
} }
}; };
const handleSubmit = () => { const handleSubmit = () => {
// Prevent multiple submissions
if (isSubmitting) {
return;
}
// Approvers are already using integer levels with proper shifting
// Just sort them and prepare for submission
const approvers = formData.approvers || [];
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
// Check for duplicate levels (should not happen, but safeguard)
const levelMap = new Map<number, typeof sortedApprovers[0]>();
const duplicates: number[] = [];
sortedApprovers.forEach((approver) => {
if (levelMap.has(approver.level)) {
duplicates.push(approver.level);
} else {
levelMap.set(approver.level, approver);
}
});
if (duplicates.length > 0) {
toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`);
console.error('Duplicate levels found:', duplicates, sortedApprovers);
return;
}
// Prepare final approvers array - preserve stepName for additional approvers
// The backend will use stepName to set the levelName for approval levels
// Also preserve originalStepLevel so backend can identify which step each approver belongs to
const finalApprovers = sortedApprovers.map((approver) => {
const result: any = {
email: approver.email,
name: approver.name,
userId: approver.userId,
level: approver.level,
tat: approver.tat,
tatType: approver.tatType,
};
// Preserve stepName for additional approvers
if (approver.isAdditional && approver.stepName) {
result.stepName = approver.stepName;
result.isAdditional = true;
}
// Preserve originalStepLevel for fixed steps (so backend can identify which step this is)
if (approver.originalStepLevel) {
result.originalStepLevel = approver.originalStepLevel;
}
return result;
});
const claimData = { const claimData = {
...formData, ...formData,
templateType: 'claim-management', templateType: 'claim-management',
submittedAt: new Date().toISOString(), submittedAt: new Date().toISOString(),
status: 'pending', status: 'pending',
currentStep: 'initiator-review', currentStep: 'initiator-review',
// Pass normalized approvers array to backend // Pass approvers array to backend
approvers: finalApprovers approvers: formData.approvers || []
}; };
// Set submitting state to prevent multiple clicks
setIsSubmitting(true);
// Clear any existing timeout
if (submitTimeoutRef.current) {
clearTimeout(submitTimeoutRef.current);
}
// Set a timeout as a fallback to reset loading state (30 seconds)
// In most cases, the parent component will navigate away on success,
// but this prevents the button from being stuck in loading state if there's an error
submitTimeoutRef.current = setTimeout(() => {
setIsSubmitting(false);
submitTimeoutRef.current = null;
}, 30000);
// Don't show toast here - let the parent component handle success/error after API call // Don't show toast here - let the parent component handle success/error after API call
if (onSubmit) { if (onSubmit) {
try { onSubmit(claimData);
onSubmit(claimData);
// Note: On success, the component will unmount when parent navigates away (timeout cleared in useEffect)
// On error, the timeout will reset the state after 30 seconds
} catch (error) {
// If onSubmit throws synchronously, reset state immediately
if (submitTimeoutRef.current) {
clearTimeout(submitTimeoutRef.current);
submitTimeoutRef.current = null;
}
setIsSubmitting(false);
console.error('Error submitting claim:', error);
}
} else {
// If no onSubmit handler, reset immediately
if (submitTimeoutRef.current) {
clearTimeout(submitTimeoutRef.current);
submitTimeoutRef.current = null;
}
setIsSubmitting(false);
} }
}; };
@ -502,26 +264,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div> <div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label> <Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select <Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
value={formData.activityType}
onValueChange={(value) => updateFormData('activityType', value)}
disabled={loadingActivityTypes}
>
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="activityType"> <SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="activityType">
<SelectValue placeholder={loadingActivityTypes ? "Loading activity types..." : "Select activity type"} /> <SelectValue placeholder="Select activity type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{activityTypes.length > 0 ? ( {CLAIM_TYPES.map((type) => (
activityTypes.map((type) => ( <SelectItem key={type} value={type}>{type}</SelectItem>
<SelectItem key={type.activityTypeId} value={type.title}> ))}
{type.title}
</SelectItem>
))
) : (
<div className="px-2 py-1.5 text-sm text-gray-500 text-center">
No activity types available
</div>
)}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -530,99 +280,38 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{/* Dealer Selection */} {/* Dealer Selection */}
<div> <div>
<Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label> <Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
<div className="mt-2"> <Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
<div className="relative"> <SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="dealer-select">
<Input <SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
placeholder="Type dealer code, name, or email to search..." {formData.dealerCode && (
value={formData.dealerCode ? `${formData.dealerName} (${formData.dealerCode})` : dealerSearchInput} <div className="flex items-center gap-2">
onChange={(e) => { <span className="font-mono text-sm">{formData.dealerCode}</span>
if (formData.dealerCode) { <span className="text-gray-400"></span>
// If dealer is already selected, clear selection first <span>{formData.dealerName}</span>
updateFormData('dealerCode', ''); </div>
updateFormData('dealerName', ''); )}
updateFormData('dealerEmail', ''); </SelectValue>
updateFormData('dealerPhone', ''); </SelectTrigger>
updateFormData('dealerAddress', ''); <SelectContent>
setDealerSearchInput(e.target.value); {dealers.length === 0 && !loadingDealers ? (
} else { <div className="p-2 text-sm text-gray-500">No dealers available</div>
handleDealerSearchInputChange(e.target.value); ) : (
} dealers.map((dealer) => (
}} <SelectItem key={dealer.userId} value={dealer.dealerCode}>
onFocus={() => { <div className="flex items-center gap-2">
// When input is focused, show search results if input has value <span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
if (dealerSearchInput && dealerSearchInput.length >= 2) { <span className="text-gray-400"></span>
handleDealerSearchInputChange(dealerSearchInput); <span>{dealer.dealerName}</span>
} </div>
}} </SelectItem>
className="h-12 border-2 border-gray-300 focus:border-blue-500" ))
disabled={verifyingDealer}
/>
{formData.dealerCode && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<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>
)} )}
{/* Search suggestions dropdown */} </SelectContent>
{(dealerSearchLoading || dealerSearchResults.length > 0) && !formData.dealerCode && ( </Select>
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
{dealerSearchLoading ? (
<div className="p-2 text-xs text-gray-500">Searching...</div>
) : (
<ul className="max-h-56 overflow-auto divide-y">
{dealerSearchResults.map((dealer) => (
<li
key={dealer.dealerId}
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
onClick={() => handleDealerSelect(dealer)}
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="font-medium text-gray-900">
{dealer.dealerName || dealer.displayName}
</div>
<div className="text-xs text-gray-600">
<span className="font-mono">{dealer.dealerCode}</span>
{dealer.email && (
<>
<span className="mx-1"></span>
<span>{dealer.email}</span>
</>
)}
</div>
{dealer.city && dealer.state && (
<div className="text-xs text-gray-500">
{dealer.city}, {dealer.state}
</div>
)}
</div>
<div className="ml-2 flex-shrink-0">
{dealer.isLoggedIn ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<XCircle className="w-4 h-4 text-red-500" />
)}
</div>
</div>
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
{formData.dealerCode && ( {formData.dealerCode && (
<div className="mt-2 space-y-1"> <p className="text-sm text-gray-600 mt-2">
<p className="text-sm text-gray-600"> Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode}) </p>
</p>
{formData.dealerEmail && (
<p className="text-xs text-gray-500">Email: {formData.dealerEmail}</p>
)}
</div>
)} )}
</div> </div>
@ -634,10 +323,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start text-left mt-2 h-12 pl-3" className="w-full justify-start text-left mt-2 h-12"
> >
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" /> <CalendarIcon className="mr-2 h-4 w-4" />
<span className="flex-1 text-left">{formData.activityDate ? format(formData.activityDate, 'd MMM yyyy') : 'Select date'}</span> {formData.activityDate ? format(formData.activityDate, 'PPP') : 'Select date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
@ -666,19 +355,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{/* Request Detail */} {/* Request Detail */}
<div> <div>
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label> <Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
<p className="text-sm text-gray-600 mb-3"> <Textarea
Explain what you need approval for, why it's needed, and any relevant background information. id="requestDescription"
<span className="block mt-1 text-xs text-blue-600"> placeholder="Provide a detailed description of your claim requirement..."
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved. value={formData.requestDescription}
</span> onChange={(e) => updateFormData('requestDescription', e.target.value)}
</p> className="mt-2 min-h-[120px]"
<RichTextEditor
value={formData.requestDescription || ''}
onChange={(html) => updateFormData('requestDescription', html)}
placeholder="Provide comprehensive details about your claim requirement including scope, objectives, expected outcomes, and any supporting context that will help approvers make an informed decision."
className="min-h-[120px] text-base border-2 border-gray-300 focus-within:border-blue-500 bg-white shadow-sm"
minHeight="120px"
/> />
<p className="text-xs text-gray-500 mt-1">
Include key details about the claim, objectives, and expected outcomes
</p>
</div> </div>
{/* Period (Optional) */} {/* Period (Optional) */}
@ -694,10 +380,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start text-left mt-2 h-12 pl-3" className="w-full justify-start text-left mt-2 h-12"
> >
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" /> <CalendarIcon className="mr-2 h-4 w-4" />
<span className="flex-1 text-left">{formData.periodStartDate ? format(formData.periodStartDate, 'd MMM yyyy') : 'Start date'}</span> {formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Start date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
@ -719,11 +405,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start text-left mt-2 h-12 pl-3" className="w-full justify-start text-left mt-2 h-12"
disabled={!formData.periodStartDate} disabled={!formData.periodStartDate}
> >
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" /> <CalendarIcon className="mr-2 h-4 w-4" />
<span className="flex-1 text-left">{formData.periodEndDate ? format(formData.periodEndDate, 'd MMM yyyy') : 'End date'}</span> {formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start"> <PopoverContent className="w-auto p-0" align="start">
@ -746,11 +432,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div className="mt-2"> <div className="mt-2">
{formData.periodStartDate && formData.periodEndDate ? ( {formData.periodStartDate && formData.periodEndDate ? (
<p className="text-xs text-gray-600"> <p className="text-xs text-gray-600">
Period: {format(formData.periodStartDate, 'd MMM yyyy')} - {format(formData.periodEndDate, 'd MMM yyyy')} Period: {format(formData.periodStartDate, 'MMM dd, yyyy')} - {format(formData.periodEndDate, 'MMM dd, yyyy')}
</p> </p>
) : ( ) : (
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{formData.periodStartDate {formData.periodStartDate
? 'Please select end date for the period' ? 'Please select end date for the period'
: 'Please select start date first'} : 'Please select start date first'}
</p> </p>
@ -770,14 +456,12 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
currentUserEmail={(user as any)?.email || ''} currentUserEmail={(user as any)?.email || ''}
currentUserId={(user as any)?.userId || ''} currentUserId={(user as any)?.userId || ''}
currentUserName={ currentUserName={
(user as any)?.displayName || (user as any)?.displayName ||
(user as any)?.name || (user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName ((user as any)?.firstName && (user as any)?.lastName
? `${(user as any).firstName} ${(user as any).lastName}`.trim() ? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (user as any)?.email || 'User') : (user as any)?.email || 'User')
} }
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
); );
@ -868,64 +552,41 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
</CardHeader> </CardHeader>
<CardContent className="pt-6 space-y-4"> <CardContent className="pt-6 space-y-4">
<div className="space-y-3"> <div className="space-y-3">
{(() => { {(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => {
// Sort approvers by level and filter out system approvers const stepNames: Record<number, string> = {
const sortedApprovers = [...(formData.approvers || [])] 1: 'Dealer Proposal Submission',
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@')) 2: 'Requestor Evaluation',
.sort((a: any, b: any) => a.level - b.level); 3: 'Department Lead Approval',
4: 'Activity Creation',
return sortedApprovers.map((approver: any) => { 5: 'Dealer Completion Documents',
const tat = Number(approver.tat || 0); 6: 'Requestor Claim Approval',
const tatType = approver.tatType || 'hours'; 7: 'E-Invoice Generation',
const hours = tatType === 'days' ? tat * 24 : tat; 8: 'Credit Note Confirmation',
};
// Find step name - handle additional approvers and shifted levels const tat = Number(approver.tat || 0);
let stepName = 'Unknown'; const tatType = approver.tatType || 'hours';
let stepLabel = ''; const hours = tatType === 'days' ? tat * 24 : tat;
if (approver.isAdditional) { return (
// Additional approver - use stepName if available <div key={approver.level} className="p-3 bg-gray-50 rounded-lg border">
stepName = approver.stepName || 'Additional Approver'; <div className="flex items-center justify-between">
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel); <div>
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`; <Label className="text-xs text-gray-600 uppercase tracking-wider">
} else { Step {approver.level}: {stepNames[approver.level]}
// Fixed step - find by originalStepLevel first, then fallback to level </Label>
const step = approver.originalStepLevel <p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel) {approver.email && (
: CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto); <p className="text-xs text-gray-500 mt-1">{approver.email}</p>
stepName = step?.name || 'Unknown'; )}
stepLabel = stepName; </div>
} <div className="text-right">
<p className="text-sm font-semibold text-gray-900">{hours} hours</p>
return ( <p className="text-xs text-gray-500">TAT</p>
<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-1">
<div className="flex items-center gap-2 mb-1">
<Label className="text-xs text-gray-600 uppercase tracking-wider">
{stepLabel}
</Label>
{approver.isAdditional && (
<Badge variant="outline" className="text-xs bg-purple-100 text-purple-700 border-purple-300">
ADDITIONAL
</Badge>
)}
</div>
<p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
{approver.email && (
<p className="text-xs text-gray-500 mt-1">{approver.email}</p>
)}
</div>
<div className="text-right ml-4">
<p className="text-sm font-semibold text-gray-900">{hours} hours</p>
<p className="text-xs text-gray-500">TAT</p>
</div>
</div> </div>
</div> </div>
); </div>
}); );
})()} })}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -975,10 +636,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div> <div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label> <Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
<div className="mt-2 p-4 bg-gray-50 rounded-lg border"> <div className="mt-2 p-4 bg-gray-50 rounded-lg border">
<FormattedDescription <p className="text-gray-900 whitespace-pre-wrap">{formData.requestDescription}</p>
content={formData.requestDescription || ''}
className="text-sm"
/>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -1047,7 +705,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<span className="hidden sm:inline">Back to Templates</span> <span className="hidden sm:inline">Back to Templates</span>
<span className="sm:hidden">Back</span> <span className="sm:hidden">Back</span>
</Button> </Button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div> <div>
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge> <Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
@ -1063,10 +721,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<Progress value={(currentStep / totalSteps) * 100} className="h-2" /> <Progress value={(currentStep / totalSteps) * 100} className="h-2" />
<div className="flex justify-between mt-2 px-1"> <div className="flex justify-between mt-2 px-1">
{STEP_NAMES.map((_name, index) => ( {STEP_NAMES.map((_name, index) => (
<span <span
key={index} key={index}
className={`text-xs sm:text-sm ${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 +758,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" />
@ -1110,37 +770,15 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
) : ( ) : (
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isStepValid() || isSubmitting} disabled={!isStepValid()}
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2" className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2"
> >
{isSubmitting ? ( <Check className="w-4 h-4" />
<> Submit Claim Request
<Loader2 className="w-4 h-4 animate-spin" />
Submitting...
</>
) : (
<>
<Check className="w-4 h-4" />
Submit Claim Request
</>
)}
</Button> </Button>
)} )}
</div> </div>
</div> </div>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</div> </div>
); );
} }

View File

@ -5,13 +5,14 @@
* 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 { Textarea } from '@/components/ui/textarea';
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 +31,75 @@ 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'; ioRemark?: string; // IO remark
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 existingIORemark = internalOrder?.ioRemark || internalOrder?.io_remark || '';
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 proposalDetails = apiRequest?.proposalDetails || {}; const organizer = internalOrder?.organizer || null;
const claimDetails = apiRequest?.claimDetails || apiRequest || {};
const [ioNumber, setIoNumber] = useState(existingIONumber);
// Calculate total base amount (needed for budget verification as requested) const [ioRemark, setIoRemark] = useState(existingIORemark);
// 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);
const maxIoRemarkChars = 300;
const ioRemarkChars = ioRemark.length;
// 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) => { const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
const org = io.organizer || null; // Get blocked by user name from organizer association (who blocked the amount)
const blockedByName = org?.displayName || // When amount is blocked, organizedBy stores the user who blocked it
org?.display_name || const blockedByName = organizer?.displayName ||
org?.name || organizer?.display_name ||
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) || organizer?.name ||
org?.email || (organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
'Unknown User'; organizer?.email ||
return { 'Unknown User';
ioNumber: io.ioNumber || io.io_number,
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0), // Set IO number and remark from existing data
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0), setIoNumber(existingIONumber);
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0), setIoRemark(existingIORemark);
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
// Only set blocked details if amount is blocked
if (existingBlockedAmount > 0) {
setBlockedDetails({
ioNumber: existingIONumber,
blockedAmount: Number(existingBlockedAmount) || 0,
availableBalance: availableBeforeBlock, // Available amount before block
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '', sapDocumentNumber: sapDocNumber,
status: (io.status === 'BLOCKED' ? 'blocked' : ioRemark: existingIORemark,
io.status === 'RELEASED' ? 'released' : status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
io.status === 'PENDING' ? 'pending' : 'blocked') as any, internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
}; });
});
setBlockedIOs(formattedIOs); // Set fetched amount if available balance exists
if (availableBeforeBlock > 0) {
// If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience setFetchedAmount(availableBeforeBlock);
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) { }
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
} }
} }
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]); }, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/** /**
* Fetch available budget from SAP * Fetch available budget from SAP
@ -132,25 +121,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
try { try {
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later // Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim()); const ioData = await validateIO(requestId, ioNumber.trim());
if (ioData.isValid && ioData.availableBalance > 0) { if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance); setFetchedAmount(ioData.availableBalance);
// Pre-fill amount to block with available balance
// Calculate total already blocked amount setAmountToBlock(String(ioData.availableBalance));
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
// Calculate remaining budget to block
const remainingToBlock = Math.max(0, estimatedBudget - totalAlreadyBlocked);
// Pre-fill amount to block with remaining budget, otherwise use available balance
if (remainingToBlock > 0) {
setAmountToBlock(String(remainingToBlock.toFixed(2)));
} else if (estimatedBudget > 0 && totalAlreadyBlocked === 0) {
setAmountToBlock(String(estimatedBudget.toFixed(2)));
} else {
setAmountToBlock(String(ioData.availableBalance.toFixed(2)));
}
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`); toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
} else { } else {
toast.error('Invalid IO number or no available balance found'); toast.error('Invalid IO number or no available balance found');
@ -167,12 +142,45 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
} }
}; };
/**
* Save IO details (IO number and remark) without blocking budget
*/
const handleSaveIODetails = async () => {
if (!ioNumber.trim()) {
toast.error('Please enter an IO number');
return;
}
if (!requestId) {
toast.error('Request ID not found');
return;
}
setBlockingBudget(true);
try {
// Save only IO number and remark (no balance fields)
const payload = {
ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
};
await updateIODetails(requestId, payload);
toast.success('IO details saved successfully');
// Refresh request details
onRefresh?.();
} catch (error: any) {
console.error('Failed to save IO details:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to save IO details';
toast.error(errorMessage);
} finally {
setBlockingBudget(false);
}
};
/** /**
* Block budget in SAP system * Block budget in SAP system
* This function:
* 1. Validates the IO number and amount
* 2. Calls SAP to block the budget
* 3. Saves IO number, blocked amount, and balance details to database
*/ */
const handleBlockBudget = async () => { const handleBlockBudget = async () => {
if (!ioNumber.trim() || fetchedAmount === null) { if (!ioNumber.trim() || fetchedAmount === null) {
@ -186,97 +194,83 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
} }
const blockAmountRaw = parseFloat(amountToBlock); const blockAmountRaw = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) { if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
toast.error('Please enter a valid amount to block'); toast.error('Please enter a valid amount to block');
return; return;
} }
// Round to exactly 2 decimal places to avoid floating point precision issues // Round to 2 decimal places to avoid floating point precision issues
// Use parseFloat with toFixed to ensure exact 2 decimal precision // This ensures we send clean values like 240.00 instead of 239.9999999
const blockAmount = parseFloat(blockAmountRaw.toFixed(2)); const blockAmount = Math.round(blockAmountRaw * 100) / 100;
if (blockAmount > fetchedAmount) { if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget'); toast.error('Amount to block exceeds available IO budget');
return; return;
} }
// Log the amount being sent to backend for debugging
// Calculate total already blocked console.log('[IOTab] Blocking budget:', {
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0); ioNumber: ioNumber.trim(),
const totalPlanned = totalAlreadyBlocked + blockAmount; originalInput: amountToBlock,
parsedAmount: blockAmountRaw,
// Validate that total planned must exactly match estimated budget roundedAmount: blockAmount,
if (estimatedBudget > 0) { fetchedAmount,
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2)); calculatedRemaining: fetchedAmount - blockAmount,
const roundedTotalPlanned = parseFloat(totalPlanned.toFixed(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;
}
}
// Blocking budget
setBlockingBudget(true); setBlockingBudget(true);
try { try {
// Call updateIODetails with blockedAmount to block budget in SAP and store in database // Call updateIODetails with blockedAmount to block budget in SAP and store in database
// This will store in internal_orders and claim_budget_tracking tables // This will store in internal_orders and claim_budget_tracking tables
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only // Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
// Ensure all amounts are rounded to 2 decimal places for consistency
const roundedFetchedAmount = parseFloat(fetchedAmount.toFixed(2));
const calculatedRemaining = parseFloat((roundedFetchedAmount - blockAmount).toFixed(2));
const payload = { const payload = {
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
ioAvailableBalance: roundedFetchedAmount, ioRemark: ioRemark.trim(),
ioAvailableBalance: fetchedAmount,
ioBlockedAmount: blockAmount, ioBlockedAmount: blockAmount,
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value) ioRemainingBalance: fetchedAmount - blockAmount, // Calculated value (backend will use SAP's actual value)
}; };
// Sending to backend console.log('[IOTab] Sending to backend:', payload);
await updateIODetails(requestId, payload); await updateIODetails(requestId, payload);
// Fetch updated claim details to get the blocked IO data // Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId); const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order; const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) { if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount); const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0); const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount));
// Calculate expected remaining balance for validation/debugging // Log what was saved vs what we sent
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount; console.log('[IOTab] Blocking result:', {
sentAmount: blockAmount,
// Blocking result processed savedBlockedAmount,
sentRemaining: fetchedAmount - blockAmount,
savedRemainingBalance,
availableBalance: fetchedAmount,
difference: savedBlockedAmount - blockAmount,
});
// Warn if the saved amount differs from what we sent // Warn if the saved amount differs from what we sent
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) { if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount); console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
} }
// Warn if remaining balance calculation seems incorrect (for backend debugging)
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
availableBalance: fetchedAmount,
blockedAmount: savedBlockedAmount,
expectedRemaining: expectedRemainingBalance,
backendRemaining: savedRemainingBalance,
difference: savedRemainingBalance - expectedRemainingBalance,
});
}
const currentUser = user as any; const currentUser = user as any;
// When blocking, always use the current user who is performing the block action // When blocking, always use the current user who is performing the block action
// The organizer association may be from initial IO organization, but we want who blocked the amount // The organizer association may be from initial IO organization, but we want who blocked the amount
const blockedByName = currentUser?.displayName || const blockedByName = currentUser?.displayName ||
currentUser?.display_name || currentUser?.display_name ||
currentUser?.name || currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) || (currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email || currentUser?.email ||
'Current User'; 'Current User';
const savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
const blocked: IOBlockedDetails = { const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber, ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: savedBlockedAmount, blockedAmount: savedBlockedAmount,
@ -285,14 +279,14 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(), blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName, blockedBy: blockedByName,
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '', sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
ioRemark: savedIoRemark,
status: 'blocked', status: 'blocked',
}; };
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
onRefresh?.(); onRefresh?.();
} else { } else {
@ -331,12 +325,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" />
@ -345,17 +339,44 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div> </div>
</div> </div>
{/* Instructions when IO number is entered but not fetched */} {/* IO Remark Input */}
{!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && ( <div className="space-y-2">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> <Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900">
<p className="text-sm text-blue-800"> IO Remark
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP. </Label>
</p> <Textarea
id="ioRemark"
placeholder="Enter remarks about IO organization"
value={ioRemark}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxIoRemarkChars) {
setIoRemark(value);
}
}}
rows={3}
disabled={!!blockedDetails}
className="bg-white text-sm min-h-[80px] resize-none"
/>
<div className="flex justify-end text-xs text-gray-600">
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
</div> </div>
</div>
{/* Save IO Details Button (shown when IO number is entered but amount not fetched) */}
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
<Button
onClick={handleSaveIODetails}
disabled={blockingBudget || !ioNumber.trim()}
variant="outline"
className="w-full border-[#2d4a3e] text-[#2d4a3e] hover:bg-[#2d4a3e] hover:text-white"
>
{blockingBudget ? 'Saving...' : 'Save IO Details'}
</Button>
)} )}
{/* Fetched Amount Display */} {/* Fetched Amount Display */}
{fetchedAmount !== null && (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">
@ -389,25 +410,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
className="pl-8" className="pl-8"
/> />
</div> </div>
{estimatedBudget > 0 && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
<p className="text-xs text-amber-800">
<strong>Required:</strong> Amount must be exactly equal to the estimated budget: <strong>{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
</p>
</div>
)}
</div> </div>
{/* Block Button */} {/* Block Button */}
<Button <Button
onClick={handleBlockBudget} onClick={handleBlockBudget}
disabled={ disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
blockingBudget ||
!amountToBlock ||
parseFloat(amountToBlock) <= 0 ||
parseFloat(amountToBlock) > fetchedAmount ||
(estimatedBudget > 0 && Math.abs((blockedIOs.reduce((s, i) => s + i.blockedAmount, 0) + parseFloat(amountToBlock)) - estimatedBudget) > 0.01)
}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]" className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
> >
<Target className="w-4 h-4 mr-2" /> <Target className="w-4 h-4 mr-2" />
@ -430,57 +438,77 @@ 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'} {blockedDetails.ioRemark && (
</Badge> <div className="p-4">
</div> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Remark</p>
<div className="grid grid-cols-2 divide-x divide-y"> <p className="text-sm font-medium text-gray-900 whitespace-pre-wrap">{blockedDetails.ioRemark}</p>
<div className="p-3"> </div>
<p className="text-[10px] text-gray-500 uppercase">Amount</p> )}
<p className="text-sm font-bold text-green-700">{io.blockedAmount.toLocaleString('en-IN')}</p> <div className="p-4 bg-green-50">
</div> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<div className="p-3"> <p className="text-xl font-bold text-green-700">
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p> {blockedDetails.blockedAmount.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">
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Available Amount (Before Block)</p>
<p className="text-xs">{io.blockedBy}</p> <p className="text-sm font-medium text-gray-900">
</div> {blockedDetails.availableBalance.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 bg-blue-50">
</div> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
</div> <p className="text-sm font-bold text-blue-700">
{blockedDetails.remainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked By</p>
<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>
) : ( ) : (

View File

@ -41,9 +41,6 @@ interface ClaimManagementOverviewTabProps {
aiGenerated?: boolean; aiGenerated?: boolean;
handleGenerateConclusion?: () => void; handleGenerateConclusion?: () => void;
handleFinalizeConclusion?: () => void; handleFinalizeConclusion?: () => void;
generationAttempts?: number;
generationFailed?: boolean;
maxAttemptsReached?: boolean;
} }
export function ClaimManagementOverviewTab({ export function ClaimManagementOverviewTab({
@ -61,9 +58,6 @@ export function ClaimManagementOverviewTab({
aiGenerated = false, aiGenerated = false,
handleGenerateConclusion, handleGenerateConclusion,
handleFinalizeConclusion, handleFinalizeConclusion,
generationAttempts = 0,
generationFailed = false,
maxAttemptsReached = 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)) {
@ -92,7 +86,16 @@ export function ClaimManagementOverviewTab({
); );
} }
// Mapped claim data ready // Debug: Log mapped data for troubleshooting
console.debug('[ClaimManagementOverviewTab] Mapped claim data:', {
activityInfo: claimRequest.activityInfo,
dealerInfo: claimRequest.dealerInfo,
hasProposalDetails: !!claimRequest.proposalDetails,
closedExpenses: claimRequest.activityInfo?.closedExpenses,
closedExpensesBreakdown: claimRequest.activityInfo?.closedExpensesBreakdown,
hasDealerCode: !!claimRequest.dealerInfo?.dealerCode,
hasDealerName: !!claimRequest.dealerInfo?.dealerName,
});
// Determine user's role // Determine user's role
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId); const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
@ -100,7 +103,13 @@ export function ClaimManagementOverviewTab({
// Get visibility settings based on role // Get visibility settings based on role
const visibility = getRoleBasedVisibility(userRole); const visibility = getRoleBasedVisibility(userRole);
// User role and visibility determined console.debug('[ClaimManagementOverviewTab] User role and visibility:', {
userRole,
visibility,
currentUserId,
showDealerInfo: visibility.showDealerInfo,
dealerInfoPresent: !!(claimRequest.dealerInfo?.dealerCode || claimRequest.dealerInfo?.dealerName),
});
// Extract initiator info from request // Extract initiator info from request
// The apiRequest has initiator object with displayName, email, department, phone, etc. // The apiRequest has initiator object with displayName, email, department, phone, etc.
@ -112,7 +121,20 @@ export function ClaimManagementOverviewTab({
phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile, phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile,
}; };
// Closure setup check completed // Debug: Log closure props to help troubleshoot
console.debug('[ClaimManagementOverviewTab] Closure setup check:', {
needsClosure,
requestStatus: apiRequest?.status,
requestStatusLower: (apiRequest?.status || '').toLowerCase(),
hasConclusionRemark: !!conclusionRemark,
conclusionRemarkLength: conclusionRemark?.length || 0,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
hasHandleGenerate: !!handleGenerateConclusion,
hasHandleFinalize: !!handleFinalizeConclusion,
hasSetConclusion: !!setConclusionRemark,
});
return ( return (
<div className={`space-y-6 ${className}`}> <div className={`space-y-6 ${className}`}>
@ -188,24 +210,17 @@ export function ClaimManagementOverviewTab({
</CardDescription> </CardDescription>
</div> </div>
{handleGenerateConclusion && ( {handleGenerateConclusion && (
<div className="flex flex-col items-end gap-1.5"> <Button
<Button variant="outline"
variant="outline" size="sm"
size="sm" onClick={handleGenerateConclusion}
onClick={handleGenerateConclusion} disabled={conclusionLoading}
disabled={conclusionLoading || maxAttemptsReached} className="gap-2 shrink-0"
className="gap-2 shrink-0 h-9" data-testid="generate-ai-conclusion-button"
data-testid="generate-ai-conclusion-button" >
> <RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} /> {aiGenerated ? 'Regenerate' : 'Generate with AI'}
{aiGenerated ? 'Regenerate' : 'Generate with AI'} </Button>
</Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining
</span>
)}
</div>
)} )}
</div> </div>
</CardHeader> </CardHeader>

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,6 @@ import { Calendar, MapPin, DollarSign, Receipt } from 'lucide-react';
import { ClaimActivityInfo } from '@/pages/RequestDetail/types/claimManagement.types'; import { ClaimActivityInfo } from '@/pages/RequestDetail/types/claimManagement.types';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { formatDateTime } from '@/utils/dateFormatter'; import { formatDateTime } from '@/utils/dateFormatter';
import { FormattedDescription } from '@/components/common/FormattedDescription';
interface ActivityInformationCardProps { interface ActivityInformationCardProps {
activityInfo: ClaimActivityInfo; activityInfo: ClaimActivityInfo;
@ -18,11 +17,11 @@ interface ActivityInformationCardProps {
updatedAt?: string | Date; updatedAt?: string | Date;
} }
export function ActivityInformationCard({ export function ActivityInformationCard({
activityInfo, activityInfo,
className, className,
createdAt, createdAt,
updatedAt updatedAt
}: ActivityInformationCardProps) { }: ActivityInformationCardProps) {
// Defensive check: Ensure activityInfo exists // Defensive check: Ensure activityInfo exists
if (!activityInfo) { if (!activityInfo) {
@ -109,7 +108,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 +122,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 +146,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>
)} )}
@ -195,12 +173,9 @@ export function ActivityInformationCard({
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide"> <label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Description Description
</label> </label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200"> <p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
<FormattedDescription {activityInfo.description}
content={activityInfo.description || ''} </p>
className="text-sm"
/>
</div>
</div> </div>
)} )}

View File

@ -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
@ -127,7 +120,7 @@ export function ProcessDetailsCard({
</Label> </Label>
</div> </div>
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p> <p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
{ioDetails.remarks && ( {ioDetails.remarks && (
<div className="pt-2 border-t border-blue-100"> <div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">Remark:</p> <p className="text-xs text-gray-600 mb-1">Remark:</p>
@ -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>
))} ))}

View File

@ -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,22 +29,19 @@ 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;
} }
return 0; return 0;
}; };
@ -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)}

View File

@ -1,237 +0,0 @@
/**
* AdditionalApproverReviewModal Component
* Modal for Additional Approvers to review request and approve/reject
* Similar to InitiatorProposalApprovalModal but simpler - shows request details
*/
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
CheckCircle,
XCircle,
FileText,
MessageSquare,
} from 'lucide-react';
import { toast } from 'sonner';
import { FormattedDescription } from '@/components/common/FormattedDescription';
interface AdditionalApproverReviewModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (comments: string) => Promise<void>;
onReject: (comments: string) => Promise<void>;
requestTitle?: string;
requestDescription?: string;
requestId?: string;
levelName?: string;
approverName?: string;
}
export function AdditionalApproverReviewModal({
isOpen,
onClose,
onApprove,
onReject,
requestTitle = 'Request',
requestDescription = '',
requestId,
levelName = 'Approval Level',
approverName = 'Approver',
}: AdditionalApproverReviewModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | null>(null);
const handleApprove = async () => {
if (!comments.trim()) {
toast.error('Please provide approval comments');
return;
}
try {
setSubmitting(true);
setActionType('approve');
await onApprove(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to approve request:', error);
toast.error('Failed to approve request. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReject = async () => {
if (!comments.trim()) {
toast.error('Please provide rejection reason');
return;
}
try {
setSubmitting(true);
setActionType('reject');
await onReject(comments);
handleReset();
onClose();
} catch (error) {
console.error('Failed to reject request:', error);
toast.error('Failed to reject request. Please try again.');
} finally {
setSubmitting(false);
setActionType(null);
}
};
const handleReset = () => {
setComments('');
setActionType(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
if (!isOpen) {
return null;
}
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dealer-proposal-modal overflow-hidden flex flex-col max-w-3xl">
<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">
<CheckCircle className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
Review Request
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm">
{levelName}: Review request details and make a decision
</DialogDescription>
<div className="space-y-1 mt-2 text-xs text-gray-600">
<div className="flex flex-wrap gap-x-4 gap-y-1">
<div>
<strong>Request ID:</strong> {requestId || 'N/A'}
</div>
<div>
<strong>Approver:</strong> {approverName}
</div>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
<div className="space-y-4">
{/* Request Title */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-sm lg:text-base flex items-center gap-2">
<FileText className="w-4 h-4 text-blue-600" />
Request Title
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50">
<p className="text-sm lg:text-base font-medium text-gray-900">{requestTitle}</p>
</div>
</div>
{/* Request Description */}
{requestDescription && (
<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">
<MessageSquare className="w-4 h-4 text-blue-600" />
Request Description
</h3>
</div>
<div className="border rounded-lg p-2.5 lg:p-3 bg-gray-50 max-h-[200px] overflow-y-auto">
<FormattedDescription
content={requestDescription}
className="text-xs lg:text-sm text-gray-700"
/>
</div>
</div>
)}
{/* Decision Section */}
<div className="space-y-2 border-t pt-3 lg:pt-3">
<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-[80px] lg:min-h-[90px] text-xs lg:text-sm"
/>
<p className="text-xs text-gray-500">{comments.length} characters</p>
</div>
{/* Warning for missing comments */}
{!comments.trim() && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-2 flex items-start gap-2">
<XCircle className="w-3.5 h-3.5 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
Please provide comments before making a decision. Comments are required and will be visible to all participants.
</p>
</div>
)}
</div>
</div>
<DialogFooter className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end px-6 pb-6 pt-3 lg:pt-4 flex-shrink-0 border-t bg-gray-50">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
className="border-2"
>
Cancel
</Button>
<div className="flex gap-2">
<Button
onClick={handleReject}
disabled={!comments.trim() || submitting}
variant="destructive"
className="bg-red-600 hover:bg-red-700"
>
{submitting && actionType === 'reject' ? (
'Rejecting...'
) : (
<>
<XCircle className="w-4 h-4 mr-2" />
Reject
</>
)}
</Button>
<Button
onClick={handleApprove}
disabled={!comments.trim() || submitting}
className="bg-green-600 hover:bg-green-700 text-white"
>
{submitting && actionType === 'approve' ? (
'Approving...'
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Approve
</>
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -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,16 +53,13 @@ export function CreditNoteSAPModal({
requestNumber, requestNumber,
requestId: _requestId, requestId: _requestId,
dueDate, dueDate,
taxationType,
}: CreditNoteSAPModalProps) { }: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false); const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== ''; const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
const creditNoteNumber = creditNoteData?.creditNoteNumber || ''; const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
const creditNoteDate = creditNoteData?.creditNoteDate const creditNoteDate = creditNoteData?.creditNoteDate
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' }) ? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: ''; : '';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0; const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
@ -73,7 +69,7 @@ export function CreditNoteSAPModal({
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009'; const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
const activity = activityName || 'Activity'; const activity = activityName || 'Activity';
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101'; const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
const dueDateDisplay = dueDate const dueDateDisplay = dueDate
? formatDateTime(dueDate, { includeTime: false, format: 'short' }) ? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026'; : 'Jan 4, 2026';
@ -120,18 +116,11 @@ export function CreditNoteSAPModal({
return ( return (
<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 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
@ -150,7 +139,7 @@ export function CreditNoteSAPModal({
</div> </div>
<Badge className="bg-green-600 text-white px-4 py-2 text-base"> <Badge className="bg-green-600 text-white px-4 py-2 text-base">
<CircleCheckBig className="w-4 h-4 mr-2" /> <CircleCheckBig className="w-4 h-4 mr-2" />
{status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'} {status === 'APPROVED' || status === 'CONFIRMED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'}
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-2 gap-4 mt-4"> <div className="grid grid-cols-2 gap-4 mt-4">

View File

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

View File

@ -1,67 +0,0 @@
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dealer-completion-documents-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 1200px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dealer-completion-documents-modal {
width: 90vw !important;
max-width: 1200px !important;
}
}
/* Date input calendar icon positioning */
.dealer-completion-documents-modal input[type="date"] {
position: relative;
cursor: pointer;
}
.dealer-completion-documents-modal input[type="date"]::-webkit-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
z-index: 1;
pointer-events: auto;
}
.dealer-completion-documents-modal input[type="date"]::-webkit-inner-spin-button,
.dealer-completion-documents-modal input[type="date"]::-webkit-clear-button {
display: none;
-webkit-appearance: none;
}
/* Firefox date input */
.dealer-completion-documents-modal input[type="date"]::-moz-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
}

View File

@ -1,67 +0,0 @@
.dealer-proposal-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dealer-proposal-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dealer-proposal-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dealer-proposal-modal {
width: 90vw !important;
max-width: 1200px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dealer-proposal-modal {
width: 90vw !important;
max-width: 1200px !important;
}
}
/* Date input calendar icon positioning */
.dealer-proposal-modal input[type="date"] {
position: relative;
cursor: pointer;
}
.dealer-proposal-modal input[type="date"]::-webkit-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
z-index: 1;
pointer-events: auto;
}
.dealer-proposal-modal input[type="date"]::-webkit-inner-spin-button,
.dealer-proposal-modal input[type="date"]::-webkit-clear-button {
display: none;
-webkit-appearance: none;
}
/* Firefox date input */
.dealer-proposal-modal input[type="date"]::-moz-calendar-picker-indicator {
position: absolute;
right: 0.5rem;
cursor: pointer;
opacity: 1;
}

View File

@ -1,39 +0,0 @@
.dept-lead-io-modal {
width: 90vw !important;
max-width: 90vw !important;
max-height: 95vh !important;
}
/* Mobile responsive */
@media (max-width: 640px) {
.dept-lead-io-modal {
width: 95vw !important;
max-width: 95vw !important;
max-height: 95vh !important;
}
}
/* Tablet and small desktop */
@media (min-width: 641px) and (max-width: 1023px) {
.dept-lead-io-modal {
width: 90vw !important;
max-width: 90vw !important;
}
}
/* Large screens - fixed max-width for better readability */
@media (min-width: 1024px) {
.dept-lead-io-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}
/* Extra large screens */
@media (min-width: 1536px) {
.dept-lead-io-modal {
width: 90vw !important;
max-width: 1000px !important;
}
}

View File

@ -21,13 +21,13 @@ import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react'; import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import './DeptLeadIOApprovalModal.css';
interface DeptLeadIOApprovalModalProps { interface DeptLeadIOApprovalModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onApprove: (data: { onApprove: (data: {
ioNumber: string; ioNumber: string;
ioRemark: string;
comments: string; comments: string;
}) => Promise<void>; }) => Promise<void>;
onReject: (comments: string) => Promise<void>; onReject: (comments: string) => Promise<void>;
@ -35,9 +35,9 @@ interface DeptLeadIOApprovalModalProps {
requestId?: string; requestId?: string;
// Pre-filled IO data from IO table // Pre-filled IO data from IO table
preFilledIONumber?: string; preFilledIONumber?: string;
preFilledIORemark?: string;
preFilledBlockedAmount?: number; preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number; preFilledRemainingBalance?: number;
taxationType?: string | null;
} }
export function DeptLeadIOApprovalModal({ export function DeptLeadIOApprovalModal({
@ -48,30 +48,31 @@ export function DeptLeadIOApprovalModal({
requestTitle, requestTitle,
requestId: _requestId, requestId: _requestId,
preFilledIONumber, preFilledIONumber,
preFilledIORemark,
preFilledBlockedAmount, preFilledBlockedAmount,
preFilledRemainingBalance, preFilledRemainingBalance,
taxationType,
}: DeptLeadIOApprovalModalProps) { }: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve'); const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [ioRemark, setIoRemark] = useState('');
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 || '';
// Reset form when modal opens/closes // Reset form when modal opens/closes
React.useEffect(() => { React.useEffect(() => {
if (isOpen) { if (isOpen) {
// Prefill IO remark from props if available
setIoRemark(preFilledIORemark || '');
setComments(''); setComments('');
setActionType('approve'); setActionType('approve');
} }
}, [isOpen]); }, [isOpen, preFilledIORemark]);
const ioRemarkChars = ioRemark.length;
const commentsChars = comments.length; const commentsChars = comments.length;
const maxIoRemarkChars = 300;
const maxCommentsChars = 500; const maxCommentsChars = 500;
// Validate form // Validate form
@ -79,12 +80,13 @@ export function DeptLeadIOApprovalModal({
if (actionType === 'reject') { if (actionType === 'reject') {
return comments.trim().length > 0; return comments.trim().length > 0;
} }
// For approve, need IO number (from table) and comments // For approve, need IO number (from table), IO remark, and comments
return ( return (
ioNumber.trim().length > 0 && // IO number must exist from IO table ioNumber.trim().length > 0 && // IO number must exist from IO table
ioRemark.trim().length > 0 &&
comments.trim().length > 0 comments.trim().length > 0
); );
}, [actionType, ioNumber, comments]); }, [actionType, ioNumber, ioRemark, comments]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!isFormValid) { if (!isFormValid) {
@ -93,6 +95,10 @@ export function DeptLeadIOApprovalModal({
toast.error('IO number is required. Please block amount from IO tab first.'); toast.error('IO number is required. Please block amount from IO tab first.');
return; return;
} }
if (!ioRemark.trim()) {
toast.error('Please enter IO remark');
return;
}
} }
if (!comments.trim()) { if (!comments.trim()) {
toast.error('Please provide comments'); toast.error('Please provide comments');
@ -103,16 +109,17 @@ export function DeptLeadIOApprovalModal({
try { try {
setSubmitting(true); setSubmitting(true);
if (actionType === 'approve') { if (actionType === 'approve') {
await onApprove({ await onApprove({
ioNumber: ioNumber.trim(), ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
comments: comments.trim(), comments: comments.trim(),
}); });
} else { } else {
await onReject(comments.trim()); await onReject(comments.trim());
} }
handleReset(); handleReset();
onClose(); onClose();
} catch (error) { } catch (error) {
@ -125,6 +132,7 @@ export function DeptLeadIOApprovalModal({
const handleReset = () => { const handleReset = () => {
setActionType('approve'); setActionType('approve');
setIoRemark('');
setComments(''); setComments('');
}; };
@ -137,40 +145,35 @@ export function DeptLeadIOApprovalModal({
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dept-lead-io-modal overflow-hidden flex flex-col"> <DialogContent className="max-w-2xl">
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-3"> <DialogHeader>
<div className="flex items-center gap-2 lg:gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-1.5 lg:p-2 rounded-lg bg-green-100"> <div className="p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" /> <CircleCheckBig className="w-6 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-xl">
Review and Approve Approve and Organise IO
{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-sm mt-1">
Review IO details and provide your approval comments Review IO details and provide your approval comments
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
{/* Request Info Card */} {/* Request Info Card */}
<div className="space-y-2 lg:space-y-3 p-3 lg:p-4 bg-gray-50 rounded-lg border"> <div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-sm lg:text-base text-gray-900">Workflow Step:</span> <span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono text-xs">Step 3</Badge> <Badge variant="outline" className="font-mono">Step 3</Badge>
</div> </div>
<div> <div>
<span className="font-medium text-sm lg:text-base text-gray-900">Title:</span> <span className="font-medium text-gray-900">Title:</span>
<p className="text-xs lg:text-sm text-gray-700 mt-1">{requestTitle || '—'}</p> <p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium text-sm lg:text-base text-gray-900">Action:</span> <span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200 text-xs"> <Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" /> <CircleCheckBig className="w-3 h-3 mr-1" />
APPROVE APPROVE
</Badge> </Badge>
@ -178,150 +181,176 @@ export function DeptLeadIOApprovalModal({
</div> </div>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6"> <div className="space-y-3">
<div className="space-y-3 lg:space-y-4"> {/* Action Toggle Buttons */}
{/* Action Toggle Buttons */} <div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg"> <Button
<Button type="button"
type="button" onClick={() => setActionType('approve')}
onClick={() => setActionType('approve')} className={`flex-1 ${
className={`flex-1 text-sm lg:text-base ${actionType === 'approve' actionType === 'approve'
? 'bg-green-600 text-white shadow-sm' ? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200' : 'text-gray-700 hover:bg-gray-200'
}`} }`}
variant={actionType === 'approve' ? 'default' : 'ghost'} variant={actionType === 'approve' ? 'default' : 'ghost'}
> >
<CircleCheckBig className="w-4 h-4 mr-1" /> <CircleCheckBig className="w-4 h-4 mr-1" />
Approve Approve
</Button> </Button>
<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 ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm' ? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200' : 'text-gray-700 hover:bg-gray-200'
}`} }`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'} variant={actionType === 'reject' ? 'destructive' : 'ghost'}
> >
<CircleX className="w-4 h-4 mr-1" /> <CircleX className="w-4 h-4 mr-1" />
Reject Reject
</Button> </Button>
</div> </div>
{/* Main Content Area - Two Column Layout on Large Screens */} {/* IO Organisation Details - Only shown when approving */}
<div className="space-y-3 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6"> {actionType === 'approve' && (
{/* Left Column - IO Organisation Details (Only shown when approving) */} <div className="p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
{actionType === 'approve' && ( <div className="flex items-center gap-2">
<div className="p-3 lg:p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-3"> <Receipt className="w-4 h-4 text-blue-600" />
<div className="flex items-center gap-2"> <h4 className="font-semibold text-blue-900">IO Organisation Details</h4>
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" /> </div>
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
{/* IO Number - Read-only from IO table */}
<div className="space-y-1">
<Label htmlFor="ioNumber" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Number <span className="text-red-500">*</span>
</Label>
<Input
id="ioNumber"
value={ioNumber || '—'}
disabled
readOnly
className="bg-gray-100 h-8 cursor-not-allowed"
/>
{!ioNumber && (
<p className="text-xs text-red-600 mt-1">
IO number not found. Please block amount from IO tab first.
</p>
)}
{ioNumber && (
<p className="text-xs text-blue-600 mt-1">
Loaded from IO table
</p>
)}
</div>
{/* IO Balance Information - Read-only */}
<div className="grid grid-cols-2 gap-2">
{/* Blocked Amount Display */}
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
<div className="p-2 bg-green-50 border border-green-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
<span className="text-sm font-bold text-green-700 mt-1">
{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div> </div>
)}
{/* IO Number - Read-only from IO table */} {/* Remaining Balance Display */}
<div className="space-y-1"> {preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2"> <div className="p-2 bg-blue-50 border border-blue-200 rounded">
IO Number <span className="text-red-500">*</span> <div className="flex flex-col">
</Label> <span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
<Input <span className="text-sm font-bold text-blue-700 mt-1">
id="ioNumber" {preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
value={ioNumber || '—'} </span>
disabled </div>
readOnly
className="bg-gray-100 h-8 lg:h-9 cursor-not-allowed text-xs lg:text-sm"
/>
{!ioNumber && (
<p className="text-xs text-red-600 mt-1">
IO number not found. Please block amount from IO tab first.
</p>
)}
{ioNumber && (
<p className="text-xs text-blue-600 mt-1">
Loaded from IO table
</p>
)}
</div> </div>
)}
</div>
{/* IO Balance Information - Read-only */} {/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
<div className="grid grid-cols-2 gap-2"> <div className="space-y-1">
{/* Blocked Amount Display */} <Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && ( IO Remark <span className="text-red-500">*</span>
<div className="p-2 bg-green-50 border border-green-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
<span className="text-xs lg:text-sm font-bold text-green-700 mt-1">
{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
{/* Remaining Balance Display */}
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
<div className="flex flex-col">
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
<span className="text-xs lg:text-sm font-bold text-blue-700 mt-1">
{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</div>
</div>
)}
{/* Right Column - Comments & Remarks */}
<div className={`space-y-1.5 ${actionType === 'approve' ? '' : 'lg:col-span-2'}`}>
<Label htmlFor="comment" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label> </Label>
<Textarea <Textarea
id="comment" id="ioRemark"
placeholder={ placeholder="Enter remarks about IO organization"
actionType === 'approve' value={ioRemark}
? 'Enter your approval comments and any conditions or notes...'
: 'Enter detailed reasons for rejection...'
}
value={comments}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value;
if (value.length <= maxCommentsChars) { if (value.length <= maxIoRemarkChars) {
setComments(value); setIoRemark(value);
} }
}} }}
rows={4} rows={3}
className="text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none" className="bg-white text-sm min-h-[80px] resize-none"
disabled={false}
readOnly={false}
/> />
<div className="flex items-center justify-between text-xs text-gray-500"> <div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-1"> {preFilledIORemark && (
<TriangleAlert className="w-3 h-3" /> <span className="text-blue-600">
Required and visible to all Prefilled from IO tab (editable)
</div> </span>
<span>{commentsChars}/{maxCommentsChars}</span> )}
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
</div> </div>
</div> </div>
</div> </div>
)}
{/* Comments & Remarks */}
<div className="space-y-1.5">
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
Comments & Remarks <span className="text-red-500">*</span>
</Label>
<Textarea
id="comment"
placeholder={
actionType === 'approve'
? 'Enter your approval comments and any conditions or notes...'
: 'Enter detailed reasons for rejection...'
}
value={comments}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxCommentsChars) {
setComments(value);
}
}}
rows={4}
className="text-sm min-h-[80px] resize-none"
/>
<div className="flex items-center justify-between text-xs text-gray-500">
<div className="flex items-center gap-1">
<TriangleAlert className="w-3 h-3" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
</div>
</div> </div>
</div> </div>
<DialogFooter className="flex-shrink-0 flex flex-col-reverse sm:flex-row sm:justify-end gap-2 px-6 pb-6 pt-3 border-t"> <DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={handleClose} onClick={handleClose}
disabled={submitting} disabled={submitting}
className="text-sm lg:text-base"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isFormValid || submitting} disabled={!isFormValid || submitting}
className={`text-sm lg:text-base ${actionType === 'approve' className={`${
? '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'}...`

View File

@ -92,7 +92,7 @@ export function EditClaimAmountModal({
return ( return (
<Dialog open={isOpen} onOpenChange={handleClose}> <Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px] lg:max-w-[800px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<DollarSign className="w-5 h-5 text-green-600" /> <DollarSign className="w-5 h-5 text-green-600" />

View File

@ -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) {
@ -53,7 +53,7 @@ This is an automated message.`;
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-2xl lg:max-w-[1000px] max-w-2xl"> <DialogContent className="sm:max-w-2xl max-w-2xl">
<DialogHeader> <DialogHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -1,215 +0,0 @@
/**
* InitiatorActionModal Component
* Modal for Initiator to take action on a returned/rejected request
* Actions: Reopen, Request Revised Quotation, Cancel
*/
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
RefreshCw,
MessageSquare,
FileEdit,
XOctagon,
AlertTriangle,
Loader2
} from 'lucide-react';
import { toast } from 'sonner';
interface InitiatorActionModalProps {
isOpen: boolean;
onClose: () => void;
onAction: (action: 'REOPEN' | 'REVISE' | 'CANCEL', comments: string) => Promise<void>;
requestTitle?: string;
requestId?: string;
defaultAction?: 'REOPEN' | 'REVISE' | 'CANCEL';
}
export function InitiatorActionModal({
isOpen,
onClose,
onAction,
requestTitle = 'Request',
requestId: _requestId,
defaultAction,
}: InitiatorActionModalProps) {
const [comments, setComments] = useState('');
const [submitting, setSubmitting] = useState(false);
const [selectedAction, setSelectedAction] = useState<'REOPEN' | 'REVISE' | 'CANCEL' | null>(defaultAction || null);
// Update selectedAction when defaultAction changes
useEffect(() => {
if (defaultAction) {
setSelectedAction(defaultAction);
}
}, [defaultAction]);
const actions = [
{
id: 'REOPEN',
label: 'Reopen & Resubmit',
description: 'Resubmit the request to the department head for approval.',
icon: <RefreshCw className="w-5 h-5 text-blue-600" />,
color: 'blue',
variant: 'default' as const
},
{
id: 'REVISE',
label: 'Request Revised Quotation',
description: 'Ask dealer to submit a new proposal/quotation.',
icon: <FileEdit className="w-5 h-5 text-amber-600" />,
color: 'amber',
variant: 'default' as const
},
{
id: 'CANCEL',
label: 'Cancel Request',
description: 'Permanently close and cancel this request.',
icon: <XOctagon className="w-5 h-5 text-red-600" />,
color: 'red',
variant: 'destructive' as const
}
];
const handleActionClick = (actionId: any) => {
setSelectedAction(actionId);
};
const handleSubmit = async () => {
if (!selectedAction) {
toast.error('Please select an action');
return;
}
if (!comments.trim()) {
toast.error('Please provide a reason or comments for this action');
return;
}
try {
setSubmitting(true);
await onAction(selectedAction, comments);
handleReset();
onClose();
} catch (error: any) {
console.error('Failed to perform initiator action:', error);
const errorMessage = error?.response?.data?.message || error?.message || 'Action failed. Please try again.';
toast.error(errorMessage);
} finally {
setSubmitting(false);
}
};
const handleReset = () => {
setComments('');
setSelectedAction(null);
};
const handleClose = () => {
if (!submitting) {
handleReset();
onClose();
}
};
if (!isOpen) return null;
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle className="text-xl">Action Required: {requestTitle}</DialogTitle>
<DialogDescription>
This request has been returned to you. Please select how you would like to proceed.
</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{actions.map((action) => (
<div
key={action.id}
onClick={() => handleActionClick(action.id)}
className={`
cursor-pointer p-4 border-2 rounded-xl transition-all duration-200
${selectedAction === action.id
? `border-${action.color}-600 bg-${action.color}-50 shadow-sm`
: 'border-gray-100 hover:border-gray-200 hover:bg-gray-50'}
`}
>
<div className="flex items-center gap-3 mb-2">
<div className={`p-2 rounded-lg bg-white border border-gray-100`}>
{action.icon}
</div>
<h4 className="font-bold text-sm text-gray-900">{action.label}</h4>
</div>
<p className="text-xs text-gray-500 leading-relaxed">
{action.description}
</p>
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-sm font-semibold text-gray-900 flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-gray-500" />
Comments / Reason
</h3>
<Textarea
placeholder="Provide a detailed reason for your decision..."
value={comments}
onChange={(e) => setComments(e.target.value)}
className="min-h-[120px] text-sm resize-none"
/>
</div>
{selectedAction === 'CANCEL' && (
<div className="p-3 bg-red-50 border border-red-100 rounded-lg flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
<div className="text-xs text-red-800">
<p className="font-bold mb-1">Warning: Irreversible Action</p>
<p>Cancelling this request will permanently close it. This action cannot be undone.</p>
</div>
</div>
)}
</div>
<DialogFooter className="border-t pt-4">
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!selectedAction || !comments.trim() || submitting}
className={`
min-w-[120px]
${selectedAction === 'CANCEL' ? 'bg-red-600 hover:bg-red-700' : 'bg-purple-600 hover:bg-purple-700'}
`}
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
</>
) : (
'Confirm Action'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,307 +0,0 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
FileText,
Calendar,
Receipt,
AlignLeft
} from "lucide-react";
import { DocumentCard } from '@/components/workflow/DocumentUpload/DocumentCard';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
import { getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
interface SnapshotDetailsModalProps {
isOpen: boolean;
onClose: () => void;
snapshot: any;
type: 'PROPOSAL' | 'COMPLETION';
title?: string;
}
export function SnapshotDetailsModal({
isOpen,
onClose,
snapshot,
type,
title
}: SnapshotDetailsModalProps) {
// State for preview
const [previewDoc, setPreviewDoc] = useState<{
fileName: string;
fileType: string;
documentId: string;
fileUrl?: string;
fileSize?: number;
} | null>(null);
if (!snapshot) return null;
const isProposal = type === 'PROPOSAL';
// Helper to format currency
const formatCurrency = (amount: number | string) => {
return Number(amount || 0).toLocaleString('en-IN', {
maximumFractionDigits: 2,
style: 'currency',
currency: 'INR'
});
};
// Helper to format date
const formatDate = (dateString: string) => {
if (!dateString) return null;
try {
return new Date(dateString).toLocaleDateString('en-IN', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
} catch {
return dateString;
}
};
// Helper to check if file is previewable
const canPreview = (fileName: string): boolean => {
if (!fileName) return false;
const name = fileName.toLowerCase();
return name.endsWith('.pdf') ||
!!name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
};
// Helper to get file type for DocumentCard
const getFileType = (fileName: string) => {
const ext = (fileName || '').split('.').pop()?.toLowerCase();
if (ext === 'pdf') return 'pdf';
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext || '')) return 'image';
return 'file';
};
// Handle document preview click
const handlePreview = (doc: any) => {
const fileName = doc.fileName || doc.originalFileName || (isProposal ? 'Proposal Document' : 'Completion Document');
const documentId = doc.documentId || '';
const fileType = fileName.toLowerCase().endsWith('.pdf') ? 'application/pdf' : 'image/jpeg';
let fileUrl = '';
if (documentId) {
fileUrl = getDocumentPreviewUrl(documentId);
} else {
// Fallback for documents without ID (using direct storageUrl)
fileUrl = doc.storageUrl || doc.documentUrl || '';
if (fileUrl && !fileUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = fileUrl.startsWith('/') ? fileUrl : `/${fileUrl}`;
fileUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
}
setPreviewDoc({
fileName,
fileType,
documentId,
fileUrl,
fileSize: doc.sizeBytes
});
};
return (
<>
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden">
<DialogHeader className="px-6 py-4 border-b">
<DialogTitle className="flex items-center gap-2">
{isProposal ? (
<FileText className="w-5 h-5 text-blue-600" />
) : (
<Receipt className="w-5 h-5 text-green-600" />
)}
{title || (isProposal ? 'Proposal Snapshot Details' : 'Completion Snapshot Details')}
</DialogTitle>
<DialogDescription>
View detailed snapshot of the {isProposal ? 'proposal' : 'completion request'} at this version.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto min-h-0 px-6 py-4">
<div className="space-y-6">
{/* Header Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-xs text-gray-500 font-medium mb-1">
{isProposal ? 'Total Budget' : 'Total Expenses'}
</p>
<p className={`text-lg font-bold ${isProposal ? 'text-blue-700' : 'text-green-700'}`}>
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
</p>
</div>
{isProposal && snapshot.expectedCompletionDate && (
<div className="p-3 bg-gray-50 rounded-lg border border-gray-100">
<p className="text-xs text-gray-500 font-medium mb-1 flex items-center gap-1">
<Calendar className="w-3 h-3" />
Expected Completion
</p>
<p className="text-sm font-semibold text-gray-700">
{formatDate(snapshot.expectedCompletionDate)}
</p>
</div>
)}
</div>
{/* Main Document */}
{snapshot.documentUrl && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
Primary Document
</h4>
<DocumentCard
document={{
documentId: '',
name: isProposal ? 'Proposal Document' : 'Completion Document',
fileType: getFileType(snapshot.documentUrl),
uploadedAt: new Date().toISOString()
}}
onPreview={canPreview(snapshot.documentUrl) ? () => handlePreview({
fileName: isProposal ? 'Proposal Document' : 'Completion Document',
documentUrl: snapshot.documentUrl
}) : undefined}
onDownload={async () => {
// Handle download for document without ID
let downloadUrl = snapshot.documentUrl;
if (!downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
window.open(downloadUrl, '_blank');
}}
/>
</div>
)}
{/* Supporting Documents */}
{snapshot.otherDocuments && snapshot.otherDocuments.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center justify-between">
<span>Supporting Documents</span>
<Badge variant="secondary" className="text-[10px] h-5">
{snapshot.otherDocuments.length} Files
</Badge>
</h4>
<div className="space-y-2">
{snapshot.otherDocuments.map((doc: any, idx: number) => (
<DocumentCard
key={idx}
document={{
documentId: doc.documentId || '',
name: doc.originalFileName || doc.fileName || 'Supporting Document',
fileType: getFileType(doc.originalFileName || doc.fileName || ''),
uploadedAt: doc.uploadedAt || new Date().toISOString()
}}
onPreview={canPreview(doc.originalFileName || doc.fileName || '') ? () => handlePreview(doc) : undefined}
onDownload={doc.documentId ? downloadDocument : async () => {
let downloadUrl = doc.storageUrl || doc.documentUrl;
if (downloadUrl && !downloadUrl.startsWith('http')) {
const baseUrl = import.meta.env.VITE_BASE_URL || '';
const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
const cleanFileUrl = downloadUrl.startsWith('/') ? downloadUrl : `/${downloadUrl}`;
downloadUrl = `${cleanBaseUrl}${cleanFileUrl}`;
}
if (downloadUrl) window.open(downloadUrl, '_blank');
}}
/>
))}
</div>
</div>
)}
{/* Cost Breakup / Expenses */}
{(snapshot.costItems || snapshot.expenses) && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1">
{isProposal ? 'Cost Breakdown' : 'Expenses Breakdown'}
</h4>
<div className="border rounded-md overflow-hidden text-sm">
<table className="w-full text-left">
<thead className="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th className="p-3 font-medium">Description</th>
<th className="p-3 font-medium text-right">Amount</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{(snapshot.costItems || snapshot.expenses).length > 0 ? (
(snapshot.costItems || snapshot.expenses).map((item: any, idx: number) => (
<tr key={idx} className="bg-white hover:bg-gray-50/50">
<td className="p-3 text-gray-800">{item.description}</td>
<td className="p-3 text-right text-gray-900 font-medium tabular-nums">
{formatCurrency(item.amount)}
</td>
</tr>
))
) : (
<tr>
<td colSpan={2} className="p-4 text-center text-gray-500 italic text-xs">
No breakdown items available
</td>
</tr>
)}
<tr className="bg-gray-50/80 font-semibold text-gray-900 border-t-2 border-gray-100">
<td className="p-3">Total</td>
<td className="p-3 text-right tabular-nums">
{formatCurrency(snapshot.totalBudget || snapshot.totalExpenses)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{/* Comments */}
{snapshot.comments && (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-gray-900 border-b pb-1 flex items-center gap-1">
<AlignLeft className="w-4 h-4" />
Comments
</h4>
<div className="bg-gray-50 rounded-lg p-3 text-sm text-gray-700 italic border border-gray-100">
{snapshot.comments}
</div>
</div>
)}
</div>
</div>
<div className="px-6 py-4 border-t bg-gray-50 flex justify-end">
<Button onClick={onClose}>Close</Button>
</div>
</DialogContent>
</Dialog>
{/* File Preview */}
{previewDoc && (
<FilePreview
fileName={previewDoc.fileName}
fileType={previewDoc.fileType}
fileUrl={previewDoc.fileUrl}
fileSize={previewDoc.fileSize}
attachmentId={previewDoc.documentId}
onDownload={downloadDocument}
open={!!previewDoc}
onClose={() => setPreviewDoc(null)}
/>
)}
</>
);
}

View File

@ -5,7 +5,6 @@
* Located in: src/dealer-claim/components/request-detail/modals/ * Located in: src/dealer-claim/components/request-detail/modals/
*/ */
export { AdditionalApproverReviewModal } from './AdditionalApproverReviewModal';
export { CreditNoteSAPModal } from './CreditNoteSAPModal'; export { CreditNoteSAPModal } from './CreditNoteSAPModal';
export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal'; export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal';
export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal'; export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal';
@ -13,6 +12,4 @@ export { DeptLeadIOApprovalModal } from './DeptLeadIOApprovalModal';
export { DMSPushModal } from './DMSPushModal'; export { DMSPushModal } from './DMSPushModal';
export { EditClaimAmountModal } from './EditClaimAmountModal'; export { EditClaimAmountModal } from './EditClaimAmountModal';
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal'; export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
export { InitiatorActionModal } from './InitiatorActionModal';
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal'; export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
export { SnapshotDetailsModal } from './SnapshotDetailsModal';

View File

@ -30,13 +30,5 @@ export { ClaimManagementWizard } from './components/request-creation/ClaimManage
// Request Detail Screen (Complete standalone screen) // Request Detail Screen (Complete standalone screen)
export { DealerClaimRequestDetail } from './pages/RequestDetail'; export { DealerClaimRequestDetail } from './pages/RequestDetail';
// Dashboard
export { DealerDashboard } from './pages/Dashboard';
// Filters
export { DealerRequestsFilters } from './components/DealerRequestsFilters';
export { DealerClosedRequestsFilters } from './components/DealerClosedRequestsFilters';
export { DealerUserAllRequestsFilters } from './components/DealerUserAllRequestsFilters';
// Re-export types // Re-export types
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types'; export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';

View File

@ -1,671 +0,0 @@
import { useEffect, useState, useMemo } from 'react';
import { Shield, Clock, FileText, ChartColumn, ChartPie, Activity, Target, DollarSign, Zap, Package, TrendingUp, TrendingDown, CircleCheckBig, CircleX, CreditCard, TriangleAlert } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
import { getDealerDashboard, type DashboardKPIs as DashboardKPIsType, type CategoryData as CategoryDataType } from '@/services/dealerClaimApi';
import { RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
// Use types from dealerClaimApi
type DashboardKPIs = DashboardKPIsType;
type CategoryData = CategoryDataType;
interface DashboardProps {
onNavigate?: (page: string) => void;
onNewRequest?: () => void;
}
export function DealerDashboard({ onNavigate, onNewRequest: _onNewRequest }: DashboardProps) {
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [kpis, setKpis] = useState<DashboardKPIs>({
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
const [categoryData, setCategoryData] = useState<CategoryData[]>([]);
const [dateRange, _setDateRange] = useState<string>('all');
const [startDate, _setStartDate] = useState<string | undefined>();
const [endDate, _setEndDate] = useState<string | undefined>();
const fetchDashboardData = async (isRefresh = false) => {
try {
if (isRefresh) {
setRefreshing(true);
} else {
setLoading(true);
}
// Fetch dealer claims dashboard data
const data = await getDealerDashboard(
dateRange || 'all',
startDate,
endDate
);
setKpis(data.kpis || {
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
setCategoryData(data.categoryData || []);
} catch (error: any) {
console.error('[DealerDashboard] Error fetching data:', error);
toast.error('Failed to load dashboard data. Please try again later.');
// Reset to empty state on error
setKpis({
totalClaims: 0,
totalValue: 0,
approved: 0,
rejected: 0,
pending: 0,
credited: 0,
pendingCredit: 0,
approvedValue: 0,
rejectedValue: 0,
pendingValue: 0,
creditedValue: 0,
pendingCreditValue: 0,
});
setCategoryData([]);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchDashboardData();
}, []);
const formatCurrency = (amount: number, showExactRupees = false) => {
// Handle null, undefined, or invalid values
if (amount == null || isNaN(amount)) {
return '₹0';
}
// Convert to number if it's a string
const numAmount = typeof amount === 'string' ? parseFloat(amount) : Number(amount);
// Handle zero or negative values
if (numAmount <= 0) {
return '₹0';
}
// If showExactRupees is true or amount is less than 10,000, show exact rupees
if (showExactRupees || numAmount < 10000) {
return `${Math.round(numAmount).toLocaleString('en-IN')}`;
}
if (numAmount >= 100000) {
return `${(numAmount / 100000).toFixed(1)}L`;
}
if (numAmount >= 1000) {
return `${(numAmount / 1000).toFixed(1)}K`;
}
// Show exact rupee amount for amounts less than 1000 (e.g., ₹100, ₹200, ₹999)
return `${Math.round(numAmount).toLocaleString('en-IN')}`;
};
const formatNumber = (num: number) => {
return num.toLocaleString('en-IN');
};
const calculateApprovalRate = () => {
if (kpis.totalClaims === 0) return 0;
return ((kpis.approved / kpis.totalClaims) * 100).toFixed(1);
};
const calculateCreditRate = () => {
if (kpis.approved === 0) return 0;
return ((kpis.credited / kpis.approved) * 100).toFixed(1);
};
// Prepare data for pie chart (Distribution by Activity Type)
const distributionData = useMemo(() => {
const totalRaised = categoryData.reduce((sum, cat) => sum + cat.raised, 0);
if (totalRaised === 0) return [];
return categoryData.map(cat => ({
name: cat.activityType.length > 20 ? cat.activityType.substring(0, 20) + '...' : cat.activityType,
value: cat.raised,
fullName: cat.activityType,
percentage: ((cat.raised / totalRaised) * 100).toFixed(0),
}));
}, [categoryData]);
// Prepare data for bar chart (Status by Category)
const statusByCategoryData = useMemo(() => {
return categoryData.map(cat => ({
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
fullName: cat.activityType,
Raised: cat.raised,
Approved: cat.approved,
Rejected: cat.rejected,
Pending: cat.pending,
}));
}, [categoryData]);
// Prepare data for value comparison chart (keep original values, formatCurrency will handle display)
const valueComparisonData = useMemo(() => {
return categoryData.map(cat => ({
name: cat.activityType.length > 15 ? cat.activityType.substring(0, 15) + '...' : cat.activityType,
fullName: cat.activityType,
Raised: cat.raisedValue, // Keep original value
Approved: cat.approvedValue, // Keep original value
Credited: cat.creditedValue, // Keep original value
}));
}, [categoryData]);
const COLORS = ['#166534', '#15803d', '#16a34a', '#22c55e', '#4ade80', '#86efac', '#bbf7d0'];
// Find best performing category
const bestPerforming = useMemo(() => {
if (categoryData.length === 0) return null;
return categoryData.reduce((best, cat) =>
cat.approvalRate > (best?.approvalRate || 0) ? cat : best
);
}, [categoryData]);
// Find highest value category
const highestValue = useMemo(() => {
if (categoryData.length === 0) return null;
return categoryData.reduce((best, cat) =>
cat.raisedValue > (best?.raisedValue || 0) ? cat : best
);
}, [categoryData]);
if (loading) {
return (
<div className="flex items-center justify-center h-screen">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="w-8 h-8 animate-spin text-blue-600" />
<p className="text-muted-foreground">Loading dashboard...</p>
</div>
</div>
);
}
// Show empty state if no data
const hasNoData = kpis.totalClaims === 0 && categoryData.length === 0;
if (hasNoData) {
return (
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
{/* Hero Section */}
<Card className="border-0 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
<Button
onClick={() => onNavigate?.('/new-request')}
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<FileText className="w-5 h-5 mr-2" />
Create New Claim
</Button>
<Button
onClick={() => {
setRefreshing(true);
fetchDashboardData(true);
}}
disabled={refreshing}
variant="outline"
className="bg-white/10 hover:bg-white/20 text-white border-white/20"
>
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Empty State */}
<Card className="shadow-lg">
<CardContent className="flex flex-col items-center justify-center py-16 px-4">
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mb-6">
<ChartPie className="w-12 h-12 text-gray-400" />
</div>
<h2 className="text-2xl font-semibold text-gray-900 mb-2">No Claims Data Available</h2>
<p className="text-gray-600 text-center max-w-md mb-6">
You don't have any claims data yet. Once you create and submit claim requests, your analytics will appear here.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<Button
onClick={() => onNavigate?.('/new-request')}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<FileText className="w-5 h-5 mr-2" />
Create Your First Claim
</Button>
<Button
onClick={() => {
setRefreshing(true);
fetchDashboardData(true);
}}
disabled={refreshing}
variant="outline"
>
<RefreshCw className={`w-5 h-5 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh Data
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6 max-w-[1600px] mx-auto p-4">
{/* Hero Section */}
<Card className="border-0 shadow-xl relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900" />
<CardContent className="relative z-10 p-8 lg:p-12">
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-6">
<div className="text-white">
<div className="flex items-center gap-4 mb-6">
<div className="w-16 h-16 bg-yellow-400 rounded-xl flex items-center justify-center shadow-lg">
<Shield className="w-8 h-8 text-slate-900" />
</div>
<div>
<h1 className="text-4xl text-white font-bold">Claims Analytics Dashboard</h1>
<p className="text-xl text-gray-200 mt-1">Comprehensive insights into approval workflows</p>
</div>
</div>
<div className="flex flex-wrap gap-4 mt-8">
<Button
onClick={() => onNavigate?.('/requests?status=pending')}
className="bg-blue-600 hover:bg-blue-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<Clock className="w-5 h-5 mr-2" />
View Pending Claims
</Button>
<Button
onClick={() => onNavigate?.('/requests')}
className="bg-emerald-600 hover:bg-emerald-700 text-white border-0 shadow-lg hover:shadow-xl transition-all duration-200"
>
<FileText className="w-5 h-5 mr-2" />
My Claims
</Button>
</div>
</div>
<div className="hidden lg:flex items-center gap-4">
<div className="w-24 h-24 bg-yellow-400/20 rounded-full flex items-center justify-center">
<div className="w-16 h-16 bg-yellow-400/30 rounded-full flex items-center justify-center">
<ChartColumn className="w-8 h-8 text-yellow-400" />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-6 gap-4">
<Card className="border-l-4 border-l-blue-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Raised Claims</CardTitle>
<div className="p-2 rounded-lg bg-blue-50">
<FileText className="h-4 w-4 text-blue-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.totalClaims)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.totalValue, true)}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-green-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Approved</CardTitle>
<div className="p-2 rounded-lg bg-green-50">
<CircleCheckBig className="h-4 w-4 text-green-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.approved)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-green-600" />
<p className="text-xs text-green-600">{calculateApprovalRate()}% approval rate</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-red-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Rejected</CardTitle>
<div className="p-2 rounded-lg bg-red-50">
<CircleX className="h-4 w-4 text-red-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.rejected)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingDown className="h-3 w-3 text-red-600" />
<p className="text-xs text-red-600">
{kpis.totalClaims > 0 ? ((kpis.rejected / kpis.totalClaims) * 100).toFixed(1) : 0}% rejection rate
</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-orange-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Pending</CardTitle>
<div className="p-2 rounded-lg bg-orange-50">
<Clock className="h-4 w-4 text-orange-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.pending)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingValue)}</p>
</CardContent>
</Card>
<Card className="border-l-4 border-l-emerald-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Credited</CardTitle>
<div className="p-2 rounded-lg bg-emerald-50">
<CreditCard className="h-4 w-4 text-emerald-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.credited)}</div>
<div className="flex items-center gap-1 mt-1">
<TrendingUp className="h-3 w-3 text-emerald-600" />
<p className="text-xs text-emerald-600">{calculateCreditRate()}% credit rate</p>
</div>
</CardContent>
</Card>
<Card className="border-l-4 border-l-amber-500 shadow-lg hover:shadow-xl transition-all duration-300">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm text-muted-foreground">Pending Credit</CardTitle>
<div className="p-2 rounded-lg bg-amber-50">
<TriangleAlert className="h-4 w-4 text-amber-600" />
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl text-gray-900">{formatNumber(kpis.pendingCredit)}</div>
<p className="text-xs text-muted-foreground mt-1">{formatCurrency(kpis.pendingCreditValue)}</p>
</CardContent>
</Card>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Distribution by Activity Type */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-purple-100 rounded-lg">
<ChartPie className="h-5 w-5 text-purple-600" />
</div>
<div>
<CardTitle>Claims Distribution by Activity Type</CardTitle>
<CardDescription>Total claims raised across activity types</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={distributionData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percentage }) => `${name}: ${percentage}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{distributionData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
<div className="grid grid-cols-3 gap-2 mt-4">
{distributionData.slice(0, 3).map((item, index) => (
<div key={index} className="flex items-center gap-2 p-2 rounded-lg bg-gray-50">
<div className="w-3 h-3 rounded" style={{ backgroundColor: COLORS[index % COLORS.length] }} />
<div>
<p className="text-xs text-gray-600">{item.name}</p>
<p className="text-sm text-gray-900">{formatNumber(item.value)}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Status by Category */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-blue-100 rounded-lg">
<ChartColumn className="h-5 w-5 text-blue-600" />
</div>
<div>
<CardTitle>Claims Status by Activity Type</CardTitle>
<CardDescription>Count comparison across workflow stages</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={statusByCategoryData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="Raised" fill="#3b82f6" />
<Bar dataKey="Approved" fill="#22c55e" />
<Bar dataKey="Rejected" fill="#ef4444" />
<Bar dataKey="Pending" fill="#f59e0b" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Detailed Category Breakdown */}
<Card className="shadow-lg">
<CardHeader>
<div className="flex items-center gap-3">
<div className="p-3 bg-emerald-100 rounded-lg">
<Activity className="h-5 w-5 text-emerald-600" />
</div>
<div>
<CardTitle>Detailed Activity Type Breakdown</CardTitle>
<CardDescription>In-depth analysis of claims by type and status</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<h3 className="text-lg mb-4 text-gray-900">Activity Type Value Comparison</h3>
<ResponsiveContainer width="100%" height={350}>
<BarChart data={valueComparisonData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis tickFormatter={(value) => formatCurrency(value)} />
<Tooltip
formatter={(value: number) => formatCurrency(value)}
labelFormatter={(label) => label}
/>
<Legend />
<Bar dataKey="Raised" fill="#3b82f6" />
<Bar dataKey="Approved" fill="#22c55e" />
<Bar dataKey="Credited" fill="#10b981" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
{categoryData.slice(0, 3).map((cat, index) => (
<Card key={index} className="shadow-md hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{cat.activityType}</CardTitle>
<Badge className="bg-emerald-50 text-emerald-700 border-emerald-200">
{cat.approvalRate.toFixed(1)}% approved
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Raised:</span>
<span className="text-gray-900">{formatNumber(cat.raised)} ({formatCurrency(cat.raisedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Approved:</span>
<span className="text-green-600">{formatNumber(cat.approved)} ({formatCurrency(cat.approvedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Rejected:</span>
<span className="text-red-600">{formatNumber(cat.rejected)} ({formatCurrency(cat.rejectedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Pending:</span>
<span className="text-orange-600">{formatNumber(cat.pending)} ({formatCurrency(cat.pendingValue)})</span>
</div>
<div className="h-px bg-gray-200 my-2" />
<div className="flex justify-between text-sm">
<span className="text-gray-600">Credited:</span>
<span className="text-emerald-600">{formatNumber(cat.credited)} ({formatCurrency(cat.creditedValue)})</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Pending Credit:</span>
<span className="text-amber-600">{formatNumber(cat.pendingCredit)} ({formatCurrency(cat.pendingCreditValue)})</span>
</div>
</div>
<div className="pt-2">
<div className="flex justify-between text-xs text-gray-600 mb-1">
<span>Credit Rate</span>
<span>{cat.creditRate.toFixed(1)}%</span>
</div>
<Progress value={cat.creditRate} className="h-2" />
</div>
</CardContent>
</Card>
))}
</div>
</div>
</CardContent>
</Card>
{/* Performance Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-t-4 border-t-green-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-green-100 rounded-lg">
<Target className="h-6 w-6 text-green-600" />
</div>
<TrendingUp className="h-5 w-5 text-green-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Best Performing</h3>
<p className="text-xl text-gray-900 mb-1">{bestPerforming?.activityType || 'N/A'}</p>
<p className="text-sm text-green-600">{bestPerforming?.approvalRate.toFixed(2) || 0}% approval rate</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-blue-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-blue-100 rounded-lg">
<DollarSign className="h-6 w-6 text-blue-600" />
</div>
<Activity className="h-5 w-5 text-blue-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Top Activity Type</h3>
<p className="text-xl text-gray-900 mb-1">{highestValue?.activityType || 'N/A'}</p>
<p className="text-sm text-blue-600">{highestValue ? formatCurrency(highestValue.raisedValue, true) : '₹0'} raised</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-emerald-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-emerald-100 rounded-lg">
<Zap className="h-6 w-6 text-emerald-600" />
</div>
<CircleCheckBig className="h-5 w-5 text-emerald-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Overall Credit Rate</h3>
<p className="text-xl text-gray-900 mb-1">{calculateCreditRate()}%</p>
<p className="text-sm text-emerald-600">{formatNumber(kpis.credited)} claims credited</p>
</CardContent>
</Card>
<Card className="border-t-4 border-t-amber-500 shadow-lg hover:shadow-xl transition-shadow">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="p-3 bg-amber-100 rounded-lg">
<Package className="h-6 w-6 text-amber-600" />
</div>
<TriangleAlert className="h-5 w-5 text-amber-600" />
</div>
<h3 className="text-sm text-gray-600 mb-1">Pending Action</h3>
<p className="text-xl text-gray-900 mb-1">{formatNumber(kpis.pendingCredit)}</p>
<p className="text-sm text-amber-600">{formatCurrency(kpis.pendingCreditValue)} awaiting credit</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -38,10 +38,6 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useModalManager } from '@/hooks/useModalManager'; import { useModalManager } from '@/hooks/useModalManager';
import { useConclusionRemark } from '@/hooks/useConclusionRemark'; import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { downloadDocument } from '@/services/workflowApi'; import { downloadDocument } from '@/services/workflowApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { getSocket, joinUserRoom } from '@/utils/socket';
// 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';
@ -117,24 +113,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const [showPauseModal, setShowPauseModal] = useState(false); const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false); const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false); const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
const { user } = useAuth(); const { user } = useAuth();
// Custom hooks // Custom hooks
@ -153,14 +131,34 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number) // Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
// Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber
const approvalFlow = apiRequest?.approvalFlow || [];
const approvals = apiRequest?.approvals || [];
// Find Department Lead step dynamically by levelName (handles step shifts when approvers are added)
const deptLeadLevel = approvalFlow.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('department lead');
}) || approvals.find((level: any) => {
const levelName = (level.levelName || level.level_name || '').toLowerCase();
return levelName.includes('department lead');
}) || approvalFlow.find((level: any) =>
(level.step || level.levelNumber || level.level_number) === 3
) || approvals.find((level: any) =>
(level.levelNumber || level.level_number) === 3
); // Fallback to step 3 for backwards compatibility
const deptLeadUserId = deptLeadLevel?.approverId || deptLeadLevel?.approver_id || deptLeadLevel?.approver?.userId;
const deptLeadEmail = (deptLeadLevel?.approverEmail || deptLeadLevel?.approver_email || deptLeadLevel?.approver?.email || '').toLowerCase().trim();
// User is department lead if they match the Department Lead approver (regardless of status or step number)
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
// 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 only for department lead (found dynamically, not hardcoded to step 3)
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer'; const showIOTab = isDeptLead;
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
request?.templateType === 'claim-management';
const showIOTab = isClaimManagement && !isDealer;
const { const {
mergedMessages, mergedMessages,
@ -179,12 +177,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setDocumentError, setDocumentError,
} = useDocumentUpload(apiRequest, refreshDetails); } = useDocumentUpload(apiRequest, refreshDetails);
// State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
const { const {
showApproveModal, showApproveModal,
setShowApproveModal, setShowApproveModal,
@ -202,30 +194,26 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setSkipApproverData, setSkipApproverData,
actionStatus, actionStatus,
setActionStatus, setActionStatus,
handleApproveConfirm: originalHandleApproveConfirm, handleApproveConfirm,
handleRejectConfirm: originalHandleRejectConfirm, handleRejectConfirm,
handleAddApprover, handleAddApprover,
handleSkipApprover, handleSkipApprover,
handleAddSpectator, handleAddSpectator,
} = useModalManager(requestIdentifier, effectiveApprovalLevel, refreshDetails); } = useModalManager(requestIdentifier, currentApprovalLevel, refreshDetails);
// Wrapper handlers that clear temporary level after action
const handleApproveConfirm = async (description: string) => {
await originalHandleApproveConfirm(description);
setTemporaryApprovalLevel(null);
};
const handleRejectConfirm = async (description: string) => {
await originalHandleRejectConfirm(description);
setTemporaryApprovalLevel(null);
};
// 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 needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
// Closure check completed // Debug logging
console.debug('[DealerClaimRequestDetail] Closure check:', {
requestStatus,
requestStatusRaw: request?.status,
apiRequestStatusRaw: apiRequest?.status,
isInitiator,
needsClosure,
});
const { const {
conclusionRemark, conclusionRemark,
setConclusionRemark, setConclusionRemark,
@ -234,9 +222,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
aiGenerated, aiGenerated,
handleGenerateConclusion, handleGenerateConclusion,
handleFinalizeConclusion, handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached,
} = useConclusionRemark( } = useConclusionRemark(
request, request,
requestIdentifier, requestIdentifier,
@ -247,32 +232,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowActionStatusModal setShowActionStatusModal
); );
// Load system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
}
};
loadSystemPolicy();
}, []);
// Auto-switch tab when URL query parameter changes // Auto-switch tab when URL query parameter changes
useEffect(() => { useEffect(() => {
const urlParams = new URLSearchParams(window.location.search); const urlParams = new URLSearchParams(window.location.search);
@ -325,7 +284,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowShareSummaryModal(true); setShowShareSummaryModal(true);
}; };
const isClosed = request?.status === 'closed'; const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// Fetch summary details if request is closed // Fetch summary details if request is closed
useEffect(() => { useEffect(() => {
@ -339,7 +298,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try { try {
setLoadingSummary(true); setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId); const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) { if (summary?.summaryId) {
setSummaryId(summary.summaryId); setSummaryId(summary.summaryId);
try { try {
@ -365,37 +324,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
fetchSummaryDetails(); fetchSummaryDetails();
}, [isClosed, apiRequest?.requestId]); }, [isClosed, apiRequest?.requestId]);
// Listen for credit note notifications and trigger silent refresh
useEffect(() => {
if (!currentUserId || !apiRequest?.requestId) return;
const socket = getSocket();
if (!socket) return;
joinUserRoom(socket, currentUserId);
const handleNewNotification = (data: { notification: any }) => {
const notif = data?.notification;
if (!notif) return;
const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
refreshDetails();
}
};
socket.on('notification:new', handleNewNotification);
return () => {
socket.off('notification:new', handleNewNotification);
};
}, [currentUserId, apiRequest?.requestId, requestIdentifier, refreshDetails]);
// Get current levels for WorkNotesTab // Get current levels for WorkNotesTab
const currentLevels = (request?.approvalFlow || []) const currentLevels = (request?.approvalFlow || [])
.filter((flow: any) => flow && typeof flow.step === 'number') .filter((flow: any) => flow && typeof flow.step === 'number')
@ -431,15 +359,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message} {accessDenied.message}
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -464,15 +392,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
The dealer claim request you're looking for doesn't exist or may have been deleted. The dealer claim request you're looking for doesn't exist or may have been deleted.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -594,16 +522,13 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
aiGenerated={aiGenerated} aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion} handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion} handleFinalizeConclusion={handleFinalizeConclusion}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/> />
</TabsContent> </TabsContent>
{isClosed && ( {isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content"> <TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab <SummaryTab
summary={summaryDetails} summary={summaryDetails}
loading={loadingSummary} loading={loadingSummary}
onShare={handleShareSummary} onShare={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}
@ -625,7 +550,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowSkipApproverModal(true); setShowSkipApproverModal(true);
}} }}
onRefresh={refreshDetails} onRefresh={refreshDetails}
documentPolicy={documentPolicy}
/> />
</TabsContent> </TabsContent>
@ -665,8 +589,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
isSpectator={isSpectator} isSpectator={isSpectator}
currentLevels={currentLevels} currentLevels={currentLevels}
onAddApprover={handleAddApprover} onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/> />
</TabsContent> </TabsContent>
</div> </div>
@ -756,8 +678,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
actionStatus={actionStatus} actionStatus={actionStatus}
existingParticipants={existingParticipants} existingParticipants={existingParticipants}
currentLevels={currentLevels} currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal} setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal} setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal} setShowAddApproverModal={setShowAddApproverModal}
@ -776,19 +696,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
downloadDocument={downloadDocument} downloadDocument={downloadDocument}
documentPolicy={documentPolicy} documentPolicy={documentPolicy}
/> />
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators,
}}
/>
</> </>
); );
} }

View File

@ -13,7 +13,6 @@
*/ */
import { RequestFlowType } from '@/utils/requestTypeUtils'; import { RequestFlowType } from '@/utils/requestTypeUtils';
import { UserFilterType } from '@/utils/userFilterUtils';
// Import flow modules from src/ level // Import flow modules from src/ level
import * as CustomFlow from './custom'; import * as CustomFlow from './custom';
@ -89,79 +88,6 @@ export function getRequestDetailScreen(flowType: RequestFlowType) {
} }
} }
/**
* Get Requests Filters component for a user filter type
* Each user type can have its own filter component
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + sort only)
* - STANDARD: Full filters (search + status + priority + template + sort)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardRequestsFilters;
}
}
/**
* Get Closed Requests Filters component for a user filter type
* Each user type can have its own filter component for closed requests
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + status + sort only, no priority or template)
* - STANDARD: Full filters (search + priority + status + template + sort)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a closed requests filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getClosedRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerClosedRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardClosedRequestsFilters;
}
}
/**
* Get User All Requests Filters component for a user filter type
* Each user type can have its own filter component for user all requests
*
* This allows for plug-and-play filter components:
* - DEALER: Simplified filters (search + status + initiator + approver + date range, no priority/template/department/sla)
* - STANDARD: Full filters (all filters including priority, template, department, and SLA compliance)
*
* To add a new user filter type:
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
* 2. Create a user all requests filter component in the appropriate flow folder
* 3. Export it from the flow's index.ts
* 4. Add a case here to return it
*/
export function getUserAllRequestsFilters(userFilterType: UserFilterType) {
switch (userFilterType) {
case 'DEALER':
return DealerClaimFlow.DealerUserAllRequestsFilters;
case 'STANDARD':
default:
return CustomFlow.StandardUserAllRequestsFilters;
}
}
// Re-export flow modules for direct access // Re-export flow modules for direct access
export { CustomFlow, DealerClaimFlow, SharedComponents }; export { CustomFlow, DealerClaimFlow, SharedComponents };
export type { RequestFlowType } from '@/utils/requestTypeUtils'; export type { RequestFlowType } from '@/utils/requestTypeUtils';
export type { UserFilterType } from '@/utils/userFilterUtils';

View File

@ -42,18 +42,6 @@ export function useConclusionRemark(
// State: Tracks if current conclusion was AI-generated (shows badge in UI) // State: Tracks if current conclusion was AI-generated (shows badge in UI)
const [aiGenerated, setAiGenerated] = useState(false); const [aiGenerated, setAiGenerated] = useState(false);
// State: Tracks number of AI generation attempts
const [generationAttempts, setGenerationAttempts] = useState(0);
// State: Tracks if AI generation failed (unable to generate)
const [generationFailed, setGenerationFailed] = useState(false);
// State: Tracks if max attempts (3 for success, 1 for fail) reached
const [maxAttemptsReached, setMaxAttemptsReached] = useState(false);
// State: Tracks number of AI generation failures
const [failureAttempts, setFailureAttempts] = useState(0);
/** /**
* Function: fetchExistingConclusion * Function: fetchExistingConclusion
* *
@ -62,46 +50,26 @@ export function useConclusionRemark(
* Use Case: When request is approved, final approver generates conclusion. * Use Case: When request is approved, final approver generates conclusion.
* Initiator needs to review and finalize it before closing request. * Initiator needs to review and finalize it before closing request.
* *
* Optimization: Check request object first before making API call
* Process: * Process:
* 1. Check if conclusion data is already in request object * 1. Dynamically import conclusion API service
* 2. If not available, fetch from API * 2. Fetch conclusion by request ID
* 3. Load into state if exists * 3. Load into state if exists
* 4. Mark as AI-generated if applicable * 4. Mark as AI-generated if applicable
*/ */
const fetchExistingConclusion = async () => { const fetchExistingConclusion = async () => {
// Optimization: Check if conclusion data is already in request object
// Request detail response includes conclusionRemark and aiGeneratedConclusion fields
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
if (existingConclusion || existingAiConclusion) {
// Use data from request object - no API call needed
setConclusionRemark(existingConclusion || existingAiConclusion);
setAiGenerated(!!existingAiConclusion);
return;
}
// Only fetch from API if not available in request object
// This handles cases where request object might not have been refreshed yet
try { try {
// Lazy load: Import conclusion API only when needed // Lazy load: Import conclusion API only when needed
const { getConclusion } = await import('@/services/conclusionApi'); const { getConclusion } = await import('@/services/conclusionApi');
// API Call: Fetch existing conclusion (returns null if not found) // API Call: Fetch existing conclusion
const result = await getConclusion(request.requestId || requestIdentifier); const result = await getConclusion(request.requestId || requestIdentifier);
if (result && (result.aiGeneratedRemark || result.finalRemark)) { if (result && result.aiGeneratedRemark) {
// Load: Set the AI-generated or final remark // Load: Set the AI-generated or final remark
// Handle null values by providing empty string fallback setConclusionRemark(result.finalRemark || result.aiGeneratedRemark);
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark || '');
setAiGenerated(!!result.aiGeneratedRemark); setAiGenerated(!!result.aiGeneratedRemark);
} }
} catch (err) { } catch (err) {
// Only log non-404 errors (404 is handled gracefully in API)
if ((err as any)?.response?.status !== 404) {
console.error('[useConclusionRemark] Error fetching conclusion:', err);
}
// No conclusion yet - this is expected for newly approved requests // No conclusion yet - this is expected for newly approved requests
} }
}; };
@ -125,12 +93,8 @@ export function useConclusionRemark(
* 5. Handle errors silently (user can type manually) * 5. Handle errors silently (user can type manually)
*/ */
const handleGenerateConclusion = async () => { const handleGenerateConclusion = async () => {
// Safety check: Prevent generation if max attempts already reached
if (maxAttemptsReached) return;
try { try {
setConclusionLoading(true); setConclusionLoading(true);
setGenerationFailed(false);
// Lazy load: Import conclusion API // Lazy load: Import conclusion API
const { generateConclusion } = await import('@/services/conclusionApi'); const { generateConclusion } = await import('@/services/conclusionApi');
@ -138,74 +102,14 @@ export function useConclusionRemark(
// API Call: Generate AI conclusion based on request data // API Call: Generate AI conclusion based on request data
const result = await generateConclusion(request.requestId || requestIdentifier); const result = await generateConclusion(request.requestId || requestIdentifier);
const newAttempts = generationAttempts + 1;
setGenerationAttempts(newAttempts);
// Check for "unable to generate" or similar keywords in proper response
const isUnableToGenerate = !result?.aiGeneratedRemark ||
result.aiGeneratedRemark.toLowerCase().includes('unable to generate') ||
result.aiGeneratedRemark.toLowerCase().includes('sorry');
if (isUnableToGenerate) {
const newFailures = failureAttempts + 1;
setFailureAttempts(newFailures);
if (newFailures >= 2) {
setMaxAttemptsReached(true);
setActionStatus?.({
success: false,
title: 'AI Generation Limit Reached',
message: "We're unable to process a conclusion remark at this time after 2 attempts. Please proceed with a manual approach using the editor below."
});
} else {
setActionStatus?.({
success: false,
title: 'System Note',
message: "We're unable to process a conclusion remark at the moment. You have one more attempt remaining, or you can proceed manually."
});
}
setShowActionStatusModal?.(true);
setConclusionRemark(result?.aiGeneratedRemark || '');
setAiGenerated(false);
return;
}
// Success: Load AI-generated remark // Success: Load AI-generated remark
setConclusionRemark(result.aiGeneratedRemark); setConclusionRemark(result.aiGeneratedRemark);
setAiGenerated(true); setAiGenerated(true);
setFailureAttempts(0); // Reset failures on success
// Limit to 2 successful attempts
if (newAttempts >= 2) {
setMaxAttemptsReached(true);
setActionStatus?.({
success: true,
title: 'Maximum Attempts Reached',
message: "You've reached the maximum of 2 regeneration attempts. Feel free to manually edit the current suggestion to fit your specific needs."
});
setShowActionStatusModal?.(true);
}
} catch (err) { } catch (err) {
// Fail silently: User can write conclusion manually
console.error('[useConclusionRemark] AI generation failed:', err); console.error('[useConclusionRemark] AI generation failed:', err);
const newFailures = failureAttempts + 1; setConclusionRemark('');
setFailureAttempts(newFailures);
setAiGenerated(false); setAiGenerated(false);
if (newFailures >= 2) {
setMaxAttemptsReached(true);
setActionStatus?.({
success: false,
title: 'System Note',
message: "We're unable to process your request at the moment. Since the maximum of 2 attempts is reached, please proceed with a manual approach."
});
} else {
setActionStatus?.({
success: false,
title: 'System Note',
message: "We're unable to process your request at the moment. You have one more attempt remaining, or you can proceed manually."
});
}
setShowActionStatusModal?.(true);
} finally { } finally {
setConclusionLoading(false); setConclusionLoading(false);
} }
@ -314,36 +218,16 @@ export function useConclusionRemark(
}; };
/** /**
* Effect: Auto-load existing conclusion when request becomes approved, rejected, or closed * Effect: Auto-fetch existing conclusion when request becomes approved or rejected
* *
* Trigger: When request status changes to "approved", "rejected", or "closed" and user is initiator * Trigger: When request status changes to "approved" or "rejected" and user is initiator
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected) * Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
*
* Optimization:
* 1. First check if conclusion data is already in request object (no API call needed)
* 2. Only fetch from API if not available in request object
*/ */
useEffect(() => { useEffect(() => {
const status = request?.status?.toLowerCase(); if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) {
const shouldLoad = (status === 'approved' || status === 'rejected' || status === 'closed')
&& isInitiator
&& !conclusionRemark;
if (!shouldLoad) return;
// Check if conclusion data is already in request object
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
if (existingConclusion || existingAiConclusion) {
// Use data from request object - no API call needed
setConclusionRemark(existingConclusion || existingAiConclusion);
setAiGenerated(!!existingAiConclusion);
} else {
// Only fetch from API if not available in request object
fetchExistingConclusion(); fetchExistingConclusion();
} }
}, [request?.status, request?.conclusionRemark, request?.aiGeneratedConclusion, isInitiator, conclusionRemark]); }, [request?.status, isInitiator]);
return { return {
conclusionRemark, conclusionRemark,
@ -352,10 +236,7 @@ export function useConclusionRemark(
conclusionSubmitting, conclusionSubmitting,
aiGenerated, aiGenerated,
handleGenerateConclusion, handleGenerateConclusion,
handleFinalizeConclusion, handleFinalizeConclusion
generationAttempts,
generationFailed,
maxAttemptsReached
}; };
} }

View File

@ -10,7 +10,6 @@ export interface RequestTemplate {
icon: React.ComponentType<any>; icon: React.ComponentType<any>;
estimatedTime: string; estimatedTime: string;
commonApprovers: string[]; commonApprovers: string[];
workflowApprovers?: any[]; // Full approver objects for Admin Templates
suggestedSLA: number; suggestedSLA: number;
priority: 'high' | 'medium' | 'low'; priority: 'high' | 'medium' | 'low';
fields: { fields: {
@ -163,9 +162,9 @@ export function useCreateRequestForm(
}); });
// Load system policy // Load system policy
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS'); const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING'); const tatConfigs = await getPublicConfigurations('TAT_SETTINGS');
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs]; const allConfigs = [...workflowConfigs, ...tatConfigs];
const configMap: Record<string, string> = {}; const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => { allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue; configMap[c.configKey] = c.configValue;
@ -200,7 +199,7 @@ export function useCreateRequestForm(
const approvals = Array.isArray(details.approvals) ? details.approvals : []; const approvals = Array.isArray(details.approvals) ? details.approvals : [];
const participants = Array.isArray(details.participants) ? details.participants : []; const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : []; const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : [];
// Store existing documents for tracking // Store existing documents for tracking
setExistingDocuments(documents); setExistingDocuments(documents);

View File

@ -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
@ -27,7 +26,7 @@ export function useDocumentUpload(
) { ) {
// State: Indicates if document is currently being uploaded // State: Indicates if document is currently being uploaded
const [uploadingDocument, setUploadingDocument] = useState(false); const [uploadingDocument, setUploadingDocument] = useState(false);
// State: Stores document for preview modal // State: Stores document for preview modal
const [previewDocument, setPreviewDocument] = useState<{ const [previewDocument, setPreviewDocument] = useState<{
fileName: string; fileName: string;
@ -102,7 +101,7 @@ export function useDocumentUpload(
// Check file extension // Check file extension
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return { return {
valid: false, valid: false,
@ -131,12 +130,12 @@ export function useDocumentUpload(
*/ */
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
// Validate: Check if file is selected // Validate: Check if file is selected
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const fileArray = Array.from(files); const fileArray = Array.from(files);
// Validate all files against document policy // Validate all files against document policy
const validationErrors: Array<{ fileName: string; reason: string }> = []; const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = []; const validFiles: File[] = [];
@ -170,11 +169,11 @@ export function useDocumentUpload(
} }
setUploadingDocument(true); setUploadingDocument(true);
try { try {
// Upload only the first valid file (backend currently supports single file) // Upload only the first valid file (backend currently supports single file)
const file = validFiles[0]; const file = validFiles[0];
// Validate: Ensure request ID is available // Validate: Ensure request ID is available
// Note: Backend requires UUID, not request number // Note: Backend requires UUID, not request number
const requestId = apiRequest?.requestId; const requestId = apiRequest?.requestId;
@ -182,17 +181,17 @@ export function useDocumentUpload(
toast.error('Request ID not found'); toast.error('Request ID not found');
return; return;
} }
// API Call: Upload document to backend // API Call: Upload document to backend
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED') // Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
if (file) { if (file) {
await uploadDocument(file, requestId, 'SUPPORTING'); await uploadDocument(file, requestId, 'SUPPORTING');
} }
// Refresh: Reload request details to show newly uploaded document // Refresh: Reload request details to show newly uploaded document
// This also updates the activity timeline // This also updates the activity timeline
await refreshDetails(); await refreshDetails();
// Success feedback // Success feedback
if (validFiles.length < fileArray.length) { if (validFiles.length < fileArray.length) {
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`); toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
@ -201,14 +200,12 @@ 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);
// Cleanup: Clear the file input to allow re-uploading same file // Cleanup: Clear the file input to allow re-uploading same file
if (event.target) { if (event.target) {
event.target.value = ''; event.target.value = '';

View File

@ -85,10 +85,6 @@ export function useModalManager(
// API Call: Submit approval // API Call: Submit approval
await approveLevel(requestIdentifier, levelId, description || ''); await approveLevel(requestIdentifier, levelId, description || '');
// Small delay to ensure backend has fully processed the approval and updated the status
// This is especially important for additional approvers where the workflow moves to the next step
await new Promise(resolve => setTimeout(resolve, 500));
// Refresh: Update UI with new approval status // Refresh: Update UI with new approval status
await refreshDetails(); await refreshDetails();

View File

@ -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';
/** /**
@ -28,19 +30,19 @@ export function useRequestDetails(
) { ) {
// State: Stores the fetched and transformed request data // State: Stores the fetched and transformed request data
const [apiRequest, setApiRequest] = useState<any | null>(null); const [apiRequest, setApiRequest] = useState<any | null>(null);
// State: Indicates if data is currently being fetched // State: Indicates if data is currently being fetched
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// State: Loading state for initial fetch // State: Loading state for initial fetch
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// State: Access denied information // State: Access denied information
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null); const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
// State: Stores the current approval level for the logged-in user // State: Stores the current approval level for the logged-in user
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null); const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
// State: Indicates if the current user is a spectator (view-only access) // State: Indicates if the current user is a spectator (view-only access)
const [isSpectator, setIsSpectator] = useState(false); const [isSpectator, setIsSpectator] = useState(false);
@ -101,14 +103,14 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : []; const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts for monitoring // Debug: Log TAT alerts for monitoring
if (tatAlerts.length > 0) { if (tatAlerts.length > 0) {
// TAT alerts loaded - logging removed // TAT alerts loaded - logging removed
} }
const currentLevel = summary?.currentLevel || wf.currentLevel || 1; const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
/** /**
* Transform: Map approval levels to UI format with TAT alerts * Transform: Map approval levels to UI format with TAT alerts
* Each approval level includes: * Each approval level includes:
@ -121,10 +123,10 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0; const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase(); const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id; const levelId = a.levelId || a.level_id;
// Determine display status based on workflow progress // Determine display status based on workflow progress
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
// Future levels that haven't been reached yet show as "waiting" // Future levels that haven't been reached yet show as "waiting"
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') { if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting'; displayStatus = 'waiting';
@ -133,10 +135,10 @@ export function useRequestDetails(
else if (levelNumber === currentLevel && levelStatus === 'PENDING') { else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
displayStatus = 'pending'; displayStatus = 'pending';
} }
// Filter: Get TAT alerts that belong to this specific approval level // Filter: Get TAT alerts that belong to this specific approval level
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return { return {
step: levelNumber, step: levelNumber,
levelId, levelId,
@ -150,8 +152,8 @@ export function useRequestDetails(
remainingHours: Number(a.remainingHours || 0), remainingHours: Number(a.remainingHours || 0),
tatPercentageUsed: Number(a.tatPercentageUsed || 0), tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Calculate actual hours taken if level is completed // Calculate actual hours taken if level is completed
actualHours: a.levelEndTime && a.levelStartTime actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
: undefined, : undefined,
comment: a.comments || undefined, comment: a.comments || undefined,
timestamp: a.actionDate || undefined, timestamp: a.actionDate || undefined,
@ -209,26 +211,23 @@ export function useRequestDetails(
* Filter: Remove TAT breach activities from audit trail * Filter: Remove TAT breach activities from audit trail
* TAT warnings are already shown in approval steps, no need to duplicate in timeline * TAT warnings are already shown in approval steps, no need to duplicate in timeline
*/ */
const filteredActivities = Array.isArray(details.activities) const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => { ? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase(); const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning'; return activityType !== 'sla_warning';
}) })
: []; : [];
/** /**
* Fetch: Get pause details only if request is actually paused * Fetch: Get pause details if request is paused
* Use request-level isPaused field from workflow response * This is needed to show resume/retrigger buttons correctly
*/ */
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false; try {
pauseInfo = await getPauseDetails(wf.requestId);
if (isPaused) { } catch (error) {
try { // Pause info not available or request not paused - ignore
pauseInfo = await getPauseDetails(wf.requestId); console.debug('Pause details not available:', error);
} catch (error) {
// Pause info not available - ignore
}
} }
/** /**
@ -238,26 +237,39 @@ 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 {
console.debug('[useRequestDetails] Fetching claim details for requestId:', wf.requestId);
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
console.debug('[useRequestDetails] Claim API response:', {
status: claimResponse.status,
hasData: !!claimResponse.data,
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
fullResponse: claimResponse.data,
});
const claimData = claimResponse.data?.data || claimResponse.data; const claimData = claimResponse.data?.data || claimResponse.data;
console.debug('[useRequestDetails] Extracted claimData:', {
hasClaimData: !!claimData,
claimDataKeys: claimData ? Object.keys(claimData) : [],
hasClaimDetails: !!(claimData?.claimDetails || claimData?.claim_details),
hasProposalDetails: !!(claimData?.proposalDetails || claimData?.proposal_details),
hasCompletionDetails: !!(claimData?.completionDetails || claimData?.completion_details),
hasInternalOrder: !!(claimData?.internalOrder || claimData?.internal_order),
});
if (claimData) { if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details; claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details; completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null; internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables // New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null; const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access // Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) { if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking; (claimDetails as any).budgetTracking = budgetTracking;
@ -265,8 +277,25 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote; (claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses; (claimDetails as any).completionExpenses = completionExpenses;
} }
// Extracted details processed console.debug('[useRequestDetails] Extracted details:', {
claimDetails: claimDetails ? {
hasActivityName: !!(claimDetails.activityName || claimDetails.activity_name),
hasActivityType: !!(claimDetails.activityType || claimDetails.activity_type),
hasLocation: !!(claimDetails.location),
activityName: claimDetails.activityName || claimDetails.activity_name,
activityType: claimDetails.activityType || claimDetails.activity_type,
location: claimDetails.location,
allKeys: Object.keys(claimDetails),
} : null,
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
hasInternalOrder: !!internalOrder,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
});
} else { } else {
console.warn('[useRequestDetails] No claimData found in response'); console.warn('[useRequestDetails] No claimData found in response');
} }
@ -328,14 +357,13 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null, proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null, completionDetails: completionDetails || null,
internalOrder: internalOrder || null, internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility) // New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null, budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
/** /**
@ -353,8 +381,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level // Include PAUSED status - paused level is still the current level
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED') return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
setCurrentApprovalLevel(newCurrentLevel || null); setCurrentApprovalLevel(newCurrentLevel || null);
@ -365,8 +393,8 @@ export function useRequestDetails(
*/ */
const viewerId = (user as any)?.userId; const viewerId = (user as any)?.userId;
if (viewerId) { if (viewerId) {
const isSpec = participants.some((p: any) => const isSpec = participants.some((p: any) =>
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' && (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
(p.userId || p.user_id) === viewerId (p.userId || p.user_id) === viewerId
); );
setIsSpectator(isSpec); setIsSpectator(isSpec);
@ -390,11 +418,11 @@ export function useRequestDetails(
setLoading(false); setLoading(false);
return; return;
} }
let mounted = true; let mounted = true;
setLoading(true); setLoading(true);
setAccessDenied(null); setAccessDenied(null);
(async () => { (async () => {
try { try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier); const details = await workflowApi.getWorkflowDetails(requestIdentifier);
@ -402,7 +430,7 @@ export function useRequestDetails(
if (mounted) setLoading(false); if (mounted) setLoading(false);
return; return;
} }
// Use the same transformation logic as refreshDetails // Use the same transformation logic as refreshDetails
const wf = details.workflow || {}; const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : []; const approvals = Array.isArray(details.approvals) ? details.approvals : [];
@ -410,7 +438,7 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : []; const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// TAT alerts received - logging removed // TAT alerts received - logging removed
const priority = (wf.priority || '').toString().toLowerCase(); const priority = (wf.priority || '').toString().toLowerCase();
@ -421,9 +449,9 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0; const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase(); const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id; const levelId = a.levelId || a.level_id;
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
// If paused, show paused status (don't change it) // If paused, show paused status (don't change it)
if (levelStatus === 'PAUSED') { if (levelStatus === 'PAUSED') {
displayStatus = 'paused'; displayStatus = 'paused';
@ -432,9 +460,9 @@ export function useRequestDetails(
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) { } else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending'; displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
} }
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return { return {
step: levelNumber, step: levelNumber,
levelId, levelId,
@ -449,8 +477,8 @@ export function useRequestDetails(
tatPercentageUsed: Number(a.tatPercentageUsed || 0), tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Use backend-calculated elapsedHours (working hours) for completed approvals // Use backend-calculated elapsedHours (working hours) for completed approvals
// Backend already calculates this correctly using calculateElapsedWorkingHours // Backend already calculates this correctly using calculateElapsedWorkingHours
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
? Number(a.elapsedHours) ? Number(a.elapsedHours)
: undefined, : undefined,
comment: a.comments || undefined, comment: a.comments || undefined,
timestamp: a.actionDate || undefined, timestamp: a.actionDate || undefined,
@ -458,7 +486,7 @@ export function useRequestDetails(
tatAlerts: levelAlerts, tatAlerts: levelAlerts,
}; };
}); });
// Map spectators // Map spectators
const spectators = participants const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR') .filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
@ -493,24 +521,20 @@ export function useRequestDetails(
}); });
// Filter out TAT warnings from activities // Filter out TAT warnings from activities
const filteredActivities = Array.isArray(details.activities) const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => { ? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase(); const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning'; return activityType !== 'sla_warning';
}) })
: []; : [];
// Fetch pause details only if request is actually paused // Fetch pause details
// Use request-level isPaused field from workflow response
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false; try {
pauseInfo = await getPauseDetails(wf.requestId);
if (isPaused) { } catch (error) {
try { // Pause info not available or request not paused - ignore
pauseInfo = await getPauseDetails(wf.requestId); console.debug('Pause details not available:', error);
} catch (error) {
// Pause info not available - ignore
}
} }
/** /**
@ -520,25 +544,29 @@ 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 {
console.debug('[useRequestDetails] Initial load - Fetching claim details for requestId:', wf.requestId);
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
console.debug('[useRequestDetails] Initial load - Claim API response:', {
status: claimResponse.status,
hasData: !!claimResponse.data,
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
});
const claimData = claimResponse.data?.data || claimResponse.data; const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) { if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details; claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
completionDetails = claimData.completionDetails || claimData.completion_details; completionDetails = claimData.completionDetails || claimData.completion_details;
internalOrder = claimData.internalOrder || claimData.internal_order || null; internalOrder = claimData.internalOrder || claimData.internal_order || null;
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
// New normalized tables // New normalized tables
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null; const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null; const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access // Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) { if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking; (claimDetails as any).budgetTracking = budgetTracking;
@ -546,8 +574,18 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote; (claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses; (claimDetails as any).completionExpenses = completionExpenses;
} }
// Initial load - Extracted details processed console.debug('[useRequestDetails] Initial load - Extracted details:', {
hasClaimDetails: !!claimDetails,
claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [],
hasProposalDetails: !!proposalDetails,
hasCompletionDetails: !!completionDetails,
hasInternalOrder: !!internalOrder,
hasBudgetTracking: !!budgetTracking,
hasInvoice: !!invoice,
hasCreditNote: !!creditNote,
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
});
} }
} catch (error: any) { } catch (error: any) {
// Claim details not available - request might not be fully initialized yet // Claim details not available - request might not be fully initialized yet
@ -596,16 +634,15 @@ export function useRequestDetails(
proposalDetails: proposalDetails || null, proposalDetails: proposalDetails || null,
completionDetails: completionDetails || null, completionDetails: completionDetails || null,
internalOrder: internalOrder || null, internalOrder: internalOrder || null,
internalOrders: internalOrders || [],
// New normalized tables (also available via claimDetails for backward compatibility) // New normalized tables (also available via claimDetails for backward compatibility)
budgetTracking: (claimDetails as any)?.budgetTracking || null, budgetTracking: (claimDetails as any)?.budgetTracking || null,
invoice: (claimDetails as any)?.invoice || null, invoice: (claimDetails as any)?.invoice || null,
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);
// Find current user's approval level // Find current user's approval level
// Only show approve/reject buttons if user is the CURRENT active approver // Only show approve/reject buttons if user is the CURRENT active approver
// Include PAUSED status - when paused, the paused level is still the current level // Include PAUSED status - when paused, the paused level is still the current level
@ -616,8 +653,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level // Include PAUSED status - paused level is still the current level
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
setCurrentApprovalLevel(userCurrentLevel || null); setCurrentApprovalLevel(userCurrentLevel || null);
@ -625,7 +662,7 @@ export function useRequestDetails(
// Check spectator status // Check spectator status
const viewerId = (user as any)?.userId; const viewerId = (user as any)?.userId;
if (viewerId) { if (viewerId) {
const isSpec = participants.some((p: any) => const isSpec = participants.some((p: any) =>
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId (p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
); );
setIsSpectator(isSpec); setIsSpectator(isSpec);
@ -637,7 +674,7 @@ export function useRequestDetails(
if (mounted) { if (mounted) {
// Check for 403 Forbidden (Access Denied) // Check for 403 Forbidden (Access Denied)
if (error?.response?.status === 403) { if (error?.response?.status === 403) {
const message = error?.response?.data?.message || const message = error?.response?.data?.message ||
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.'; 'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
setAccessDenied({ denied: true, message }); setAccessDenied({ denied: true, message });
} }
@ -649,26 +686,34 @@ export function useRequestDetails(
} }
} }
})(); })();
return () => { mounted = false; }; return () => { mounted = false; };
}, [requestIdentifier, user]); }, [requestIdentifier, user]);
/** /**
* Computed: Get final request object with fallback to static databases * Computed: Get final request object with fallback to static databases
* Priority: API data Custom 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 dynamicRequest = dynamicRequests.find((req: any) => const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
req.id === 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) =>
req.id === requestIdentifier ||
req.requestNumber === requestIdentifier || req.requestNumber === requestIdentifier ||
req.request_number === requestIdentifier req.request_number === requestIdentifier
); );
if (dynamicRequest) return dynamicRequest; if (dynamicRequest) return dynamicRequest;
return null; return null;
}, [requestIdentifier, dynamicRequests, apiRequest]); }, [requestIdentifier, dynamicRequests, apiRequest]);
@ -689,9 +734,9 @@ export function useRequestDetails(
*/ */
const existingParticipants = useMemo(() => { const existingParticipants = useMemo(() => {
if (!request) return []; if (!request) return [];
const participants: Array<{ email: string; participantType: string; name?: string }> = []; const participants: Array<{ email: string; participantType: string; name?: string }> = [];
// Add initiator // Add initiator
if (request.initiator?.email) { if (request.initiator?.email) {
participants.push({ participants.push({
@ -700,7 +745,7 @@ export function useRequestDetails(
name: request.initiator.name name: request.initiator.name
}); });
} }
// Add approvers from approval flow // Add approvers from approval flow
if (request.approvalFlow && Array.isArray(request.approvalFlow)) { if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
request.approvalFlow.forEach((approval: any) => { request.approvalFlow.forEach((approval: any) => {
@ -713,7 +758,7 @@ export function useRequestDetails(
} }
}); });
} }
// Add spectators // Add spectators
if (request.spectators && Array.isArray(request.spectators)) { if (request.spectators && Array.isArray(request.spectators)) {
request.spectators.forEach((spectator: any) => { request.spectators.forEach((spectator: any) => {
@ -726,20 +771,20 @@ export function useRequestDetails(
} }
}); });
} }
// Add from participants array // Add from participants array
if (request.participants && Array.isArray(request.participants)) { if (request.participants && Array.isArray(request.participants)) {
request.participants.forEach((p: any) => { request.participants.forEach((p: any) => {
const email = (p.userEmail || p.email || '').toLowerCase(); const email = (p.userEmail || p.email || '').toLowerCase();
const participantType = (p.participantType || p.participant_type || '').toUpperCase(); const participantType = (p.participantType || p.participant_type || '').toUpperCase();
const name = p.userName || p.user_name || p.name; const name = p.userName || p.user_name || p.name;
if (email && participantType && !participants.find(x => x.email === email)) { if (email && participantType && !participants.find(x => x.email === email)) {
participants.push({ email, participantType, name }); participants.push({ email, participantType, name });
} }
}); });
} }
return participants; return participants;
}, [request]); }, [request]);
@ -758,12 +803,12 @@ export function useRequestDetails(
*/ */
useEffect(() => { useEffect(() => {
if (!requestIdentifier || !apiRequest) return; if (!requestIdentifier || !apiRequest) return;
const socket = getSocket(); const socket = getSocket();
if (!socket) { if (!socket) {
return; return;
} }
/** /**
* Handler: Request updated by another user * Handler: Request updated by another user
* Silently refresh to show latest changes * Silently refresh to show latest changes
@ -775,10 +820,10 @@ export function useRequestDetails(
refreshDetails(); refreshDetails();
} }
}; };
// Register listener // Register listener
socket.on('request:updated', handleRequestUpdated); socket.on('request:updated', handleRequestUpdated);
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
socket.off('request:updated', handleRequestUpdated); socket.off('request:updated', handleRequestUpdated);

View File

@ -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>

View File

@ -1,165 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Search, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { getTemplates, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
import { toast } from 'sonner';
export function AdminTemplatesList() {
const navigate = useNavigate();
const [templates, setTemplates] = useState<WorkflowTemplate[]>(() => getCachedTemplates() || []);
// Only show full loading skeleton if we don't have any data yet
const [loading, setLoading] = useState(() => !getCachedTemplates());
const [searchQuery, setSearchQuery] = useState('');
const fetchTemplates = async () => {
try {
// If we didn't have cache, we are already loading.
// If we HAD cache, we don't want to set loading=true (flashing skeletons),
// we just want to update the data in background.
if (templates.length === 0) setLoading(true);
const data = await getTemplates();
setTemplates(data || []);
} catch (error) {
console.error('Failed to fetch templates:', error);
toast.error('Failed to load templates');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTemplates();
}, []);
const filteredTemplates = templates.filter(template =>
template.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
template.category.toLowerCase().includes(searchQuery.toLowerCase())
);
const getPriorityColor = (priority: string) => {
switch (priority.toLowerCase()) {
case 'high': return 'bg-red-100 text-red-700 border-red-200';
case 'medium': return 'bg-orange-100 text-orange-700 border-orange-200';
case 'low': return 'bg-green-100 text-green-700 border-green-200';
default: return 'bg-gray-100 text-gray-700 border-gray-200';
}
};
return (
<div className="max-w-7xl mx-auto space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-gray-900">Admin Templates</h1>
<p className="text-gray-500">Manage workflow templates for your organization</p>
</div>
<Button
onClick={() => navigate('/admin/create-template')}
className="bg-re-green hover:bg-re-green/90"
>
<Plus className="w-4 h-4 mr-2" />
Create New Template
</Button>
</div>
<div className="flex items-center gap-4 bg-white p-4 rounded-lg border border-gray-200 shadow-sm">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search templates..."
className="pl-10 border-gray-200"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{loading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map(i => (
<Card key={i} className="h-48">
<CardHeader>
<Skeleton className="h-6 w-3/4 mb-2" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent>
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-2/3" />
</CardContent>
</Card>
))}
</div>
) : filteredTemplates.length === 0 ? (
<div className="text-center py-16 bg-white rounded-lg border border-dashed border-gray-300">
<div className="w-16 h-16 bg-gray-50 rounded-full flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No templates found</h3>
<p className="text-gray-500 max-w-sm mx-auto mb-6">
{searchQuery ? 'Try adjusting your search terms' : 'Get started by creating your first workflow template'}
</p>
{!searchQuery && (
<Button onClick={() => navigate('/admin/create-template')} variant="outline">
Create Template
</Button>
)}
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredTemplates.map((template) => (
<Card key={template.id} className="hover:shadow-md transition-shadow duration-200 group">
<CardHeader className="pb-3">
<div className="flex justify-between items-start gap-2">
<div className="p-2 bg-blue-50 rounded-lg text-blue-600 mb-2 w-fit">
<FileText className="w-5 h-5" />
</div>
<Badge variant="outline" className={getPriorityColor(template.priority)}>
{template.priority}
</Badge>
</div>
<CardTitle className="line-clamp-1 text-lg">{template.name}</CardTitle>
<CardDescription className="line-clamp-3 min-h-[4.5rem]">
{template.description}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-gray-500 mb-4 space-y-1">
<div className="flex justify-between">
<span>Category:</span>
<span className="font-medium text-gray-900">{template.category}</span>
</div>
<div className="flex justify-between">
<span>SLA:</span>
<span className="font-medium text-gray-900">{template.suggestedSLA} hours</span>
</div>
<div className="flex justify-between">
<span>Approvers:</span>
<span className="font-medium text-gray-900">{template.approvers?.length || 0} levels</span>
</div>
</div>
<div className="flex gap-2 pt-2 border-t mt-2">
<Button
variant="outline"
className="flex-1 text-blue-600 hover:text-blue-700 hover:bg-blue-50 border-blue-100"
onClick={() => navigate(`/admin/edit-template/${template.id}`)}
>
<Pencil className="w-4 h-4 mr-2" />
Edit
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@ -1,503 +0,0 @@
import { useState, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { X, Save, ArrowLeft, Loader2, Clock } from 'lucide-react';
import { useUserSearch } from '@/hooks/useUserSearch';
import { createTemplate, updateTemplate, getTemplates, WorkflowTemplate } from '@/services/workflowTemplateApi';
import { toast } from 'sonner';
export function CreateTemplate() {
const navigate = useNavigate();
const { templateId } = useParams<{ templateId: string }>();
const isEditing = !!templateId;
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const { searchResults, searchLoading, searchUsersDebounced, clearSearch } = useUserSearch();
const [approverSearchInput, setApproverSearchInput] = useState('');
const [formData, setFormData] = useState({
name: '',
description: '',
category: 'General',
priority: 'medium' as 'low' | 'medium' | 'high',
estimatedTime: '2 days',
suggestedSLA: 24,
approvers: [] as any[]
});
useEffect(() => {
if (isEditing && templateId) {
const fetchTemplate = async () => {
try {
setFetching(true);
const templates = await getTemplates();
const template = templates.find((t: WorkflowTemplate) => t.id === templateId);
if (template) {
setFormData({
name: template.name,
description: template.description,
category: template.category,
priority: template.priority,
estimatedTime: template.estimatedTime,
suggestedSLA: template.suggestedSLA,
approvers: template.approvers || []
});
} else {
toast.error('Template not found');
navigate('/admin/templates');
}
} catch (error) {
console.error('Failed to load template:', error);
toast.error('Failed to load template details');
} finally {
setFetching(false);
}
};
fetchTemplate();
}
}, [isEditing, templateId, navigate]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSelectChange = (name: string, value: string) => {
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleApproverSearch = (val: string) => {
setApproverSearchInput(val);
// Only trigger search if specifically starting with '@'
// This prevents triggering on email addresses like "user@example.com"
if (val.startsWith('@')) {
const query = val.slice(1);
// Search if we have at least 1 character after @
// This allows searching for "L" in "@L"
if (query.length >= 1) {
// Pass the full query starting with @, as useUserSearch expects it
searchUsersDebounced(val, 5);
return;
}
}
// If no @ at start or query too short, clear results
clearSearch();
};
// ... (rest of the component)
// In the return JSX:
<div className="flex gap-2">
<Input
placeholder="Type '@' to search user by name or email..."
value={approverSearchInput}
onChange={(e) => handleApproverSearch(e.target.value)}
className="border-gray-200"
/>
</div>
const addApprover = (user: any) => {
if (formData.approvers.some(a => a.userId === user.userId)) {
toast.error('Approver already added');
return;
}
setFormData(prev => ({
...prev,
approvers: [...prev.approvers, {
userId: user.userId,
name: user.displayName || user.email,
email: user.email,
level: prev.approvers.length + 1,
tat: 24, // Default TAT in hours
tatType: 'hours' // Default unit
}]
}));
setApproverSearchInput('');
clearSearch();
};
const removeApprover = (index: number) => {
const newApprovers = [...formData.approvers];
newApprovers.splice(index, 1);
// Re-index levels
newApprovers.forEach((a, i) => a.level = i + 1);
setFormData(prev => ({ ...prev, approvers: newApprovers }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name || !formData.description) {
toast.error('Please fill in required fields');
return;
}
if (formData.approvers.length === 0) {
toast.error('Please add at least one approver');
return;
}
// Prepare payload with TAT conversion
const payload = {
...formData,
approvers: formData.approvers.map(a => ({
...a,
tat: a.tatType === 'days' ? (parseInt(a.tat) * 24) : parseInt(a.tat)
}))
};
try {
setLoading(true);
if (isEditing && templateId) {
await updateTemplate(templateId, payload);
toast.success('Template updated successfully');
} else {
await createTemplate(payload);
toast.success('Template created successfully');
}
navigate('/admin/templates'); // Back to list
} catch (error) {
toast.error(isEditing ? 'Failed to update template' : 'Failed to create template');
console.error(error);
} finally {
setLoading(false);
}
};
if (fetching) {
return (
<div className="flex h-96 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
</div>
);
}
const isFormValid = formData.name &&
formData.description &&
formData.approvers.length > 0 &&
formData.approvers.every((a: any) => {
const val = parseInt(String(a.tat)) || 0;
const max = a.tatType === 'days' ? 7 : 24;
return val >= 1 && val <= max;
});
return (
<div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/templates')}>
<ArrowLeft className="w-5 h-5" />
</Button>
<div>
<h1 className="text-3xl font-bold text-gray-900">
{isEditing ? 'Edit Workflow Template' : 'Create Workflow Template'}
</h1>
<p className="text-gray-500">
{isEditing ? 'Update existing workflow configuration' : 'Define a new standardized request workflow'}
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
<CardDescription>General details about the template</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
name="name"
placeholder="e.g., Office Stationery Request"
value={formData.name}
onChange={handleInputChange}
className="border-gray-200"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="category">Category</Label>
<div className="relative">
{/* Simple text input for now, could be select */}
<Input
id="category"
name="category"
placeholder="e.g., Admin, HR, Finance"
value={formData.category}
onChange={handleInputChange}
className="border-gray-200"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
name="description"
placeholder="Describe what this request is for..."
value={formData.description}
onChange={handleInputChange}
className="border-gray-200"
required
/>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="priority">Default Priority</Label>
<Select
name="priority"
value={formData.priority}
onValueChange={(val) => handleSelectChange('priority', val)}
>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="low">Low</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="high">High</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="estimatedTime">Estimated Time</Label>
<Input
id="estimatedTime"
name="estimatedTime"
placeholder="e.g., 2 days"
value={formData.estimatedTime}
onChange={handleInputChange}
className="border-gray-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="suggestedSLA">SLA (Hours)</Label>
<Input
id="suggestedSLA"
name="suggestedSLA"
type="number"
placeholder="24"
value={formData.suggestedSLA}
onChange={handleInputChange}
className="border-gray-200"
/>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Approver Workflow</CardTitle>
<CardDescription>Define static approvers for this template</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
{formData.approvers.map((approver, index) => (
<div key={index} className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg border border-gray-100">
<Badge variant="outline" className="bg-white">Level {approver.level}</Badge>
<div className="flex-1">
<div className="font-medium text-gray-900">{approver.name}</div>
<div className="text-sm text-gray-500">{approver.email}</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-1">
<Label htmlFor={`tat-${index}`} className="text-xs whitespace-nowrap">TAT</Label>
<div className="flex items-center gap-1">
<Input
id={`tat-${index}`}
type="number"
className="h-8 w-16 border-gray-200"
value={approver.tat || ''}
min={1}
max={approver.tatType === 'days' ? 7 : 24}
placeholder={approver.tatType === 'days' ? '1' : '24'}
onChange={(e) => {
const val = parseInt(e.target.value) || 0;
// const max = approver.tatType === 'days' ? 7 : 24;
// Optional: strict clamping or just allow typing and validate later
// For better UX, let's allow typing but validate in isFormValid
// But prevent entering negative numbers
if (val < 0) return;
const newApprovers = [...formData.approvers];
newApprovers[index].tat = e.target.value; // Store as string to allow clearing
setFormData(prev => ({ ...prev, approvers: newApprovers }));
}}
/>
<Select
value={approver.tatType || 'hours'}
onValueChange={(val) => {
const newApprovers = [...formData.approvers];
newApprovers[index].tatType = val;
newApprovers[index].tat = 1; // Reset to 1 on change
setFormData(prev => ({ ...prev, approvers: newApprovers }));
}}
>
<SelectTrigger className="h-8 w-20 text-xs px-2">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<Button type="button" variant="ghost" size="sm" onClick={() => removeApprover(index)}>
<X className="w-4 h-4 text-gray-500 hover:text-red-600" />
</Button>
</div>
))}
{formData.approvers.length === 0 && (
<div className="text-center p-8 border-2 border-dashed rounded-lg text-gray-500 text-sm">
No approvers defined. Requests will be auto-approved or require manual assignment.
</div>
)}
</div>
<div className="space-y-2 relative">
<Label>Add Approver</Label>
<div className="flex gap-2">
<Input
placeholder="Type '@' to search user by name or email..."
value={approverSearchInput}
onChange={(e) => handleApproverSearch(e.target.value)}
className="border-gray-200"
/>
</div>
{(searchLoading || searchResults.length > 0) && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-lg z-10 max-h-60 overflow-y-auto">
{searchLoading && <div className="p-2 text-sm text-gray-500">Searching...</div>}
{searchResults.map(user => (
<div
key={user.userId}
className="p-2 hover:bg-gray-50 cursor-pointer flex items-center gap-3"
onClick={() => addApprover(user)}
>
<Avatar className="h-8 w-8">
<AvatarFallback>{(user.displayName || 'U').substring(0, 2)}</AvatarFallback>
</Avatar>
<div>
<div className="text-sm font-medium">{user.displayName}</div>
<div className="text-xs text-gray-500">{user.email}</div>
</div>
</div>
))}
</div>
)}
</div>
{/* TAT Summary */}
{formData.approvers.length > 0 && (
<div className="mt-6 p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border border-emerald-200">
<div className="flex items-start gap-3">
<Clock className="w-5 h-5 text-emerald-600 mt-0.5" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-emerald-900">TAT Summary</h4>
<div className="text-right">
{(() => {
const totalCalendarDays = formData.approvers.reduce((sum: number, a: any) => {
const tat = Number(a.tat || 0);
const tatType = a.tatType || 'hours';
if (tatType === 'days') {
return sum + tat;
} else {
return sum + (tat / 24);
}
}, 0) || 0;
const displayDays = Math.ceil(totalCalendarDays);
return (
<>
<div className="text-lg font-bold text-emerald-800">{displayDays} {displayDays === 1 ? 'Day' : 'Days'}</div>
<div className="text-xs text-emerald-600">Total Duration</div>
</>
);
})()}
</div>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{formData.approvers.map((approver: any, idx: number) => {
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat;
if (!tat) return null;
return (
<div key={idx} className="bg-white/60 p-2 rounded border border-emerald-100">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-emerald-900">Level {idx + 1}</span>
<span className="text-sm text-emerald-700">{hours} {hours === 1 ? 'hour' : 'hours'}</span>
</div>
</div>
);
})}
</div>
{(() => {
const totalHours = formData.approvers.reduce((sum: number, a: any) => {
const tat = Number(a.tat || 0);
const tatType = a.tatType || 'hours';
if (tatType === 'days') {
return sum + (tat * 24);
} else {
return sum + tat;
}
}, 0) || 0;
const workingDays = Math.ceil(totalHours / 8);
if (totalHours === 0) return null;
return (
<div className="bg-white/80 p-3 rounded border border-emerald-200">
<div className="grid grid-cols-2 gap-4 text-center">
<div>
<div className="text-lg font-bold text-emerald-800">{totalHours}h</div>
<div className="text-xs text-emerald-600">Total Hours</div>
</div>
<div>
<div className="text-lg font-bold text-emerald-800">{workingDays}</div>
<div className="text-xs text-emerald-600">Working Days*</div>
</div>
</div>
<p className="text-xs text-emerald-600 mt-2 text-center">*Based on 8-hour working days</p>
</div>
);
})()}
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => navigate('/admin/templates')}>Cancel</Button>
<Button type="submit" disabled={loading || !isFormValid} className="bg-re-green hover:bg-re-green/90">
{loading ? 'Saving...' : (
<>
<Save className="w-4 h-4 mr-2" />
{isEditing ? 'Update Template' : 'Create Template'}
</>
)}
</Button>
</div>
</form>
</div>
);
}

View File

@ -11,7 +11,6 @@ import { Pagination } from '@/components/common/Pagination';
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
import { formatDate, formatDateTime } from '../utils/formatters'; import { formatDate, formatDateTime } from '../utils/formatters';
import { formatHoursMinutes } from '@/utils/slaTracker'; import { formatHoursMinutes } from '@/utils/slaTracker';
import { navigateToRequest } from '@/utils/requestNavigation';
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types'; import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
interface ApproverPerformanceRequestListProps { interface ApproverPerformanceRequestListProps {
@ -70,6 +69,7 @@ export function ApproverPerformanceRequestList({
key={request.requestId} key={request.requestId}
className="hover:shadow-md transition-shadow cursor-pointer" className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => { onClick={() => {
const { navigateToRequest } = require('@/utils/requestNavigation');
navigateToRequest({ navigateToRequest({
requestId: request.requestId, requestId: request.requestId,
requestTitle: request.title, requestTitle: request.title,
@ -166,6 +166,7 @@ export function ApproverPerformanceRequestList({
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const { navigateToRequest } = require('@/utils/requestNavigation');
navigateToRequest({ navigateToRequest({
requestId: request.requestId, requestId: request.requestId,
requestTitle: request.title, requestTitle: request.title,

View File

@ -1,39 +1,22 @@
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 } from '@/assets';
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 [imageLoaded, setImageLoaded] = useState(false);
// Preload the background image const handleSSOLogin = async () => {
useEffect(() => {
const img = new Image();
img.src = LandingPageImage;
img.onload = () => {
setImageLoaded(true);
};
// If image is already cached, trigger load immediately
if (img.complete) {
setImageLoaded(true);
}
}, []);
const handleOKTALogin = async () => {
// Clear any existing session data // Clear any existing session data
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
try { try {
await login(); await login();
} catch (loginError) { } catch (loginError) {
console.error('========================================'); console.error('========================================');
console.error('OKTA LOGIN ERROR'); console.error('LOGIN ERROR');
console.error('Error details:', loginError); console.error('Error details:', loginError);
console.error('Error message:', (loginError as Error)?.message); console.error('Error message:', (loginError as Error)?.message);
console.error('Error stack:', (loginError as Error)?.stack); console.error('Error stack:', (loginError as Error)?.stack);
@ -41,123 +24,59 @@ export function Auth() {
} }
}; };
const handleTanflowLogin = () => {
// Clear any existing session data
localStorage.clear();
sessionStorage.clear();
setTanflowLoading(true);
try {
initiateTanflowLogin();
} catch (loginError) {
console.error('========================================');
console.error('TANFLOW LOGIN ERROR');
console.error('Error details:', loginError);
setTanflowLoading(false);
}
};
if (error) { if (error) {
console.error('Auth Error in Auth Component:', { console.error('Auth0 Error in Auth Component:', {
message: error.message, message: error.message,
error: error error: error
}); });
} }
return ( return (
<div <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
className="min-h-screen flex items-center justify-center p-4 relative" <Card className="w-full max-w-md shadow-xl">
style={{
backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
transition: 'background-image 0.3s ease-in-out'
}}
>
{/* Fallback background while image loads */}
{!imageLoaded && (
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 to-slate-800"></div>
)}
{/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/40"></div>
<Card className="w-full max-w-md shadow-xl relative z-10 bg-black backdrop-blur-sm border-gray-800">
<CardHeader className="space-y-1 text-center pb-6"> <CardHeader className="space-y-1 text-center pb-6">
<div className="flex flex-col items-center justify-center mb-4"> <div className="flex flex-col items-center justify-center mb-4">
<img <img
src={ReLogo} src={ReLogo}
alt="Royal Enfield Logo" alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain mb-2" className="h-10 w-auto max-w-[168px] object-contain mb-2"
/> />
<p className="text-xs text-gray-300 text-center truncate">Approval Portal</p> <p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{error && ( {error && (
<div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg"> <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
<p className="text-sm font-medium">Authentication Error</p> <p className="text-sm font-medium">Authentication Error</p>
<p className="text-sm">{error.message}</p> <p className="text-sm">{error.message}</p>
</div> </div>
)} )}
<Button
onClick={handleSSOLogin}
disabled={isLoading}
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
size="lg"
>
{isLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Logging in...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
SSO Login
</>
)}
</Button>
<div className="space-y-3"> <div className="text-center text-sm text-gray-500 mt-4">
<Button
onClick={handleOKTALogin}
disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
size="lg"
>
{isLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Logging in...
</>
) : (
<>
<LogIn className="mr-2 h-5 w-5" />
RE Employee Login
</>
)}
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-700"></span>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-gray-900 px-2 text-gray-400">Or</span>
</div>
</div>
<Button
onClick={handleTanflowLogin}
disabled={isLoading || tanflowLoading}
className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
size="lg"
>
{tanflowLoading ? (
<>
<div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/>
Redirecting...
</>
) : (
<>
<Shield className="mr-2 h-5 w-5" />
Dealer Login
</>
)}
</Button>
</div>
<div className="text-center text-sm text-gray-400 mt-4">
<p>Secure Single Sign-On</p> <p>Secure Single Sign-On</p>
<p className="text-xs mt-1 text-gray-500">Choose your authentication provider</p> <p className="text-xs mt-1">Powered by Auth0</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Auth } from './Auth'; import { Auth } from './Auth';
import { AuthCallback } from './AuthCallback'; import { AuthCallback } from './AuthCallback';
import { TanflowCallback } from './TanflowCallback';
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo'; import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
import App from '../../App'; import App from '../../App';
@ -11,8 +10,7 @@ export function AuthenticatedApp() {
const [showDebugInfo, setShowDebugInfo] = useState(false); const [showDebugInfo, setShowDebugInfo] = useState(false);
// Check if we're on callback route (after all hooks are called) // Check if we're on callback route (after all hooks are called)
const isCallbackRoute = typeof window !== 'undefined' && const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback';
window.location.pathname === '/login/callback';
const handleLogout = async () => { const handleLogout = async () => {
try { try {
@ -41,35 +39,7 @@ export function AuthenticatedApp() {
}, [isAuthenticated, isLoading, error, user]); }, [isAuthenticated, isLoading, error, user]);
// Always show callback loader when on callback route (after all hooks) // Always show callback loader when on callback route (after all hooks)
// Detect provider from sessionStorage to show appropriate callback component
if (isCallbackRoute) { if (isCallbackRoute) {
// Check if this is a logout redirect (no code, no error)
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
const hasCode = urlParams?.get('code');
const hasError = urlParams?.get('error');
// If no code and no error, it's a logout redirect - redirect immediately
if (!hasCode && !hasError) {
console.log('🚪 AuthenticatedApp: Logout redirect detected, redirecting to home');
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
window.location.replace(`/?${logoutParams.toString()}`);
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-900 border-t-transparent mx-auto mb-4"></div>
<p className="text-gray-600">Redirecting...</p>
</div>
</div>
);
}
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
if (authProvider === 'tanflow') {
return <TanflowCallback />;
}
// Default to OKTA callback (or if provider not set yet)
return <AuthCallback />; return <AuthCallback />;
} }

View File

@ -1,301 +0,0 @@
/**
* Tanflow OAuth Callback Handler
* Handles the redirect from Tanflow SSO after authentication
*/
import { useEffect, useState, useRef } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { exchangeTanflowCodeForTokens } from '@/services/tanflowAuth';
import { getCurrentUser } from '@/services/authApi';
import { TokenManager } from '@/utils/tokenManager';
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { ReLogo } from '@/assets';
export function TanflowCallback() {
const { isAuthenticated, isLoading, error, user } = useAuth();
const [authStep, setAuthStep] = useState<'exchanging' | 'fetching' | 'complete' | 'error'>('exchanging');
const [errorMessage, setErrorMessage] = useState<string>('');
const callbackProcessedRef = useRef(false);
useEffect(() => {
// Determine current authentication step based on state
if (error) {
setAuthStep('error');
return;
}
if (isLoading) {
const urlParams = new URLSearchParams(window.location.search);
const hasCode = urlParams.get('code');
if (hasCode && !user) {
setAuthStep('exchanging');
} else if (user && !isAuthenticated) {
setAuthStep('fetching');
} else {
setAuthStep('exchanging');
}
} else if (user && isAuthenticated) {
setAuthStep('complete');
// If already authenticated, redirect immediately
// This handles the case where auth state was set before this component rendered
setTimeout(() => {
window.location.href = '/';
}, 1000);
}
}, [isAuthenticated, isLoading, error, user]);
// Handle Tanflow callback
useEffect(() => {
// Only process if we're on the callback route
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
return;
}
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const errorParam = urlParams.get('error');
// SIMPLIFIED: If no code and no error, it's a logout redirect - redirect immediately
// Tanflow logout redirects back to /login/callback without any parameters
if (!code && !errorParam) {
console.log('🚪 Logout redirect detected: no code, no error - redirecting to home immediately');
callbackProcessedRef.current = true;
// Redirect to home with logout flags
const logoutParams = new URLSearchParams();
logoutParams.set('tanflow_logged_out', 'true');
logoutParams.set('logout', Date.now().toString());
const redirectUrl = `/?${logoutParams.toString()}`;
console.log('🚪 Redirecting to:', redirectUrl);
window.location.replace(redirectUrl);
return;
}
// Check if this is a Tanflow callback
const authProvider = sessionStorage.getItem('auth_provider');
if (authProvider !== 'tanflow') {
// Not a Tanflow callback, let AuthContext handle it
return;
}
const handleCallback = async () => {
callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const errorParam = urlParams.get('error');
// Clean URL immediately
window.history.replaceState({}, document.title, '/login/callback');
// Check for errors from Tanflow
if (errorParam) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
// Validate state
const storedState = sessionStorage.getItem('tanflow_auth_state');
if (state && state !== storedState) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
if (!code) {
setAuthStep('error');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
return;
}
try {
setAuthStep('exchanging');
// Exchange code for tokens (this stores tokens in TokenManager)
const tokenData = await exchangeTanflowCodeForTokens(code, state || '');
// Clear state but keep provider flag for logout detection
sessionStorage.removeItem('tanflow_auth_state');
// Keep auth_provider in sessionStorage so logout can detect which provider to use
// This will be cleared during logout
setAuthStep('fetching');
// Fetch user profile (tokenData already has user, but fetch to ensure it's current)
const userData = tokenData.user || await getCurrentUser();
if (userData) {
// Store user data in TokenManager (already stored by exchangeTanflowCodeForTokens, but ensure it's set)
TokenManager.setUserData(userData);
// Show success message briefly
setAuthStep('complete');
// Clean URL and do full page reload to ensure AuthContext checks auth status
// This is necessary because AuthContext skips auth check on /login/callback route
// After reload, AuthContext will check tokens and set isAuthenticated/user properly
setTimeout(() => {
window.history.replaceState({}, document.title, '/');
// Use window.location.href for full page reload to trigger AuthContext initialization
window.location.href = '/';
}, 1000);
} else {
throw new Error('User data not received');
}
} catch (err: any) {
console.error('Tanflow callback error:', err);
setAuthStep('error');
setErrorMessage(err.message || 'Authentication failed');
sessionStorage.removeItem('auth_provider');
sessionStorage.removeItem('tanflow_auth_state');
}
};
handleCallback();
}, []);
const getLoadingMessage = () => {
switch (authStep) {
case 'exchanging':
return 'Exchanging authorization code...';
case 'fetching':
return 'Fetching your profile...';
case 'complete':
return 'Authentication successful!';
case 'error':
return 'Authentication failed';
default:
return 'Completing authentication...';
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGZpbGw9IiMxZTIxMmQiIGZpbGwtb3BhY2l0eT0iMC4wNSI+PGNpcmNsZSBjeD0iMzAiIGN5PSIzMCIgcj0iMzAiLz48L2c+PC9nPjwvc3ZnPg==')] opacity-20"></div>
<div className="relative z-10 text-center px-4 max-w-md w-full">
{/* Logo/Brand Section */}
<div className="mb-8">
<div className="flex flex-col items-center justify-center">
<img
src={ReLogo}
alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain mb-2"
/>
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
</div>
</div>
{/* Main Loader Card */}
<div className="bg-white/10 backdrop-blur-xl rounded-2xl p-8 shadow-2xl border border-white/20">
{/* Status Icon */}
<div className="mb-6 flex justify-center">
{authStep === 'error' ? (
<div className="relative">
<div className="absolute inset-0 animate-ping opacity-75">
<AlertCircle className="w-16 h-16 text-red-500" />
</div>
<AlertCircle className="w-16 h-16 text-red-500 relative" />
</div>
) : authStep === 'complete' ? (
<div className="relative">
<div className="absolute inset-0 animate-ping opacity-75">
<CheckCircle2 className="w-16 h-16 text-green-500" />
</div>
<CheckCircle2 className="w-16 h-16 text-green-500 relative" />
</div>
) : (
<div className="relative">
<Loader2 className="w-16 h-16 animate-spin text-re-red" />
<div className="absolute inset-0 border-4 rounded-full border-re-red/20"></div>
<div className="absolute inset-0 border-4 border-transparent border-t-re-red rounded-full animate-spin"></div>
</div>
)}
</div>
{/* Loading Message */}
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">
{authStep === 'complete' ? 'Welcome Back!' : authStep === 'error' ? 'Authentication Error' : 'Authenticating'}
</h2>
<p className="text-slate-300 text-sm">{getLoadingMessage()}</p>
</div>
{/* Progress Steps */}
{authStep !== 'error' && (
<div className="space-y-3 mb-6">
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
<span>Validating credentials</span>
</div>
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
<span>Loading your profile</span>
</div>
{authStep === 'complete' && (
<div className="flex items-center gap-3 text-sm transition-all duration-500 text-white">
<div className="w-2 h-2 rounded-full transition-all duration-500 bg-green-500"></div>
<span>Setting up your session</span>
</div>
)}
</div>
)}
{/* Error Message */}
{authStep === 'error' && errorMessage && (
<div className="mt-6 p-4 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-red-400 text-sm">{errorMessage}</p>
<button
onClick={() => {
window.location.href = '/';
}}
className="mt-4 text-sm text-red-400 hover:text-red-300 underline"
>
Return to login
</button>
</div>
)}
{/* Animated Progress Bar */}
{authStep !== 'error' && authStep !== 'complete' && (
<div className="mt-6">
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
<div
className="h-full bg-re-red rounded-full animate-pulse"
style={{
animation: 'progress 2s ease-in-out infinite',
}}
></div>
</div>
<style>{`
@keyframes progress {
0%, 100% { width: 20%; }
50% { width: 80%; }
}
`}</style>
</div>
)}
</div>
{/* Footer Text */}
<p className="mt-6 text-slate-500 text-xs">
{authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}
</p>
</div>
{/* Animated Background Elements */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse"></div>
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
</div>
);
}

View File

@ -1,7 +1,8 @@
import { useCallback, useRef, useEffect, useMemo } from 'react'; import { useCallback, useRef, useEffect } from 'react';
// Components // Components
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader'; import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
import { ClosedRequestsList } from './components/ClosedRequestsList'; import { ClosedRequestsList } from './components/ClosedRequestsList';
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty'; import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination'; import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
@ -13,11 +14,6 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
// Types // Types
import type { ClosedRequestsProps } from './types/closedRequests.types'; import type { ClosedRequestsProps } from './types/closedRequests.types';
// Utils & Factory
import { getUserFilterType } from '@/utils/userFilterUtils';
import { getClosedRequestsFilters } from '@/flows';
import { TokenManager } from '@/utils/tokenManager';
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
// Data fetching hook // Data fetching hook
const closedRequests = useClosedRequests({ itemsPerPage: 10 }); const closedRequests = useClosedRequests({ itemsPerPage: 10 });
@ -27,29 +23,10 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current = closedRequests.fetchRequests; fetchRef.current = closedRequests.fetchRequests;
const filters = useClosedRequestsFilters(); const filters = useClosedRequestsFilters();
// Get user filter type and corresponding filter component (plug-and-play pattern)
const userFilterType = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return getUserFilterType(userData);
} catch (error) {
console.error('[ClosedRequests] Error getting user filter type:', error);
return 'STANDARD' as const;
}
}, []);
// Get the appropriate filter component based on user type
const ClosedRequestsFiltersComponent = useMemo(() => {
return getClosedRequestsFilters(userFilterType);
}, [userFilterType]);
const isDealer = userFilterType === 'DEALER';
const prevFiltersRef = useRef({ const prevFiltersRef = useRef({
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -61,15 +38,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current(storedPage, { fetchRef.current(storedPage, {
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined, status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
// Only include priority and templateType filters if user is not a dealer priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, sortBy: filters.sortBy,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, sortOrder: filters.sortOrder,
sortBy: filters.sortBy, });
sortOrder: filters.sortOrder,
});
hasInitialFetchRun.current = true; hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDealer]); // Re-fetch if dealer status changes }, []); // Only on mount
// Track filter changes and refetch // Track filter changes and refetch
useEffect(() => { useEffect(() => {
@ -80,7 +55,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
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.sortBy !== filters.sortBy || prev.sortBy !== filters.sortBy ||
prev.sortOrder !== filters.sortOrder; prev.sortOrder !== filters.sortOrder;
@ -93,17 +67,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
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,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
// Update previous values // Update previous values
prevFiltersRef.current = { prevFiltersRef.current = {
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter, priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}; };
@ -111,7 +83,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
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.sortBy, filters.sortOrder, isDealer]); }, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.sortBy, filters.sortOrder]);
// Page change handler // Page change handler
const handlePageChange = useCallback( const handlePageChange = useCallback(
@ -122,7 +94,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
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,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -137,7 +108,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
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,
sortBy: filters.sortBy, sortBy: filters.sortBy,
sortOrder: filters.sortOrder, sortOrder: filters.sortOrder,
}); });
@ -153,25 +123,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
onRefresh={handleRefresh} onRefresh={handleRefresh}
/> />
{/* Filters - Plug-and-play pattern */} {/* Filters */}
<ClosedRequestsFiltersComponent <ClosedRequestsFiltersComponent
searchTerm={filters.searchTerm} searchTerm={filters.searchTerm}
priorityFilter={filters.priorityFilter} priorityFilter={filters.priorityFilter}
statusFilter={filters.statusFilter} statusFilter={filters.statusFilter}
templateTypeFilter={filters.templateTypeFilter}
sortBy={filters.sortBy} sortBy={filters.sortBy}
sortOrder={filters.sortOrder} sortOrder={filters.sortOrder}
activeFiltersCount={ activeFiltersCount={filters.activeFiltersCount}
isDealer
? // For dealers: only count search and status (closure type)
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
: // For standard users: count all filters
filters.activeFiltersCount
}
onSearchChange={filters.setSearchTerm} onSearchChange={filters.setSearchTerm}
onPriorityChange={filters.setPriorityFilter} onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter} onStatusChange={filters.setStatusFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter}
onSortByChange={filters.setSortBy} onSortByChange={filters.setSortBy}
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')} onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
onClearFilters={filters.clearFilters} onClearFilters={filters.clearFilters}

View File

@ -67,11 +67,11 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; let templateLabel = 'Custom';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200'; let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Claim Management';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';

View File

@ -12,14 +12,12 @@ interface ClosedRequestsFiltersProps {
searchTerm: string; searchTerm: string;
priorityFilter: string; priorityFilter: string;
statusFilter: string; statusFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
activeFiltersCount: number; activeFiltersCount: number;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onPriorityChange: (value: string) => void; onPriorityChange: (value: string) => void;
onStatusChange: (value: string) => void; onStatusChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onSortByChange: (value: 'created' | 'due' | 'priority') => void; onSortByChange: (value: 'created' | 'due' | 'priority') => void;
onSortOrderChange: () => void; onSortOrderChange: () => void;
onClearFilters: () => void; onClearFilters: () => void;
@ -29,14 +27,12 @@ export function ClosedRequestsFilters({
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
activeFiltersCount, activeFiltersCount,
onSearchChange, onSearchChange,
onPriorityChange, onPriorityChange,
onStatusChange, onStatusChange,
onTemplateTypeChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -86,7 +82,7 @@ export function ClosedRequestsFilters({
data-testid="closed-requests-search" data-testid="closed-requests-search"
/> />
</div> </div>
<Select value={priorityFilter} onValueChange={onPriorityChange}> <Select value={priorityFilter} onValueChange={onPriorityChange}>
<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-priority-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-priority-filter">
<SelectValue placeholder="All Priorities" /> <SelectValue placeholder="All Priorities" />
@ -107,7 +103,7 @@ export function ClosedRequestsFilters({
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={statusFilter} onValueChange={onStatusChange}> <Select value={statusFilter} onValueChange={onStatusChange}>
<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-status-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-status-filter">
<SelectValue placeholder="Closure Type" /> <SelectValue placeholder="Closure Type" />
@ -129,17 +125,6 @@ export function ClosedRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<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">
<SelectValue placeholder="All Templates" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Templates</SelectItem>
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent>
</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')}>
<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-sort-by"> <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-sort-by">
@ -151,7 +136,7 @@ export function ClosedRequestsFilters({
<SelectItem value="priority">Priority</SelectItem> <SelectItem value="priority">Priority</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@ -30,7 +30,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
}); });
const fetchRequests = useCallback( const fetchRequests = useCallback(
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => { async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
@ -51,7 +51,6 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
search: filters?.search, search: filters?.search,
status: filters?.status && filters.status !== 'all' ? filters.status : undefined, status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
priority: filters?.priority, priority: filters?.priority,
templateType: filters?.templateType,
sortBy: filters?.sortBy, sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder sortOrder: filters?.sortOrder
}); });
@ -91,7 +90,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
// Initial fetch removed - component handles initial fetch using Redux stored page // Initial fetch removed - component handles initial fetch using Redux stored page
// This prevents duplicate fetches and allows page persistence // This prevents duplicate fetches and allows page persistence
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => { const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
setRefreshing(true); setRefreshing(true);
fetchRequests(pagination.currentPage, filters); fetchRequests(pagination.currentPage, filters);
}, [fetchRequests, pagination.currentPage]); }, [fetchRequests, pagination.currentPage]);

View File

@ -9,7 +9,6 @@ import {
setSearchTerm as setSearchTermAction, setSearchTerm as setSearchTermAction,
setStatusFilter as setStatusFilterAction, setStatusFilter as setStatusFilterAction,
setPriorityFilter as setPriorityFilterAction, setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction,
setSortBy as setSortByAction, setSortBy as setSortByAction,
setSortOrder as setSortOrderAction, setSortOrder as setSortOrderAction,
setCurrentPage as setCurrentPageAction, setCurrentPage as setCurrentPageAction,
@ -27,13 +26,12 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
// Get filters from Redux // Get filters from Redux
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests); const { searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
// 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]);
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]); const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
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 setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]); const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]); const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]); const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
@ -43,11 +41,10 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
search: searchTerm, search: searchTerm,
status: statusFilter, status: statusFilter,
priority: priorityFilter, priority: priorityFilter,
templateType: templateTypeFilter !== 'all' ? templateTypeFilter : undefined,
sortBy, sortBy,
sortOrder, sortOrder,
}; };
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder]); }, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder]);
// Debounced filter change handler // Debounced filter change handler
useEffect(() => { useEffect(() => {
@ -74,7 +71,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
clearTimeout(debounceTimeoutRef.current); clearTimeout(debounceTimeoutRef.current);
} }
}; };
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]); }, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
const clearFilters = useCallback(() => { const clearFilters = useCallback(() => {
dispatch(clearFiltersAction()); dispatch(clearFiltersAction());
@ -83,22 +80,19 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
const activeFiltersCount = [ const activeFiltersCount = [
searchTerm, searchTerm,
priorityFilter !== 'all' ? priorityFilter : null, priorityFilter !== 'all' ? priorityFilter : null,
statusFilter !== 'all' ? statusFilter : null, statusFilter !== 'all' ? statusFilter : null
templateTypeFilter !== 'all' ? templateTypeFilter : null
].filter(Boolean).length; ].filter(Boolean).length;
return { return {
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
currentPage, currentPage,
setSearchTerm, setSearchTerm,
setPriorityFilter, setPriorityFilter,
setStatusFilter, setStatusFilter,
setTemplateTypeFilter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -4,7 +4,6 @@ export interface ClosedRequestsFiltersState {
searchTerm: string; searchTerm: string;
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
currentPage: number; currentPage: number;
@ -14,7 +13,6 @@ const initialState: ClosedRequestsFiltersState = {
searchTerm: '', searchTerm: '',
statusFilter: 'all', statusFilter: 'all',
priorityFilter: 'all', priorityFilter: 'all',
templateTypeFilter: 'all',
sortBy: 'created', sortBy: 'created',
sortOrder: 'desc', sortOrder: 'desc',
currentPage: 1, currentPage: 1,
@ -33,9 +31,6 @@ const closedRequestsSlice = createSlice({
setPriorityFilter: (state, action: PayloadAction<string>) => { setPriorityFilter: (state, action: PayloadAction<string>) => {
state.priorityFilter = action.payload; state.priorityFilter = action.payload;
}, },
setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
state.templateTypeFilter = action.payload;
},
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => { setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
state.sortBy = action.payload; state.sortBy = action.payload;
}, },
@ -49,7 +44,6 @@ const closedRequestsSlice = createSlice({
state.searchTerm = ''; state.searchTerm = '';
state.statusFilter = 'all'; state.statusFilter = 'all';
state.priorityFilter = 'all'; state.priorityFilter = 'all';
state.templateTypeFilter = 'all';
state.currentPage = 1; state.currentPage = 1;
}, },
}, },
@ -59,7 +53,6 @@ export const {
setSearchTerm, setSearchTerm,
setStatusFilter, setStatusFilter,
setPriorityFilter, setPriorityFilter,
setTemplateTypeFilter,
setSortBy, setSortBy,
setSortOrder, setSortOrder,
setCurrentPage, setCurrentPage,

View File

@ -28,7 +28,6 @@ export interface ClosedRequestsFilters {
search: string; search: string;
status: string; status: string;
priority: string; priority: string;
templateType?: string;
sortBy: 'created' | 'due' | 'priority'; sortBy: 'created' | 'due' | 'priority';
sortOrder: 'asc' | 'desc'; sortOrder: 'asc' | 'desc';
} }

View File

@ -1,232 +0,0 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ChevronRight, Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/AuthContext';
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
import { createWorkflowMultipart, submitWorkflow, CreateWorkflowFromFormPayload } from '@/services/workflowApi';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { FileText } from 'lucide-react';
import { AdminRequestDetailsStep } from './components/AdminRequestDetailsStep';
import { AdminRequestReviewStep } from './components/AdminRequestReviewStep';
import { toast } from 'sonner';
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
export function CreateAdminRequest() {
const { templateId } = useParams<{ templateId: string }>();
const navigate = useNavigate();
const { user: _ } = useAuth(); // Keeping hook call but ignoring return if needed for auth check side effect, or just remove destructuring
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [template, setTemplate] = useState<RequestTemplate | null>(null);
const [step, setStep] = useState(1);
const [documents, setDocuments] = useState<File[]>([]);
// Simplified form data
const [formData, setFormData] = useState({
title: '',
description: '',
});
const stepNames = ['Request Details', 'Review & Submit'];
useEffect(() => {
const loadTemplate = async () => {
try {
setLoading(true);
// Ideally we would have a getTemplateById API, but for now we filter from list
// Optimization: In a real app, create a specific endpoint
const templates = await getTemplates();
const found = templates.find((t: BackendTemplate) => t.id === templateId);
if (found) {
const mapped: RequestTemplate = {
id: found.id,
name: found.name,
description: found.description,
category: found.category,
icon: FileText,
estimatedTime: found.estimatedTime,
commonApprovers: found.approvers.map((a: any) => a.name),
workflowApprovers: found.approvers,
suggestedSLA: found.suggestedSLA,
priority: found.priority,
fields: found.fields || {}
};
setTemplate(mapped);
// Pre-fill
setFormData({
title: mapped.name,
description: mapped.description
});
} else {
toast.error('Template not found');
// navigate('/new-request'); // Removed to prevent potential redirect loops
// We will show the "Template not found" UI below since template is null
}
} catch (error) {
console.error('Error loading template:', error);
toast.error('Failed to load template details');
} finally {
setLoading(false);
}
};
if (templateId) {
loadTemplate();
}
}, [templateId, navigate]);
const handleSubmit = async () => {
if (!template) return;
try {
setSubmitting(true);
const formPayload: CreateWorkflowFromFormPayload = {
templateId: template.id,
templateType: 'TEMPLATE',
title: formData.title,
description: formData.description,
priorityUi: template.priority === 'high' ? 'express' : 'standard',
approverCount: template.workflowApprovers?.length || 0,
approvers: (template.workflowApprovers || []).map((a: any) => ({
email: a.email,
name: a.name,
tat: a.tat,
tatType: 'hours'
})),
spectators: [],
ccList: []
};
const response = await createWorkflowMultipart(formPayload, documents);
if (response && response.id) {
await submitWorkflow(response.id);
}
toast.success('Request Submitted Successfully', {
description: `Your request "${formData.title}" has been created.`
});
navigate('/my-requests');
} catch (error) {
console.error('Submission failed:', error);
toast.error('Failed to submit request');
} finally {
setSubmitting(false);
}
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
if (!template) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
<div className="bg-white p-8 rounded-lg shadow-md max-w-md w-full text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<FileText className="w-8 h-8 text-red-600" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">Template Not Found</h2>
<p className="text-gray-600 mb-6">
The requested template could not be loaded. It may have been deleted or you do not have permission to view it.
</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => navigate('/dashboard')}>
Go to Dashboard
</Button>
<Button onClick={() => navigate('/new-request')}>
Browse Templates
</Button>
</div>
</div>
</div>
);
}
return (
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Header */}
<header className="bg-white border-b flex-shrink-0 z-10">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => navigate('/new-request')}>
<ArrowLeft className="w-5 h-5 text-gray-500" />
</Button>
<div>
<h1 className="text-xl font-bold text-gray-900">New Request</h1>
<p className="text-sm text-gray-500">{template.name}</p>
</div>
</div>
<Button variant="outline" onClick={() => navigate('/dashboard')}>
Cancel Request
</Button>
</div>
{/* Stepper */}
<WizardStepper
currentStep={step}
totalSteps={2}
stepNames={stepNames}
/>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto py-8 px-6 bg-gray-50/50">
<div className="max-w-4xl mx-auto">
{step === 1 ? (
<AdminRequestDetailsStep
template={template}
formData={formData}
setFormData={setFormData}
documents={documents}
setDocuments={setDocuments}
/>
) : (
<AdminRequestReviewStep
template={template}
formData={formData}
documents={documents}
/>
)}
</div>
</main>
{/* Footer Actions */}
<footer className="bg-white border-t px-6 py-4 flex-shrink-0 z-10">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<Button
variant="outline"
onClick={() => step === 1 ? navigate('/new-request') : setStep(1)}
disabled={submitting}
>
{step === 1 ? 'Cancel' : 'Back to Details'}
</Button>
<Button
onClick={() => step === 1 ? setStep(2) : handleSubmit()}
disabled={submitting}
className={step === 2 ? "bg-re-green hover:bg-re-green/90" : "bg-re-green hover:bg-re-green/90"}
>
{step === 1 ? (
<>Review Request <ChevronRight className="w-4 h-4 ml-1" /></>
) : (
<>{submitting ? 'Submitting...' : 'Submit Request'} <Check className="w-4 h-4 ml-1" /></>
)}
</Button>
</div>
</footer>
</div>
);
}

View File

@ -1,171 +0,0 @@
import { useRef, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Upload, X, FileText, Eye } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
interface AdminRequestDetailsStepProps {
template: RequestTemplate;
formData: any;
setFormData: (data: any) => void;
documents: File[];
setDocuments: (docs: File[]) => void;
}
export function AdminRequestDetailsStep({
template,
formData,
setFormData,
documents,
setDocuments
}: AdminRequestDetailsStepProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setDocuments([...documents, ...Array.from(e.target.files)]);
}
};
const removeDocument = (index: number) => {
const newDocs = [...documents];
newDocs.splice(index, 1);
setDocuments(newDocs);
};
const handlePreview = (file: File) => {
const url = URL.createObjectURL(file);
setPreviewFile({ file, url });
};
const closePreview = () => {
if (previewFile?.url) {
URL.revokeObjectURL(previewFile.url);
}
setPreviewFile(null);
};
const canPreview = (file: File) => {
return file.type.includes('image') || file.type.includes('pdf');
};
return (
<div className="space-y-6 max-w-4xl mx-auto">
<Card className="shadow-sm">
<CardContent className="pt-6">
<div className="mb-4">
<h2 className="text-xl font-bold text-gray-800">{template.name}</h2>
<p className="text-sm text-gray-500 mt-1">{template.description}</p>
</div>
<div className="flex gap-4 text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">
<div className="flex items-center gap-1">
<span className="font-semibold">Category:</span> {template.category}
</div>
<div className="flex items-center gap-1">
<span className="font-semibold">Priority:</span>
<span className="capitalize">{template.priority}</span>
</div>
<div className="flex items-center gap-1">
<span className="font-semibold">SLA:</span> {template.suggestedSLA} Hours
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-4">
<div className="space-y-2">
<Label htmlFor="requestTitle">Request Title *</Label>
<Input
id="requestTitle"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder={`Request for ${template.name}`}
className="border-gray-200"
/>
</div>
<div className="space-y-2">
<Label htmlFor="justification" className="text-base font-semibold">Request Detail *</Label>
<p className="text-sm text-gray-600 mb-2">
Explain what you need approval for, why it's needed, and any relevant details.
</p>
<RichTextEditor
value={formData.description || ''}
onChange={(html) => setFormData({ ...formData, description: html })}
placeholder="Provide comprehensive details about your request..."
className="min-h-[120px] text-base border-gray-200 bg-white shadow-sm"
minHeight="120px"
/>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 space-y-4">
<Label>Supporting Documents</Label>
<div
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
<p className="text-sm font-medium text-gray-700">Click to upload files</p>
<p className="text-xs text-gray-500 mt-1">PDF, Excel, Images (Max 10MB)</p>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
/>
</div>
{documents.length > 0 && (
<div className="grid grid-cols-1 gap-2 mt-4">
{documents.map((file, index) => (
<div key={index} className="flex items-center justify-between p-3 bg-white border rounded-lg shadow-sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="text-sm font-medium text-gray-800 truncate max-w-[200px]">{file.name}</p>
<p className="text-xs text-gray-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
</div>
</div>
<div className="flex items-center gap-1">
{canPreview(file) && (
<Button variant="ghost" size="icon" onClick={() => handlePreview(file)}>
<Eye className="w-4 h-4 text-gray-500 hover:text-blue-600" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => removeDocument(index)}>
<X className="w-4 h-4 text-gray-500 hover:text-red-500" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{previewFile && (
<FilePreview
fileName={previewFile.file.name}
fileType={previewFile.file.type}
fileUrl={previewFile.url}
fileSize={previewFile.file.size}
open={!!previewFile}
onClose={closePreview}
/>
)}
</div>
);
}

View File

@ -1,136 +0,0 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { FileText, AlertCircle } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { sanitizeHTML } from '@/utils/sanitizer';
interface AdminRequestReviewStepProps {
template: RequestTemplate;
formData: any;
documents: File[];
}
export function AdminRequestReviewStep({
template,
formData,
documents
}: AdminRequestReviewStepProps) {
// Use template approvers if available, otherwise fallback (though should always be there for admin templates)
const approvers = template.workflowApprovers || [];
return (
<div className="space-y-6 max-w-4xl mx-auto">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
<div>
<h4 className="font-semibold text-blue-900">Ready to Submit?</h4>
<p className="text-sm text-blue-700 mt-1">
Please review the details below. This request will follow the standardized approval workflow defined by the administrator.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="md:col-span-2 space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Request Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Title</span>
<p className="text-base font-medium text-gray-900 mt-1">{formData.title}</p>
</div>
<div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
<div
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: sanitizeHTML(formData.description) }}
/>
</div>
{documents.length > 0 && (
<div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider block mb-2">Attachments ({documents.length})</span>
<div className="flex flex-wrap gap-2">
{documents.map((doc, i) => (
<Badge key={i} variant="secondary" className="pl-1 pr-2 py-1 flex items-center gap-1.5 h-auto">
<FileText className="w-3 h-3 text-gray-500" />
<span className="truncate max-w-[150px]">{doc.name}</span>
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Approval Workflow</CardTitle>
</CardHeader>
<CardContent>
<div className="relative pl-6 border-l-2 border-gray-100 space-y-8 py-2">
{approvers.map((approver: any, index: number) => (
<div key={index} className="relative">
{/* Timeline dot */}
<div className="absolute -left-[31px] top-1 w-4 h-4 rounded-full bg-white border-2 border-blue-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div className="flex justify-between items-start mb-1">
<div>
<h5 className="font-semibold text-gray-800 text-sm">{approver.name || approver.email}</h5>
<p className="text-xs text-gray-500">Level {approver.level} Approver</p>
</div>
<Badge variant="outline" className="bg-white text-xs">
{approver.tat || 24} Hours TAT
</Badge>
</div>
<p className="text-xs text-gray-400">{approver.email}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm uppercase text-gray-500">Properties</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Template</span>
<span className="text-sm font-medium text-right">{template.name}</span>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Priority</span>
<Badge className={
template.priority === 'high' ? 'bg-red-100 text-red-700 hover:bg-red-100' :
template.priority === 'medium' ? 'bg-orange-100 text-orange-700 hover:bg-orange-100' :
'bg-green-100 text-green-700 hover:bg-green-100'
}>
{template.priority.toUpperCase()}
</Badge>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Est. Time</span>
<span className="text-sm text-gray-900">{template.estimatedTime}</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -13,7 +13,7 @@
* - components/ - UI components * - components/ - UI components
*/ */
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal'; import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
@ -22,7 +22,7 @@ import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { downloadDocument } from '@/services/workflowApi'; import { downloadDocument } from '@/services/workflowApi';
// Custom Hooks // Custom Hooks
import { useCreateRequestForm, RequestTemplate } from '@/hooks/useCreateRequestForm'; import { useCreateRequestForm } from '@/hooks/useCreateRequestForm';
import { useWizardNavigation } from '@/hooks/useWizardNavigation'; import { useWizardNavigation } from '@/hooks/useWizardNavigation';
import { useRequestModals } from './hooks/useRequestModals'; import { useRequestModals } from './hooks/useRequestModals';
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission'; import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
@ -31,10 +31,6 @@ import { useCreateRequestHandlers } from './hooks/useCreateRequestHandlers';
// Constants // Constants
import { REQUEST_TEMPLATES } from './constants/requestTemplates'; import { REQUEST_TEMPLATES } from './constants/requestTemplates';
// Services
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
import { FileText } from 'lucide-react';
// Components // Components
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper'; import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter'; import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
@ -68,35 +64,6 @@ export function CreateRequest({
const isEditing = isEditMode && !!editRequestId; const isEditing = isEditMode && !!editRequestId;
const { user } = useAuth(); const { user } = useAuth();
const [adminTemplates, setAdminTemplates] = useState<RequestTemplate[]>([]);
useEffect(() => {
const fetchTemplates = async () => {
try {
const templates = await getTemplates();
const mappedTemplates: RequestTemplate[] = templates.map((t: BackendTemplate) => ({
id: t.id,
name: t.name,
description: t.description,
category: t.category,
icon: FileText,
estimatedTime: t.estimatedTime,
commonApprovers: t.approvers.map((a: any) => a.name),
workflowApprovers: t.approvers,
suggestedSLA: t.suggestedSLA,
priority: t.priority,
fields: t.fields || {}
}));
setAdminTemplates(mappedTemplates);
} catch (error) {
console.error('Failed to fetch admin templates:', error);
}
};
fetchTemplates();
}, []);
const allTemplates = useMemo(() => [...REQUEST_TEMPLATES, ...adminTemplates], [adminTemplates]);
// Form and state management hooks // Form and state management hooks
const { const {
formData, formData,
@ -108,7 +75,7 @@ export function CreateRequest({
documentPolicy, documentPolicy,
existingDocuments, existingDocuments,
setExistingDocuments, setExistingDocuments,
} = useCreateRequestForm(isEditing, editRequestId, allTemplates); } = useCreateRequestForm(isEditing, editRequestId, REQUEST_TEMPLATES);
const { const {
currentStep, currentStep,
@ -117,7 +84,6 @@ export function CreateRequest({
isStepValid, isStepValid,
nextStep: wizardNextStep, nextStep: wizardNextStep,
prevStep: wizardPrevStep, prevStep: wizardPrevStep,
goToStep,
} = useWizardNavigation(isEditing, selectedTemplate, formData); } = useWizardNavigation(isEditing, selectedTemplate, formData);
// Document management state // Document management state
@ -132,7 +98,6 @@ export function CreateRequest({
documentErrorModal, documentErrorModal,
openValidationModal, openValidationModal,
closeValidationModal, closeValidationModal,
openPolicyViolationModal,
closePolicyViolationModal, closePolicyViolationModal,
openDocumentErrorModal, openDocumentErrorModal,
closeDocumentErrorModal, closeDocumentErrorModal,
@ -173,40 +138,23 @@ export function CreateRequest({
wizardPrevStep, wizardPrevStep,
user: user!, user: user!,
openValidationModal, openValidationModal,
systemPolicy,
onPolicyViolation: openPolicyViolationModal,
onSubmit, onSubmit,
goToStep,
}); });
// Handle back button: // Handle back button:
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history) // - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
// - Other steps: Go to previous step in wizard // - Other steps: Go to previous step in wizard
const handleBackButton = useCallback(() => { const handleBackButton = useCallback(() => {
// If on the first step (Template Selection), always go back to dashboard if (currentStep === 1 || currentStep === 3 || currentStep === 4) {
// This prevents infinite loops if the user was redirected here from an error page // On steps 1, 3, or 4, navigate back to previous screen using browser history
if (currentStep === 1) {
navigate('/dashboard', { replace: true });
return;
}
// For other major steps (3=Approval, 4=Participants), we might want to go back to prev screen
// But for consistency and safety against loops, let's treat "Back" as "Previous Step"
// or explicit exit if at the start of a flow.
// Actually, keep the history logic ONLY for later steps if needed, but Step 1 MUST be explicit.
if (currentStep === 3 || currentStep === 4) {
// ... existing logic for these steps if we want to keep it,
// but typically "Back" in a wizard should go to previous wizard step.
// However, the original code had this specific logic.
// Let's defer to prevStep() for wizard navigation, and only use history/dashboard for exit.
if (onBack) { if (onBack) {
onBack(); onBack();
} else { } else {
// Use window.history.back() as fallback for more reliable navigation
if (window.history.length > 1) { if (window.history.length > 1) {
window.history.back(); window.history.back();
} else { } else {
// If no history, navigate to dashboard
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
} }
} }
@ -219,7 +167,7 @@ export function CreateRequest({
// Sync documents from formData only on initial mount (when loading draft) // Sync documents from formData only on initial mount (when loading draft)
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const documentsSyncedRef = useRef(false); const documentsSyncedRef = useRef(false);
useEffect(() => { useEffect(() => {
// Only sync from formData on initial mount or when loading a draft // Only sync from formData on initial mount or when loading a draft
if (isInitialMount.current && formData.documents && formData.documents.length > 0 && !documentsSyncedRef.current) { if (isInitialMount.current && formData.documents && formData.documents.length > 0 && !documentsSyncedRef.current) {
@ -233,7 +181,7 @@ export function CreateRequest({
// Use a ref to prevent circular updates // Use a ref to prevent circular updates
const isUpdatingFromFormData = useRef(false); const isUpdatingFromFormData = useRef(false);
const prevDocumentsRef = useRef(documents); const prevDocumentsRef = useRef(documents);
useEffect(() => { useEffect(() => {
// Skip if we're currently syncing from formData // Skip if we're currently syncing from formData
if (isUpdatingFromFormData.current) { if (isUpdatingFromFormData.current) {
@ -241,7 +189,7 @@ export function CreateRequest({
prevDocumentsRef.current = documents; prevDocumentsRef.current = documents;
return; return;
} }
// Only update if documents actually changed // Only update if documents actually changed
if (prevDocumentsRef.current !== documents) { if (prevDocumentsRef.current !== documents) {
updateFormData('documents', documents); updateFormData('documents', documents);
@ -259,7 +207,6 @@ export function CreateRequest({
templates={REQUEST_TEMPLATES} templates={REQUEST_TEMPLATES}
selectedTemplate={selectedTemplate} selectedTemplate={selectedTemplate}
onSelectTemplate={selectTemplate} onSelectTemplate={selectTemplate}
adminTemplates={adminTemplates}
/> />
); );
case 2: case 2:
@ -275,7 +222,6 @@ export function CreateRequest({
<ApprovalWorkflowStep <ApprovalWorkflowStep
formData={formData} formData={formData}
updateFormData={updateFormData} updateFormData={updateFormData}
systemPolicy={systemPolicy}
onValidationError={(error) => onValidationError={(error) =>
openValidationModal( openValidationModal(
error.type as 'error' | 'self-assign' | 'not-found', error.type as 'error' | 'self-assign' | 'not-found',
@ -283,7 +229,6 @@ export function CreateRequest({
error.message error.message
) )
} }
onPolicyViolation={openPolicyViolationModal}
/> />
); );
case 4: case 4:

View File

@ -8,7 +8,7 @@ import { Lightbulb, FileText } from 'lucide-react';
export const REQUEST_TEMPLATES: RequestTemplate[] = [ export const REQUEST_TEMPLATES: RequestTemplate[] = [
{ {
id: 'custom', id: 'custom',
name: 'Non-Templatized', name: 'Custom Request',
description: description:
'Create a custom request for unique business needs with full flexibility to define your own workflow and requirements', 'Create a custom request for unique business needs with full flexibility to define your own workflow and requirements',
category: 'General', category: 'General',

View File

@ -8,8 +8,7 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { RequestTemplate, FormData } from '@/hooks/useCreateRequestForm';
import { RequestTemplate, FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
import { PreviewDocument } from '../types/createRequest.types'; import { PreviewDocument } from '../types/createRequest.types';
import { getDocumentPreviewUrl } from '@/services/workflowApi'; import { getDocumentPreviewUrl } from '@/services/workflowApi';
import { validateApprovers } from './useApproverValidation'; import { validateApprovers } from './useApproverValidation';
@ -29,10 +28,7 @@ interface UseHandlersOptions {
email: string, email: string,
message: string message: string
) => void; ) => void;
systemPolicy?: SystemPolicy;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
onSubmit?: (requestData: any) => void; onSubmit?: (requestData: any) => void;
goToStep?: (step: number) => void;
} }
export function useCreateRequestHandlers({ export function useCreateRequestHandlers({
@ -46,12 +42,8 @@ export function useCreateRequestHandlers({
wizardPrevStep, wizardPrevStep,
user, user,
openValidationModal, openValidationModal,
systemPolicy,
onPolicyViolation,
onSubmit, onSubmit,
// goToStep,
}: UseHandlersOptions) { }: UseHandlersOptions) {
const navigate = useNavigate();
const [showTemplateModal, setShowTemplateModal] = useState(false); const [showTemplateModal, setShowTemplateModal] = useState(false);
const [previewDocument, setPreviewDocument] = const [previewDocument, setPreviewDocument] =
useState<PreviewDocument | null>(null); useState<PreviewDocument | null>(null);
@ -67,23 +59,13 @@ export function useCreateRequestHandlers({
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA); suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
updateFormData('slaEndDate', suggestedDate); updateFormData('slaEndDate', suggestedDate);
// Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep) if (template.id === 'existing-template') {
setShowTemplateModal(true);
if (template.id !== 'custom' && template.id !== 'existing-template') {
// Redirect to dedicated Admin Request flow
navigate(`/create-admin-request/${template.id}`);
} }
}; };
const handleTemplateSelection = (templateId: string) => { const handleTemplateSelection = (templateId: string) => {
// Navigate directly to the template-specific route when template is selected from modal if (onSubmit) {
if (templateId === 'claim-management') {
navigate('/claim-management');
} else if (templateId === 'vendor-payment') {
// Add vendor-payment route if it exists, otherwise fallback to onSubmit
navigate('/vendor-payment');
} else if (onSubmit) {
// Fallback to onSubmit for other template types
onSubmit({ templateType: templateId }); onSubmit({ templateType: templateId });
} }
}; };
@ -92,32 +74,12 @@ export function useCreateRequestHandlers({
const nextStep = async () => { const nextStep = async () => {
if (!isStepValid()) return; if (!isStepValid()) return;
// On step 1, if "existing-template" is selected, open the template selection modal
if (currentStep === 1 && _selectedTemplate?.id === 'existing-template') {
setShowTemplateModal(true);
return;
}
if (window.innerWidth < 640) { if (window.innerWidth < 640) {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
} }
// Special validation when leaving step 3 (Approval Workflow) // Special validation when leaving step 3 (Approval Workflow)
if (currentStep === 3) { if (currentStep === 3) {
// Validate approval level count against system policy
if (systemPolicy && onPolicyViolation) {
const approverCount = formData.approverCount || 1;
if (approverCount > systemPolicy.maxApprovalLevels) {
onPolicyViolation([{
type: 'Maximum Approval Levels Exceeded',
message: `The request has ${approverCount} approval levels, which exceeds the maximum allowed (${systemPolicy.maxApprovalLevels}). Please reduce the number of approvers.`,
currentValue: approverCount,
maxValue: systemPolicy.maxApprovalLevels
}]);
return;
}
}
const initiatorEmail = (user as any)?.email?.toLowerCase() || ''; const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
const validation = await validateApprovers( const validation = await validateApprovers(
formData.approvers, formData.approvers,
@ -180,7 +142,6 @@ export function useCreateRequestHandlers({
setPreviewDocument(null); setPreviewDocument(null);
}; };
return { return {
showTemplateModal, showTemplateModal,
setShowTemplateModal, setShowTemplateModal,

View File

@ -3,7 +3,6 @@
*/ */
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'sonner';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm'; import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
import { import {
buildCreatePayload, buildCreatePayload,
@ -11,6 +10,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,48 +58,34 @@ 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,
documentsToDelete documentsToDelete
); );
// Show toast after backend confirmation
toast.success('Request Submitted Successfully!', {
description: `Your request "${formData.title}" has been submitted and sent for approval.`,
duration: 5000,
});
onSubmit?.({ onSubmit?.({
...formData, ...formData,
backendId: editRequestId, backendId: editRequestId,
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
toast.success('Request Submitted Successfully!', {
description: `Your request "${formData.title}" has been created and sent for approval.`,
duration: 5000,
});
onSubmit?.({ onSubmit?.({
...formData, ...formData,
@ -106,12 +93,8 @@ export function useCreateRequestSubmission({
template: selectedTemplate, template: selectedTemplate,
}); });
} }
} catch (error: any) { } catch (error) {
console.error('Failed to submit workflow:', error); console.error('Failed to submit workflow:', error);
toast.error('Failed to Submit Request', {
description: error?.response?.data?.message || error?.message || 'An error occurred while submitting the request.',
duration: 5000,
});
setSubmitting(false); setSubmitting(false);
} }
}; };
@ -133,12 +116,11 @@ 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
); );
await updateWorkflowRequest( await updateWorkflowRequest(
@ -148,44 +130,29 @@ export function useCreateRequestSubmission({
documentsToDelete documentsToDelete
); );
toast.success('Draft Saved Successfully!', {
description: `Your request "${formData.title}" has been saved as draft.`,
duration: 5000,
});
onSubmit?.({ onSubmit?.({
...formData, ...formData,
backendId: editRequestId, backendId: editRequestId,
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
); );
const result = await createWorkflow(createPayload, documents); const result = await createWorkflow(createPayload, documents);
toast.success('Draft Saved Successfully!', {
description: `Your request "${formData.title}" has been saved as draft.`,
duration: 5000,
});
onSubmit?.({ onSubmit?.({
...formData, ...formData,
backendId: result.id, backendId: result.id,
template: selectedTemplate, template: selectedTemplate,
}); });
} }
} catch (error: any) { } catch (error) {
console.error('Failed to save draft:', error); console.error('Failed to save draft:', error);
toast.error('Failed to Save Draft', {
description: error?.response?.data?.message || error?.message || 'An error occurred while saving the draft.',
duration: 5000,
});
setSavingDraft(false); setSavingDraft(false);
} }
}; };

View File

@ -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,36 @@ 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);
}
/**
* Create and submit a workflow in one operation
*/
export async function createAndSubmitWorkflow(
payload: CreateWorkflowPayload,
documents: File[]
): Promise<{ id: string }> {
const result = await createWorkflow(payload, documents);
await submitWorkflowRequest(result.id);
return result;
}
/**
* Update and submit a workflow in one operation
*/
export async function updateAndSubmitWorkflow(
requestId: string,
payload: UpdateWorkflowPayload,
documents: File[],
documentsToDelete: string[]
): Promise<void> {
await updateWorkflowRequest(requestId, payload, documents, documentsToDelete);
await submitWorkflowRequest(requestId);
}

View File

@ -67,7 +67,6 @@ export interface CreateWorkflowPayload {
email: string; email: string;
}>; }>;
participants: Participant[]; participants: Participant[];
isDraft?: boolean;
} }
export interface UpdateWorkflowPayload { export interface UpdateWorkflowPayload {
@ -77,7 +76,6 @@ export interface UpdateWorkflowPayload {
approvalLevels: ApprovalLevel[]; approvalLevels: ApprovalLevel[];
participants: Participant[]; participants: Participant[];
deleteDocumentIds?: string[]; deleteDocumentIds?: string[];
isDraft?: boolean;
} }
export interface ValidationModalState { export interface ValidationModalState {

View File

@ -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,
}; };
} }
@ -80,7 +84,7 @@ export function validateApproversForSubmission(
approverCount: number approverCount: number
): { valid: boolean; message?: string } { ): { valid: boolean; message?: string } {
const approversToCheck = approvers.slice(0, approverCount); const approversToCheck = approvers.slice(0, approverCount);
// Check if all approvers have valid emails // Check if all approvers have valid emails
const hasMissingEmails = approversToCheck.some( const hasMissingEmails = approversToCheck.some(
(a: any) => !a?.email || !a.email.trim() (a: any) => !a?.email || !a.email.trim()
@ -108,3 +112,4 @@ export function validateApproversForSubmission(
return { valid: true }; return { valid: true };
} }

View File

@ -4,7 +4,6 @@ import { type DateRange } from '@/services/dashboard.service';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext'; import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { useAppSelector, useAppDispatch } from '@/redux/hooks'; import { useAppSelector, useAppDispatch } from '@/redux/hooks';
import { setViewAsUser } from './redux/dashboardSlice'; import { setViewAsUser } from './redux/dashboardSlice';
import { TokenManager } from '@/utils/tokenManager';
// Custom Hooks // Custom Hooks
import { useDashboardFilters } from './hooks/useDashboardFilters'; import { useDashboardFilters } from './hooks/useDashboardFilters';
@ -162,19 +161,8 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateRange, customStartDate, customEndDate, viewAsUser]); }, [dateRange, customStartDate, customEndDate, viewAsUser]);
// Check if user is a Dealer
const isDealer = useMemo(() => {
try {
const userData = TokenManager.getUserData();
return userData?.jobTitle === 'Dealer';
} catch (error) {
console.error('[Dashboard] Error checking dealer status:', error);
return false;
}
}, []);
// Quick actions // Quick actions
const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate, isDealer), [isAdmin, onNewRequest, onNavigate, isDealer]); const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate), [isAdmin, onNewRequest, onNavigate]);
// KPI click handler // KPI click handler
const handleKPIClick = useCallback((filters: { const handleKPIClick = useCallback((filters: {

View File

@ -9,11 +9,11 @@ import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Filter, Calendar as CalendarIcon, RefreshCw } from 'lucide-react'; import { Filter, Calendar as CalendarIcon, RefreshCw } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { DateRange } from '@/services/dashboard.service'; import { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker';
interface DashboardFiltersBarProps { interface DashboardFiltersBarProps {
isAdmin: boolean; isAdmin: boolean;
@ -96,10 +96,12 @@ export function DashboardFiltersBar({
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label> <Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
<CustomDatePicker <Input
value={customStartDate || null} id="start-date"
onChange={(dateStr: string | null) => { type="date"
const date = dateStr ? new Date(dateStr) : undefined; value={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) { if (date) {
onCustomStartDateChange(date); onCustomStartDateChange(date);
if (customEndDate && date > customEndDate) { if (customEndDate && date > customEndDate) {
@ -109,18 +111,19 @@ export function DashboardFiltersBar({
onCustomStartDateChange(undefined); onCustomStartDateChange(undefined);
} }
}} }}
maxDate={new Date()} max={format(new Date(), 'yyyy-MM-dd')}
placeholderText="dd/mm/yyyy"
className="w-full" className="w-full"
data-testid="start-date-input" data-testid="start-date-input"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label> <Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
<CustomDatePicker <Input
value={customEndDate || null} id="end-date"
onChange={(dateStr: string | null) => { type="date"
const date = dateStr ? new Date(dateStr) : undefined; value={customEndDate ? format(customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) { if (date) {
onCustomEndDateChange(date); onCustomEndDateChange(date);
if (customStartDate && date < customStartDate) { if (customStartDate && date < customStartDate) {
@ -130,9 +133,8 @@ export function DashboardFiltersBar({
onCustomEndDateChange(undefined); onCustomEndDateChange(undefined);
} }
}} }}
minDate={customStartDate || undefined} min={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : undefined}
maxDate={new Date()} max={format(new Date(), 'yyyy-MM-dd')}
placeholderText="dd/mm/yyyy"
className="w-full" className="w-full"
data-testid="end-date-input" data-testid="end-date-input"
/> />
@ -179,88 +181,6 @@ export function DashboardFiltersBar({
<SelectItem value="custom">Custom Range</SelectItem> <SelectItem value="custom">Custom Range</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{/* Custom Date Range Picker for Normal Users */}
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2" data-testid="custom-date-trigger">
<CalendarIcon className="w-4 h-4" />
{customStartDate && customEndDate
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8} data-testid="custom-date-picker">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date-user" className="text-sm font-medium">Start Date</Label>
<CustomDatePicker
value={customStartDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomStartDateChange(date);
if (customEndDate && date > customEndDate) {
onCustomEndDateChange(date);
}
} else {
onCustomStartDateChange(undefined);
}
}}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
data-testid="start-date-input-user"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date-user" className="text-sm font-medium">End Date</Label>
<CustomDatePicker
value={customEndDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
if (date) {
onCustomEndDateChange(date);
if (customStartDate && date < customStartDate) {
onCustomStartDateChange(date);
}
} else {
onCustomEndDateChange(undefined);
}
}}
minDate={customStartDate || undefined}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
data-testid="end-date-input-user"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={onApplyCustomDate}
disabled={!customStartDate || !customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
data-testid="apply-custom-date-user"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={onResetCustomDates}
data-testid="cancel-custom-date-user"
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
)} )}
@ -286,3 +206,4 @@ export function DashboardFiltersBar({
</Card> </Card>
); );
} }

Some files were not shown because too many files have changed in this diff Show More