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>
<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 { 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 { Dashboard } from '@/pages/Dashboard';
import { OpenRequests } from '@/pages/OpenRequests';
@ -10,7 +10,6 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
@ -18,21 +17,16 @@ import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings';
import { SecuritySettings } from '@/pages/Settings/SecuritySettings';
import { Notifications } from '@/pages/Notifications';
import { DetailedReports } from '@/pages/DetailedReports';
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
import { Admin } from '@/pages/Admin';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { AuthCallback } from '@/pages/Auth/AuthCallback';
import { createClaimRequest } from '@/services/dealerClaimApi';
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
import { navigateToRequest } from '@/utils/requestNavigation';
import { TokenManager } from '@/utils/tokenManager';
interface AppProps {
onLogout?: () => void;
@ -43,7 +37,7 @@ interface AppProps {
function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) {
const { user } = useAuth();
const isAdmin = hasManagementAccess(user);
// Render separate screens based on user role
// Admin/Management users see all organization requests
// 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
function AppRoutes({ onLogout }: AppProps) {
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);
setSelectedRequestTitle(requestTitle || 'Unknown Request');
// Use global navigation utility for consistent routing
const { navigateToRequest } = await import('@/utils/requestNavigation');
navigateToRequest({
requestId,
requestTitle,
@ -183,18 +141,11 @@ function AppRoutes({ onLogout }: AppProps) {
}
return;
}
// If requestData has backendId, it means it came from the API flow (CreateRequest component)
// 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)
// Regular custom request submission
// 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
const newCustomRequest = {
id: requestId,
@ -222,21 +173,21 @@ function AppRoutes({ onLogout }: AppProps) {
avatar: 'CU'
},
department: requestData.department || 'General',
createdAt: new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
createdAt: new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
hour12: true
}),
updatedAt: new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
updatedAt: new Date().toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
hour12: true
}),
dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
submittedDate: new Date().toISOString(),
@ -246,7 +197,7 @@ function AppRoutes({ onLogout }: AppProps) {
// Extract name from email if name is not available
const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`;
const approverEmail = approver?.email || '';
return {
step: index + 1,
approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`,
@ -271,28 +222,32 @@ function AppRoutes({ onLogout }: AppProps) {
};
}),
auditTrail: [
{
type: 'created',
action: 'Request Created',
details: `Custom request "${requestData.title}" created`,
user: 'Current User',
{
type: 'created',
action: 'Request Created',
details: `Custom request "${requestData.title}" created`,
user: 'Current User',
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
},
{
type: 'assignment',
action: 'Assigned to Approver',
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`,
user: 'System',
{
type: 'assignment',
action: 'Assigned to Approver',
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`,
user: 'System',
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
}
],
tags: requestData.tags || ['custom-request']
};
// Add to dynamic requests
setDynamicRequests([...dynamicRequests, newCustomRequest]);
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) => {
@ -309,7 +264,7 @@ function AppRoutes({ onLogout }: AppProps) {
duration: 5000,
});
}
setApprovalAction(null);
resolve(true);
}, 1000);
@ -342,7 +297,7 @@ function AppRoutes({ onLogout }: AppProps) {
// Call API to create claim request
const response = await createClaimRequest(payload);
// Validate response - ensure request was actually created successfully
if (!response || !response.request) {
throw new Error('Invalid response from server: Request object not found');
@ -376,11 +331,11 @@ function AppRoutes({ onLogout }: AppProps) {
}
} catch (error: any) {
console.error('[App] Error creating claim request:', error);
// Check for manager-related errors
const errorData = error?.response?.data;
const errorCode = errorData?.code || errorData?.error?.code;
if (errorCode === 'NO_MANAGER_FOUND') {
// Show modal for no manager found
setManagerModalData({
@ -391,7 +346,7 @@ function AppRoutes({ onLogout }: AppProps) {
setManagerModalOpen(true);
return;
}
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
// Show modal with manager list for selection
const managers = errorData?.managers || errorData?.error?.managers || [];
@ -404,171 +359,343 @@ function AppRoutes({ onLogout }: AppProps) {
setManagerModalOpen(true);
return;
}
// Other errors - show toast
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
toast.error('Failed to Submit Claim Request', {
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 (
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes>
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
<Route
path="/login/callback"
element={<AuthCallback />}
{/* Auth Callback - Must be before other routes */}
<Route
path="/login/callback"
element={<AuthCallback />}
/>
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
<Route
path="/"
{/* Dashboard */}
<Route
path="/"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
}
/>
<Route
path="/dashboard"
<Route
path="/dashboard"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</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 */}
<Route
path="/open-requests"
<Route
path="/open-requests"
element={
<PageLayout currentPage="open-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<OpenRequests onViewRequest={handleViewRequest} />
</PageLayout>
}
}
/>
{/* Closed Requests */}
<Route
path="/closed-requests"
<Route
path="/closed-requests"
element={
<PageLayout currentPage="closed-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<ClosedRequests onViewRequest={handleViewRequest} />
</PageLayout>
}
}
/>
{/* Shared Summaries */}
<Route
path="/shared-summaries"
<Route
path="/shared-summaries"
element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaries />
</PageLayout>
}
}
/>
{/* Shared Summary Detail */}
<Route
path="/shared-summaries/:sharedSummaryId"
<Route
path="/shared-summaries/:sharedSummaryId"
element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaryDetail />
</PageLayout>
}
}
/>
{/* My Requests */}
<Route
path="/my-requests"
<Route
path="/my-requests"
element={
<PageLayout currentPage="my-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<MyRequests onViewRequest={handleViewRequest} dynamicRequests={dynamicRequests} />
</PageLayout>
}
}
/>
{/* Requests - Separate screens for Admin and Regular Users */}
<Route
path="/requests"
<Route
path="/requests"
element={
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<RequestsRoute onViewRequest={handleViewRequest} />
</PageLayout>
}
}
/>
{/* Approver Performance - Detailed Performance Analysis */}
<Route
path="/approver-performance"
<Route
path="/approver-performance"
element={
<PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<ApproverPerformance />
</PageLayout>
}
}
/>
{/* Request Detail - requestId will be read from URL params */}
<Route
path="/request/:requestId"
<Route
path="/request/:requestId"
element={
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<RequestDetail
requestId=""
<RequestDetail
requestId=""
onBack={handleBack}
dynamicRequests={dynamicRequests}
/>
</PageLayout>
}
}
/>
{/* Work Notes - Dedicated Full-Screen Page */}
<Route
path="/work-notes/:requestId"
element={<WorkNotes />}
<Route
path="/work-notes/:requestId"
element={<WorkNotes />}
/>
{/* New Request (Custom) */}
<Route
path="/new-request"
<Route
path="/new-request"
element={
<CreateRequest
onBack={handleBack}
onSubmit={handleNewRequestSubmit}
/>
}
}
/>
{/* Edit Draft Request */}
<Route
path="/edit-request/:requestId"
<Route
path="/edit-request/:requestId"
element={
<CreateRequest
onBack={handleBack}
@ -576,76 +703,72 @@ function AppRoutes({ onLogout }: AppProps) {
requestId={undefined} // Will be read from URL params
isEditMode={true}
/>
}
}
/>
{/* Claim Management Wizard */}
<Route
path="/claim-management"
<Route
path="/claim-management"
element={
<ClaimManagementWizard
onBack={handleBack}
onSubmit={handleClaimManagementSubmit}
/>
}
}
/>
{/* Profile */}
<Route
path="/profile"
<Route
path="/profile"
element={
<PageLayout currentPage="profile" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Profile />
</PageLayout>
}
}
/>
{/* Settings */}
<Route
path="/settings"
<Route
path="/settings"
element={
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Settings />
</PageLayout>
}
/>
{/* Security Settings */}
<Route
path="/settings/security"
element={
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SecuritySettings />
</PageLayout>
}
}
/>
{/* Notifications */}
<Route
path="/notifications"
<Route
path="/notifications"
element={
<PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Notifications onNavigate={handleNavigate} />
</PageLayout>
}
}
/>
{/* Detailed Reports */}
<Route
path="/detailed-reports"
<Route
path="/detailed-reports"
element={
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DetailedReports />
</PageLayout>
}
}
/>
{/* Admin Control Panel */}
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
</Routes>
<Toaster
<Toaster
position="top-right"
toastOptions={{
style: {
@ -699,7 +822,7 @@ interface MainAppProps {
export default function App(props?: MainAppProps) {
const { onLogout } = props || {};
return (
<BrowserRouter>
<AppRoutes onLogout={onLogout} />

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

View File

@ -8,7 +8,6 @@
// Images
export { default as ReLogo } from './images/Re_Logo.png';
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
export { default as LandingPageImage } from './images/landing_page_image.jpg';
// Fonts
// 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 = () => {
// TODO: Implement API call to save configuration
toast.success('Analytics configuration saved successfully');
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,6 @@ import { ReLogo } from '@/assets';
import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns';
import { TokenManager } from '@/utils/tokenManager';
interface PageLayoutProps {
children: React.ReactNode;
@ -36,18 +35,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false);
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
const getUserInitials = () => {
try {
@ -67,28 +55,24 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
return 'U';
}
};
const menuItems = useMemo(() => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: Home },
// 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: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
{ id: 'requests', label: 'All Requests', icon: List },
];
// Add remaining menu items (exclude "My Requests" for dealers)
if (!isDealer) {
items.push({ id: 'my-requests', label: 'My Requests', icon: User });
}
// Add remaining menu items
items.push(
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
);
return items;
}, [isDealer]);
}, []);
const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen);
@ -99,7 +83,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Mark as read
if (!notification.isRead) {
await notificationApi.markAsRead(notification.notificationId);
setNotifications(prev =>
setNotifications(prev =>
prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n)
);
setUnreadCount(prev => Math.max(0, prev - 1));
@ -112,14 +96,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
if (requestNumber) {
// Determine which tab to open based on notification type
let navigationUrl = `request/${requestNumber}`;
// Work note related notifications should open Work Notes tab
if (notification.notificationType === 'mention' ||
notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') {
if (notification.notificationType === 'mention' ||
notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') {
navigationUrl += '?tab=worknotes';
}
// Navigate to request detail page
onNavigate(navigationUrl);
}
@ -153,7 +137,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
try {
const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false });
if (!mounted) return;
const notifs = result.data?.notifications || [];
setNotifications(notifs);
setUnreadCount(result.data?.unreadCount || 0);
@ -174,7 +158,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Listen for new notifications
const handleNewNotification = (data: { notification: Notification }) => {
if (!mounted) return;
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
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">
{/* Mobile Overlay */}
{sidebarOpen && (
<div
<div
className="fixed inset-0 bg-black/50 z-40 md:hidden"
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="p-4 border-b border-gray-800 flex-shrink-0">
<div className="flex flex-col items-center justify-center">
<img
src={ReLogo}
alt="Royal Enfield Logo"
<img
src={ReLogo}
alt="Royal Enfield Logo"
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>
@ -249,44 +233,39 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div>
<div className="p-3 flex-1 overflow-y-auto">
<div className="space-y-2">
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => (
{menuItems.map((item) => (
<button
key={item.id}
onClick={() => {
if (item.id === 'admin/templates') {
onNavigate?.('admin/templates');
} else {
onNavigate?.(item.id);
}
onNavigate?.(item.id);
// Close sidebar on mobile after navigation
if (window.innerWidth < 768) {
setSidebarOpen(false);
}
}}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
? 'bg-re-green text-white font-medium'
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
}`}
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
currentPage === item.id
? '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" />
<span className="truncate">{item.label}</span>
</button>
))}
</div>
{/* Quick Action in Sidebar - Right below menu items */}
{!isDealer && (
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
<Button
onClick={onNewRequest}
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Raise New Request
</Button>
</div>
)}
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
<Button
onClick={onNewRequest}
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Raise New Request
</Button>
</div>
</div>
</div>
</aside>
@ -296,14 +275,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{/* Header */}
<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">
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
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" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
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" />}
</Button>
{/* Search bar commented out */}
{/* <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" />
@ -315,16 +294,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</div>
<div className="flex items-center gap-4 shrink-0">
{!isDealer && (
<Button
onClick={onNewRequest}
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
size="sm"
>
<Plus className="w-4 h-4" />
New Request
</Button>
)}
<Button
onClick={onNewRequest}
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
size="sm"
>
<Plus className="w-4 h-4" />
New Request
</Button>
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<DropdownMenuTrigger asChild>
@ -365,8 +342,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{notifications.map((notif) => (
<div
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)}
>
<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
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setShowLogoutDialog(true)}
<DropdownMenuItem
onClick={() => setShowLogoutDialog(true)}
className="text-red-600 focus:text-red-600"
>
<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 { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Upload,
X,
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Upload,
X,
FileText,
Check,
Users
@ -150,7 +150,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
onChange={(e) => updateFormData('title', e.target.value)}
/>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
@ -183,7 +183,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<PopoverTrigger asChild>
<Button variant="outline" className="w-full justify-start text-left">
<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>
</PopoverTrigger>
<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>
</div>
</div>
<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.'
: 'All approvers will review the request simultaneously.'
}
@ -311,7 +311,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
</SelectTrigger>
<SelectContent>
{availableUsers
.filter(user =>
.filter(user =>
!formData.spectators.find(s => s.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">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-2">
click to browse
Drag and drop files here, or click to browse
</p>
<input
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Button } from '../ui/button';
import { Badge } from '../ui/badge';
import { Separator } from '../ui/separator';
import {
Receipt,
Package,
import {
Receipt,
Package,
ArrowRight,
ArrowLeft,
Clock,
CheckCircle,
Target,
X,
Sparkles,
Check,
AlertCircle
Check
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { TokenManager } from '../../utils/tokenManager';
interface TemplateSelectionModalProps {
open: boolean;
@ -41,8 +39,7 @@ const AVAILABLE_TEMPLATES = [
'Document verification',
'E-invoice generation',
'Credit note issuance'
],
disabled: false
]
},
{
id: 'vendor-payment',
@ -58,32 +55,14 @@ const AVAILABLE_TEMPLATES = [
'Invoice verification',
'Multi-level approvals',
'Payment scheduling'
],
disabled: true,
comingSoon: true
]
}
];
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
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) => {
// 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);
};
@ -96,7 +75,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
return (
<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"
>
{/* 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.
</DialogDescription>
{/* Back arrow button - Top left */}
{/* Custom Close button */}
<button
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"
aria-label="Go back"
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"
>
<ArrowLeft className="w-5 h-5 text-gray-600" />
<X className="w-5 h-5 text-gray-600" />
</button>
{/* Full Screen Content Container */}
<div className="h-full overflow-y-auto">
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
{/* Header Section */}
<motion.div
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-center mb-12 max-w-3xl"
@ -139,7 +117,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{AVAILABLE_TEMPLATES.map((template, index) => {
const Icon = template.icon;
const isSelected = selectedTemplate === template.id;
const isDisabled = isDealer || template.disabled;
return (
<motion.div
@ -147,16 +124,15 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
whileHover={isDisabled ? {} : { scale: 1.03 }}
whileTap={isDisabled ? {} : { scale: 0.98 }}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
>
<Card
className={`h-full transition-all duration-300 border-2 ${isDisabled
? 'opacity-50 cursor-not-allowed border-gray-200'
: isSelected
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`}
<Card
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
isSelected
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
}`}
onClick={() => handleSelect(template.id)}
>
<CardHeader className="space-y-4 pb-4">
@ -181,22 +157,6 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
<CardDescription className="text-sm leading-relaxed">
{template.description}
</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>
</CardHeader>
<CardContent className="pt-0 space-y-4">
@ -205,9 +165,9 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
{template.category}
</Badge>
</div>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
@ -243,28 +203,29 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
</div>
{/* Action Buttons */}
<motion.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
>
<Button
variant="outline"
<Button
variant="outline"
onClick={onClose}
size="lg"
className="px-8"
>
Cancel
</Button>
<Button
<Button
onClick={handleContinue}
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
disabled={!selectedTemplate}
size="lg"
className={`gap-2 px-8 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400 cursor-not-allowed'
}`}
className={`gap-2 px-8 ${
selectedTemplate
? 'bg-blue-600 hover:bg-blue-700'
: 'bg-gray-400'
}`}
>
Continue with Template
<ArrowRight className="w-4 h-4" />

View File

@ -1,19 +1,18 @@
import React, { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
import { sanitizeHTML } from '../../utils/sanitizer';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Avatar, AvatarFallback } from '../ui/avatar';
import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { ScrollArea } from '../ui/scroll-area';
import {
Send,
Smile,
Paperclip,
Users,
FileText,
Download,
import {
Send,
Smile,
Paperclip,
Users,
FileText,
Download,
Eye,
MoreHorizontal
} from 'lucide-react';
@ -167,8 +166,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
const formatMessage = (content: string) => {
// Simple mention highlighting
const formatted = content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
return sanitizeHTML(formatted);
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
};
return (
@ -189,7 +187,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<TabsTrigger value="chat">Chat</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
</TabsList>
<TabsContent value="chat" className="flex-1 flex flex-col">
<ScrollArea className="flex-1 p-4 border rounded-lg">
<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' : ''}`}>
{!msg.isSystem && (
<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' :
'bg-re-light-green'
}`}>
'bg-re-light-green'
}`}>
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
{msg.isSystem ? (
<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}
</span>
</div>
<div
<div
className="text-sm bg-muted/30 p-3 rounded-lg"
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
/>
@ -301,14 +300,15 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<h4 className="font-medium">Participants</h4>
<Badge variant="outline">{participants.length}</Badge>
</div>
<div className="space-y-3">
{participants.map((participant, index) => (
<div key={index} className="flex items-center gap-3">
<div className="relative">
<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}
</AvatarFallback>
</Avatar>

View File

@ -24,8 +24,6 @@ interface AddApproverModalProps {
requestTitle?: string;
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
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({
@ -33,9 +31,7 @@ export function AddApproverModal({
onClose,
onConfirm,
existingParticipants = [],
currentLevels = [],
maxApprovalLevels,
onPolicyViolation
currentLevels = []
}: AddApproverModalProps) {
const [email, setEmail] = useState('');
const [tatHours, setTatHours] = useState<number>(24);
@ -144,36 +140,6 @@ export function AddApproverModal({
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
const existingParticipant = existingParticipants.find(
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.
</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 */}
{currentLevels.length > 0 && (
<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
data-slot="chart"
data-chart={chartId}
style={getChartStyle(config)}
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",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</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(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return {};
return null;
}
const styles: Record<string, string> = {};
colorConfig.forEach(([key, itemConfig]) => {
// For simplicity, we'll use the default color or the light theme color
// If you need per-theme variables, they should be handled via CSS classes or media queries
// but applying them here as inline styles is CSP-safe.
const color = itemConfig.color || itemConfig.theme?.light;
if (color) {
styles[`--color-${key}`] = color;
}
// Handle dark theme if present
const darkColor = itemConfig.theme?.dark;
if (darkColor) {
styles[`--color-${key}-dark`] = darkColor;
}
});
return styles as React.CSSProperties;
};
// Deprecated: Kept for backward compatibility if needed in other files.
const ChartStyle = () => {
return null;
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
@ -318,8 +316,8 @@ function getPayloadConfigFromPayload(
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: 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 { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
import { sanitizeHTML } from "@/utils/sanitizer";
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
value: string;
@ -60,8 +59,7 @@ export function RichTextEditor({
// Only update if the value actually changed externally
const currentValue = editorRef.current.innerHTML;
if (currentValue !== value) {
// Sanitize incoming content
editorRef.current.innerHTML = sanitizeHTML(value || '');
editorRef.current.innerHTML = value || '';
}
}
}, [value]);
@ -70,55 +68,55 @@ export function RichTextEditor({
const cleanWordHTML = React.useCallback((html: string): string => {
// Remove HTML comments (like Word style definitions)
html = html.replace(/<!--[\s\S]*?-->/g, '');
// Remove style tags (Word CSS)
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
// Remove script tags
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
// Remove meta tags
html = html.replace(/<meta[^>]*>/gi, '');
// Remove Word-specific classes and attributes
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="[^"]*font-family:[^"]*"/gi, '');
// Remove xmlns attributes
html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, '');
// Remove o:p tags (Word paragraph markers)
html = html.replace(/<\/?o:p[^>]*>/gi, '');
// Remove v:shapes and other Word-specific elements
html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
html = html.replace(/<v:[^>]*\/>/gi, '');
// Clean up empty paragraphs
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
html = html.replace(/<div[^>]*>\s*<\/div>/gi, '');
// Remove excessive whitespace
html = html.replace(/\s+/g, ' ');
html = html.trim();
return html;
}, []);
// Handle paste event to preserve formatting
const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const clipboardData = e.clipboardData;
let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain');
// Clean Word/Office metadata if HTML
if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) {
pastedData = cleanWordHTML(pastedData);
}
if (!editorRef.current) return;
const selection = window.getSelection();
@ -133,12 +131,12 @@ export function RichTextEditor({
// Clean and preserve formatting
const fragment = document.createDocumentFragment();
// Process each node to preserve lists, tables, and basic formatting
Array.from(tempDiv.childNodes).forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
// Preserve lists (ul, ol)
if (element.tagName === 'UL' || element.tagName === 'OL') {
const list = element.cloneNode(true) as HTMLElement;
@ -171,6 +169,9 @@ export function RichTextEditor({
// Wrap table in scrollable container for mobile
const wrapper = document.createElement('div');
wrapper.className = 'table-wrapper';
wrapper.style.overflowX = 'auto';
wrapper.style.maxWidth = '100%';
wrapper.style.margin = '8px 0';
wrapper.appendChild(table);
fragment.appendChild(wrapper);
}
@ -181,7 +182,7 @@ export function RichTextEditor({
const innerHTML = element.innerHTML;
// Remove style tags and comments from inner HTML
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '');
.replace(/<!--[\s\S]*?-->/g, '');
p.innerHTML = cleaned;
p.removeAttribute('style');
p.removeAttribute('class');
@ -226,36 +227,36 @@ export function RichTextEditor({
}
range.insertNode(fragment);
// Move cursor to end of inserted content
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
// Trigger onChange with sanitized content
// Trigger onChange
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
}, [onChange, cleanWordHTML]);
// Check active formats (bold, italic, etc.)
const checkActiveFormats = React.useCallback(() => {
if (!editorRef.current || !isFocused) return;
const formats = new Set<string>();
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement;
} else {
element = commonAncestor as HTMLElement;
}
while (element && element !== editorRef.current) {
const tagName = element.tagName.toLowerCase();
if (tagName === 'strong' || tagName === 'b') formats.add('bold');
@ -266,40 +267,40 @@ export function RichTextEditor({
if (tagName === 'h3') formats.add('h3');
if (tagName === 'ul') formats.add('ul');
if (tagName === 'ol') formats.add('ol');
const style = window.getComputedStyle(element);
if (style.textAlign === 'center') formats.add('center');
if (style.textAlign === 'right') formats.add('right');
if (style.textAlign === 'left') formats.add('left');
// Convert RGB/RGBA to hex for comparison
const colorToHex = (color: string): string | null => {
// If already hex format
if (color.startsWith('#')) {
return color.toUpperCase();
}
// If RGB/RGBA format
const result = color.match(/\d+/g);
if (!result || result.length < 3) return null;
const r = result[0];
const g = result[1];
const b = result[2];
if (!r || !g || !b) return null;
const rHex = parseInt(r).toString(16).padStart(2, '0');
const gHex = parseInt(g).toString(16).padStart(2, '0');
const bHex = parseInt(b).toString(16).padStart(2, '0');
return `#${rHex}${gHex}${bHex}`.toUpperCase();
};
// Convert RGB/RGBA to hex for comparison
const colorToHex = (color: string): string | null => {
// If already hex format
if (color.startsWith('#')) {
return color.toUpperCase();
}
// If RGB/RGBA format
const result = color.match(/\d+/g);
if (!result || result.length < 3) return null;
const r = result[0];
const g = result[1];
const b = result[2];
if (!r || !g || !b) return null;
const rHex = parseInt(r).toString(16).padStart(2, '0');
const gHex = parseInt(g).toString(16).padStart(2, '0');
const bHex = parseInt(b).toString(16).padStart(2, '0');
return `#${rHex}${gHex}${bHex}`.toUpperCase();
};
// Check for background color (highlight)
const bgColor = style.backgroundColor;
// Check if background color is set and not transparent/default
if (bgColor &&
bgColor !== 'rgba(0, 0, 0, 0)' &&
bgColor !== 'transparent' &&
bgColor !== 'rgb(255, 255, 255)' &&
bgColor !== '#ffffff' &&
bgColor !== '#FFFFFF') {
if (bgColor &&
bgColor !== 'rgba(0, 0, 0, 0)' &&
bgColor !== 'transparent' &&
bgColor !== 'rgb(255, 255, 255)' &&
bgColor !== '#ffffff' &&
bgColor !== '#FFFFFF') {
formats.add('highlight');
const hexColor = colorToHex(bgColor);
if (hexColor) {
@ -320,15 +321,15 @@ export function RichTextEditor({
// Only reset if we haven't found a highlight yet
setCurrentHighlightColor(null);
}
// Check for text color
const textColor = style.color;
// Convert to hex for comparison
const hexTextColor = colorToHex(textColor);
// Check if text color is set and not default black
if (textColor && hexTextColor &&
textColor !== 'rgba(0, 0, 0, 0)' &&
hexTextColor !== '#000000') {
if (textColor && hexTextColor &&
textColor !== 'rgba(0, 0, 0, 0)' &&
hexTextColor !== '#000000') {
formats.add('textColor');
// Find matching color from our palette
const matchedColor = HIGHLIGHT_COLORS.find(c => {
@ -349,23 +350,23 @@ export function RichTextEditor({
setCurrentTextColor(null);
}
}
element = element.parentElement;
}
}
setActiveFormats(formats);
}, [isFocused]);
// Apply formatting command
const applyFormat = React.useCallback((command: string, value?: string) => {
if (!editorRef.current) return;
// Restore focus if needed
if (!isFocused) {
editorRef.current.focus();
}
// Save current selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
@ -373,15 +374,15 @@ export function RichTextEditor({
editorRef.current.focus();
return;
}
// Execute formatting command
document.execCommand(command, false, value);
// Update content
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
// Check active formats after a short delay
setTimeout(checkActiveFormats, 10);
}, [isFocused, onChange, checkActiveFormats]);
@ -389,12 +390,12 @@ export function RichTextEditor({
// Apply highlight color
const applyHighlight = React.useCallback((color: string) => {
if (!editorRef.current) return;
// Restore focus if needed
if (!isFocused) {
editorRef.current.focus();
}
// Save current selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
@ -402,26 +403,26 @@ export function RichTextEditor({
editorRef.current.focus();
return;
}
// Check if this color is already applied by checking the selection's style
let isAlreadyApplied = false;
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement;
} else {
element = commonAncestor as HTMLElement;
}
// Check if the selected element has the same background color
while (element && element !== editorRef.current) {
const style = window.getComputedStyle(element);
const bgColor = style.backgroundColor;
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
// Convert to hex and compare
const colorToHex = (c: string): string | null => {
if (c.startsWith('#')) return c.toUpperCase();
@ -445,7 +446,7 @@ export function RichTextEditor({
element = element.parentElement;
}
}
// Use backColor command for highlight (background color)
if (color === 'transparent' || isAlreadyApplied) {
// Remove highlight - use a more aggressive approach to fully remove
@ -453,10 +454,10 @@ export function RichTextEditor({
if (!range.collapsed) {
// Store the range before manipulation
const contents = range.extractContents();
// Create a new text node or span without background color
const fragment = document.createDocumentFragment();
// Process extracted contents to remove background colors
const processNode = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
@ -464,14 +465,14 @@ export function RichTextEditor({
} else if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const newEl = document.createElement(el.tagName.toLowerCase());
// Copy all attributes except style-related ones
Array.from(el.attributes).forEach(attr => {
if (attr.name !== 'style' && attr.name !== 'class') {
newEl.setAttribute(attr.name, attr.value);
}
});
// Process children and copy without background color
Array.from(el.childNodes).forEach(child => {
const processed = processNode(child);
@ -479,27 +480,27 @@ export function RichTextEditor({
newEl.appendChild(processed);
}
});
// Remove background color if present
if (el.style.backgroundColor) {
newEl.style.backgroundColor = '';
}
return newEl;
}
return null;
};
Array.from(contents.childNodes).forEach(child => {
const processed = processNode(child);
if (processed) {
fragment.appendChild(processed);
}
});
// Insert the cleaned fragment
range.insertNode(fragment);
// Also use execCommand to ensure removal
document.execCommand('removeFormat', false);
} else {
@ -522,21 +523,21 @@ export function RichTextEditor({
return;
}
}
// Clear selection immediately after applying to prevent "sticky" highlight mode
const sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
}
// Update content
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
// Close popover
setHighlightColorOpen(false);
// Refocus editor after a short delay and check formats
setTimeout(() => {
if (editorRef.current) {
@ -549,12 +550,12 @@ export function RichTextEditor({
// Apply text color
const applyTextColor = React.useCallback((color: string) => {
if (!editorRef.current) return;
// Restore focus if needed
if (!isFocused) {
editorRef.current.focus();
}
// Save current selection
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
@ -562,20 +563,20 @@ export function RichTextEditor({
editorRef.current.focus();
return;
}
// Check if this color is already applied by checking the selection's style
let isAlreadyApplied = false;
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const commonAncestor = range.commonAncestorContainer;
let element: HTMLElement | null = null;
if (commonAncestor.nodeType === Node.TEXT_NODE) {
element = commonAncestor.parentElement;
} else {
element = commonAncestor as HTMLElement;
}
// Check if the selected element has the same text color
while (element && element !== editorRef.current) {
const style = window.getComputedStyle(element);
@ -611,7 +612,7 @@ export function RichTextEditor({
element = element.parentElement;
}
}
// Use foreColor command for text color
if (color === 'transparent' || color === 'default' || isAlreadyApplied) {
// Remove text color by removing format or setting to default
@ -632,15 +633,15 @@ export function RichTextEditor({
setCustomTextColor(color);
}
}
// Update content
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
// Close popover
setTextColorOpen(false);
// Check active formats after a short delay
setTimeout(checkActiveFormats, 10);
}, [isFocused, onChange, checkActiveFormats]);
@ -648,7 +649,7 @@ export function RichTextEditor({
// Handle input changes
const handleInput = React.useCallback(() => {
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
checkActiveFormats();
}, [onChange, checkActiveFormats]);
@ -684,18 +685,18 @@ export function RichTextEditor({
const handleBlur = React.useCallback(() => {
setIsFocused(false);
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
}, [onChange]);
// Handle selection change to update active formats
React.useEffect(() => {
if (!isFocused) return;
const handleSelectionChange = () => {
checkActiveFormats();
};
document.addEventListener('selectionchange', handleSelectionChange);
return () => {
document.removeEventListener('selectionchange', handleSelectionChange);
@ -747,7 +748,7 @@ export function RichTextEditor({
>
<Underline className="h-4 w-4" />
</Button>
{/* Highlight Color Picker */}
<Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}>
<PopoverTrigger asChild>
@ -764,8 +765,8 @@ export function RichTextEditor({
<Highlighter className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2"
<PopoverContent
className="w-auto p-2"
align="start"
onPointerDownOutside={(e) => {
// Prevent closing when clicking inside popover
@ -790,7 +791,7 @@ export function RichTextEditor({
>
<X className="h-4 w-4 text-gray-500" />
</Button>
<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">
{HIGHLIGHT_COLORS.map((color) => {
@ -832,7 +833,7 @@ export function RichTextEditor({
);
})}
</div>
{/* Remove Highlight Button - Standard pattern */}
{currentHighlightColor && currentHighlightColor !== 'transparent' && (
<div className="mb-2">
@ -851,7 +852,7 @@ export function RichTextEditor({
</Button>
</div>
)}
{/* Custom Color Picker */}
<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>
@ -898,7 +899,7 @@ export function RichTextEditor({
// Get pasted text from clipboard
const pastedText = e.clipboardData.getData('text').trim();
e.preventDefault();
// Process after paste event completes
setTimeout(() => {
// Check if it's a valid hex color with #
@ -979,7 +980,7 @@ export function RichTextEditor({
</div>
</PopoverContent>
</Popover>
{/* Text Color Picker */}
<Popover open={textColorOpen} onOpenChange={setTextColorOpen}>
<PopoverTrigger asChild>
@ -996,8 +997,8 @@ export function RichTextEditor({
<Type className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-auto p-2"
<PopoverContent
className="w-auto p-2"
align="start"
onPointerDownOutside={(e) => {
const target = e.target as HTMLElement;
@ -1021,7 +1022,7 @@ export function RichTextEditor({
>
<X className="h-4 w-4 text-gray-500" />
</Button>
<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">
{/* Default/Black Color Option - First position (standard) */}
@ -1084,7 +1085,7 @@ export function RichTextEditor({
);
})}
</div>
{/* Remove Text Color Button - Standard pattern */}
{currentTextColor && currentTextColor !== '#000000' && (
<div className="mb-2">
@ -1103,7 +1104,7 @@ export function RichTextEditor({
</Button>
</div>
)}
{/* Custom Text Color Picker */}
<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>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
import { sanitizeHTML } from '@/utils/sanitizer';
import { toast } from 'sonner';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { formatDateTime } from '@/utils/dateFormatter';
@ -9,12 +8,12 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import {
Send,
Smile,
Paperclip,
FileText,
Download,
import {
Send,
Smile,
Paperclip,
FileText,
Download,
Clock,
Flag,
X,
@ -59,11 +58,9 @@ interface WorkNoteChatSimpleProps {
}
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(/\n/g, '<br />');
return sanitizeHTML(formattedContent);
};
const FileIcon = ({ type }: { type: string }) => {
@ -105,7 +102,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const filteredMessages = messages.filter(msg =>
const filteredMessages = messages.filter(msg =>
msg.content.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
useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded
let joinedId = requestId;
(async () => {
try {
@ -143,39 +140,39 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (details?.workflow?.requestId) {
joinedId = details.workflow.requestId;
}
} catch { }
} catch {}
try {
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
joinRequestRoom(s, joinedId, currentUserId || undefined);
const noteHandler = (payload: any) => {
const n = payload?.note || payload;
if (!n) return;
setMessages(prev => {
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
return prev;
}
const userName = n.userName || n.user_name || 'User';
const userRole = n.userRole || n.user_role;
const participantRole = formatParticipantRole(userRole);
const noteUserId = n.userId || n.user_id;
const newMsg = {
id: n.noteId || n.note_id || String(Date.now()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: n.message || '',
timestamp: n.createdAt || n.created_at || new Date().toISOString(),
isCurrentUser: noteUserId === currentUserId
} as any;
return [...prev, newMsg];
});
};
@ -192,7 +189,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
}
})();
return () => {
try { (window as any).__wn_cleanup?.(); } catch { }
try { (window as any).__wn_cleanup?.(); } catch {}
};
}, [requestId, currentUserId]);
@ -208,20 +205,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole);
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
};
}) : [];
setMessages(mapped as any);
} catch { }
} catch {}
}
setMessage('');
setSelectedFiles([]);
@ -237,21 +234,21 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id;
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || m.content || '',
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,
name: a.fileName || a.file_name || a.name,
name: 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',
fileType: a.fileType || a.file_type || a.type || 'file',
fileSize: a.fileSize || a.file_size
@ -260,24 +257,24 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} as any;
});
setMessages(mapped);
} catch { }
} catch {}
} else {
(async () => {
try {
const rows = await getWorkNotes(requestId);
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
const userName = m.userName || m.user_name || 'User';
const userRole = m.userRole || m.user_role;
const participantRole = formatParticipantRole(userRole);
const noteUserId = m.userId || m.user_id;
return {
id: m.noteId || m.note_id || m.id || String(Math.random()),
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
user: {
name: userName,
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
role: participantRole
},
content: m.message || '',
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
@ -339,7 +336,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
if (msg.id === messageId) {
const reactions = msg.reactions || [];
const existingReaction = reactions.find(r => r.emoji === emoji);
if (existingReaction) {
if (existingReaction.users.includes('You')) {
existingReaction.users = existingReaction.users.filter(u => u !== 'You');
@ -352,7 +349,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} else {
reactions.push({ emoji, users: ['You'] });
}
return { ...msg, reactions };
}
return msg;
@ -374,7 +371,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div>
</div>
</div>
{/* Search Bar */}
<div className="relative">
<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">
{filteredMessages.map((msg) => {
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
return (
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!msg.isSystem && !isCurrentUser && (
<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' :
msg.user.role === 'Approver' ? 'bg-blue-600' :
'bg-slate-600'
}`}>
<AvatarFallback className={`text-white font-semibold text-sm ${
msg.user.role === 'Initiator' ? 'bg-green-600' :
msg.user.role === 'Approver' ? 'bg-blue-600' :
'bg-slate-600'
}`}>
{msg.user.avatar}
</AvatarFallback>
</Avatar>
)}
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
{msg.isSystem ? (
<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 */}
<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"
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 fileType = attachment.fileType || attachment.file_type || attachment.type || '';
const attachmentId = attachment.attachmentId || attachment.attachment_id;
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 className="flex-shrink-0">
<FileIcon type={fileType} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate">
{fileName}
<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">
<FileIcon type={fileType} />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-700 truncate">
{fileName}
</p>
{fileSize && (
<p className="text-xs text-gray-500">
{formatFileSize(fileSize)}
</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>
)}
{/* 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) => {
</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();
if (!attachmentId) {
toast.error('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
toast.error('Failed to download file');
}
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
setPreviewFile({
fileName,
fileType,
fileUrl: previewUrl,
fileSize,
attachmentId
});
}}
title="Download file"
title="Preview file"
>
<Download className="w-4 h-4" />
<Eye className="w-4 h-4" />
</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>
@ -530,18 +528,19 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<button
key={index}
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')
? 'bg-blue-100 text-blue-800 border border-blue-200'
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${
reaction.users.includes('You')
? 'bg-blue-100 text-blue-800 border border-blue-200'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
}`}
>
<span>{reaction.emoji}</span>
<span className="text-xs font-medium">{reaction.users.length}</span>
</button>
))}
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 flex-shrink-0"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
>
@ -553,7 +552,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div>
)}
</div>
{!msg.isSystem && isCurrentUser && (
<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">
@ -649,27 +648,27 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div className="flex items-center justify-between gap-2">
{/* Left side - Action buttons */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={handleAttachmentClick}
title="Attach file"
>
<Paperclip className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
title="Add emoji"
>
<Smile className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
onClick={() => setMessage(prev => prev + '@')}
title="Mention someone"
@ -683,8 +682,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<span className="text-xs text-gray-500 whitespace-nowrap">
{message.length}/2000
</span>
<Button
onClick={handleSendMessage}
<Button
onClick={handleSendMessage}
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"
size="sm"
@ -696,7 +695,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
</div>
</div>
</div>
{/* File Preview Modal */}
{previewFile && (
<FilePreview

View File

@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
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 { ensureUserExists } from '@/services/userApi';
@ -15,8 +15,6 @@ interface ApprovalWorkflowStepProps {
formData: FormData;
updateFormData: (field: keyof FormData, value: any) => 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({
formData,
updateFormData,
onValidationError,
systemPolicy,
onPolicyViolation
onValidationError
}: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
@ -45,18 +41,18 @@ export function ApprovalWorkflowStep({
useEffect(() => {
const approverCount = formData.approverCount || 1;
const currentApprovers = formData.approvers || [];
// Ensure we have the correct number of approvers
if (currentApprovers.length < approverCount) {
const newApprovers = [...currentApprovers];
// Fill missing approver slots
for (let i = currentApprovers.length; i < approverCount; i++) {
if (!newApprovers[i]) {
newApprovers[i] = {
email: '',
name: '',
level: i + 1,
tat: '' as any
newApprovers[i] = {
email: '',
name: '',
level: i + 1,
tat: '' as any
};
}
}
@ -71,7 +67,7 @@ export function ApprovalWorkflowStep({
const newApprovers = [...formData.approvers];
const previousEmail = newApprovers[index]?.email;
const emailChanged = previousEmail !== value;
newApprovers[index] = {
...newApprovers[index],
email: value,
@ -94,8 +90,8 @@ export function ApprovalWorkflowStep({
try {
// Check for duplicates in other approver slots (excluding current index)
const isDuplicateApprover = formData.approvers?.some(
(approver: any, idx: number) =>
idx !== index &&
(approver: any, idx: number) =>
idx !== index &&
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
);
@ -196,9 +192,9 @@ export function ApprovalWorkflowStep({
<div data-testid="approval-workflow-count-field">
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
<div className="flex items-center gap-4">
<Button
<Button
type="button"
variant="outline"
variant="outline"
size="sm"
onClick={() => {
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">
{formData.approverCount || 1}
</span>
<Button
<Button
type="button"
variant="outline"
variant="outline"
size="sm"
onClick={() => {
const currentCount = formData.approverCount || 1;
const newCount = 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;
}
const newCount = Math.min(10, currentCount + 1);
updateFormData('approverCount', newCount);
}}
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
disabled={(formData.approverCount || 1) >= 10}
data-testid="approval-workflow-increase-count"
>
<Plus className="w-4 h-4" />
</Button>
</div>
<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>
</div>
</CardContent>
@ -282,13 +266,13 @@ export function ApprovalWorkflowStep({
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
const level = index + 1;
const isLast = level === (formData.approverCount || 1);
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
const approver = formData.approvers[index] || {
email: '',
name: '',
level: level,
tat: '' as any
const approver = formData.approvers[index] || {
email: '',
name: '',
level: level,
tat: '' as any
};
return (
@ -296,16 +280,18 @@ export function ApprovalWorkflowStep({
<div className="flex justify-center">
<div className="w-px h-6 bg-gray-300"></div>
</div>
<div className={`p-4 rounded-lg border-2 transition-all ${approver.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className={`p-4 rounded-lg border-2 transition-all ${
approver.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${approver.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
approver.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
<span className="text-white font-semibold">{level}</span>
</div>
<div className="flex-1">
@ -334,7 +320,7 @@ export function ApprovalWorkflowStep({
<Input
id={`approver-${level}`}
type="email"
placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`}
placeholder="approver@royalenfield.com"
value={approver.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
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
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({
fileName: file.name,
@ -111,16 +111,16 @@ export function DocumentsStep({
const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
} else {
const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
}
};
@ -160,7 +160,7 @@ export function DocumentsStep({
<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>
<p className="text-gray-600 mb-4">
click to browse
Drag and drop files here, or click to browse
</p>
<input
type="file"
@ -172,10 +172,10 @@ export function DocumentsStep({
ref={fileInputRef}
data-testid="documents-file-input"
/>
<Button
variant="outline"
size="lg"
type="button"
<Button
variant="outline"
size="lg"
type="button"
onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button"
>
@ -206,7 +206,7 @@ export function DocumentsStep({
const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null;
return (
<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">
@ -222,9 +222,9 @@ export function DocumentsStep({
</div>
<div className="flex items-center gap-2">
{canPreview(doc, true) && (
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`}
>
@ -276,9 +276,9 @@ export function DocumentsStep({
</div>
<div className="flex items-center gap-2">
{canPreview(file, false) && (
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(file, false)}
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
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 { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, Info, Flame, Target, TrendingUp } from 'lucide-react';
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
interface TemplateSelectionStepProps {
templates: RequestTemplate[];
selectedTemplate: RequestTemplate | null;
onSelectTemplate: (template: RequestTemplate) => void;
adminTemplates?: RequestTemplate[];
}
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({
templates,
selectedTemplate,
onSelectTemplate,
adminTemplates = []
onSelectTemplate
}: 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 (
<motion.div
initial={{ opacity: 0, y: 20 }}
@ -78,154 +47,100 @@ export function TemplateSelectionStep({
{/* Header Section */}
<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">
{viewMode === 'main' ? 'Choose Your Request Type' : 'Organization Templates'}
Choose Your Request Type
</h1>
<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.'
: 'Select a pre-configured workflow template defined by your organization.'}
Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.
</p>
</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 */}
<div
<div
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"
>
{displayTemplates.length === 0 && viewMode === 'admin' ? (
<div className="col-span-full text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
<FolderOpen className="w-12 h-12 mx-auto mb-3 text-gray-300" />
<p>No admin templates available yet.</p>
</div>
) : (
displayTemplates.map((template) => {
const isComingSoon = false;
const isDisabled = isComingSoon;
const isCategoryCard = template.id === 'admin-templates-category';
// const isCustomCard = template.id === 'custom';
const isSelected = selectedTemplate?.id === template.id;
return (
<motion.div
key={template.id}
whileHover={!isDisabled ? { scale: 1.03 } : {}}
whileTap={!isDisabled ? { scale: 0.98 } : {}}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
data-testid={`template-card-${template.id}`}
>
<Card
className={`h-full transition-all duration-300 border-2 ${isDisabled
? 'border-gray-200 bg-gray-50/50 opacity-85 cursor-not-allowed'
: 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'
{templates.map((template) => (
<motion.div
key={template.id}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
data-testid={`template-card-${template.id}`}
>
<Card
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
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'
}`}
onClick={() => onSelectTemplate(template)}
data-testid={`template-card-${template.id}-clickable`}
>
<CardHeader className="space-y-4 pb-4">
<div className="flex items-start justify-between">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center ${
selectedTemplate?.id === template.id
? 'bg-blue-100'
: 'bg-gray-100'
}`}
onClick={!isDisabled ? () => handleTemplateClick(template) : undefined}
data-testid={`template-card-${template.id}-clickable`}
>
<CardHeader className="space-y-4 pb-4">
<div className="flex items-start justify-between">
<div
className={`w-14 h-14 rounded-xl flex items-center justify-center ${isSelected
? 'bg-blue-100'
: isCategoryCard
? 'bg-blue-100'
: 'bg-gray-100'
}`}
data-testid={`template-card-${template.id}-icon`}
>
<template.icon
className={`w-7 h-7 ${isSelected
? '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`}
data-testid={`template-card-${template.id}-icon`}
>
<template.icon
className={`w-7 h-7 ${
selectedTemplate?.id === template.id
? 'text-blue-600'
: 'text-gray-600'
}`}
/>
</div>
{selectedTemplate?.id === template.id && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
data-testid={`template-card-${template.id}-selected-indicator`}
>
{template.description}
</p>
{!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 className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<Check className="w-5 h-5 text-white" />
</div>
)}
</CardContent>
</Card>
</motion.div>
);
})
)}
</motion.div>
)}
</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>
{/* Template Details Card */}
@ -250,7 +165,7 @@ export function TemplateSelectionStep({
<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">
<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 className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
<Label className="text-blue-900 font-semibold">Priority Level</Label>
@ -265,22 +180,18 @@ export function TemplateSelectionStep({
</div>
</div>
<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">
{selectedTemplate.commonApprovers?.length > 0 ? (
selectedTemplate.commonApprovers.map((approver, index) => (
<Badge
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
variant="outline"
className="border-blue-300 text-blue-700 bg-white"
data-testid={`template-details-approver-${index}`}
>
{approver}
</Badge>
))
) : (
<span className="text-sm text-gray-500 italic">No specific approvers defined</span>
)}
{selectedTemplate.commonApprovers.map((approver, index) => (
<Badge
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
variant="outline"
className="border-blue-300 text-blue-700 bg-white"
data-testid={`template-details-approver-${index}`}
>
{approver}
</Badge>
))}
</div>
</div>
</CardContent>

View File

@ -19,20 +19,17 @@ interface WizardStepperProps {
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
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 (
<div
<div
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"
>
<div className={`${containerMaxWidth} mx-auto`}>
<div className="max-w-6xl mx-auto">
{/* Mobile: Current step indicator only */}
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
<div className="flex items-center justify-between mb-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"
data-testid="wizard-stepper-mobile-current-step"
>
@ -54,11 +51,11 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
</div>
</div>
{/* Progress bar */}
<div
<div
className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden"
data-testid="wizard-stepper-mobile-progress-bar"
>
<div
<div
className="bg-green-600 h-full transition-all duration-300"
style={{ width: `${progressPercentage}%` }}
data-testid="wizard-stepper-mobile-progress-fill"
@ -68,16 +65,17 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
{/* Desktop: Full step indicator */}
<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) => (
<div key={index} className="flex items-center flex-1 last:flex-none" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 ${index + 1 < currentStep
? 'bg-green-500 text-white'
: index + 1 === currentStep
? 'bg-green-500 text-white ring-2 ring-green-500/30 ring-offset-1'
<div key={index} className="flex items-center" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
index + 1 < currentStep
? 'bg-green-600 text-white'
: index + 1 === currentStep
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-600'
}`}
}`}
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
>
{index + 1 < currentStep ? (
@ -87,24 +85,26 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
)}
</div>
{index < stepNames.length - 1 && (
<div
className={`flex-1 h-0.5 mx-2 ${index + 1 < currentStep ? 'bg-green-500' : 'bg-gray-200'
}`}
<div
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`}
/>
)}
</div>
))}
</div>
<div
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2 px-1"
<div
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2"
data-testid="wizard-stepper-desktop-labels"
>
{stepNames.map((step, index) => (
<span
key={index}
className={`${index + 1 === currentStep ? 'font-semibold text-green-600' : ''
}`}
<span
key={index}
className={`${
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
}`}
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
>
{step}

View File

@ -6,7 +6,7 @@ export interface DocumentData {
documentId: string;
name: string;
fileType: string;
size?: string;
size: string;
sizeBytes?: number;
uploadedBy?: string;
uploadedAt: string;
@ -48,9 +48,7 @@ export function DocumentCard({
{document.name}
</p>
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
{document.size && <span>{document.size} </span>}
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
{formatDateTime(document.uploadedAt)}
{document.size} Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
</p>
</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 { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
import { tanflowLogout } from '../services/tanflowAuth';
interface User {
userId?: string;
@ -73,15 +72,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__');
if (logoutFlag === 'true' || forceLogout === 'true') {
// Remove flags
sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__');
// Clear all tokens one more time (aggressive)
TokenManager.clearAll();
// Also manually clear everything
try {
localStorage.clear();
@ -89,81 +88,71 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} catch (e) {
console.error('Error clearing storage:', e);
}
// Set unauthenticated state
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
setError(null);
return;
}
// PRIORITY 2: Check if URL has logout parameter (from redirect)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
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();
// Don't clear sessionStorage completely - we might need logout flags
sessionStorage.clear();
setIsAuthenticated(false);
setUser(null);
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();
if (urlParams.has('okta_logged_out')) {
cleanParams.set('okta_logged_out', 'true');
}
if (urlParams.has('tanflow_logged_out')) {
cleanParams.set('tanflow_logged_out', 'true');
}
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
window.history.replaceState({}, document.title, newUrl);
return;
}
// 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
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
// The callback handler will set isAuthenticated after successful token exchange
return;
}
// PRIORITY 4: Check authentication status
const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData();
const hasAuthData = token || refreshToken || userData;
// 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';
// In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first
if (isProductionMode) {
// Prod: Verify session with server via httpOnly cookie
// Production: Verify session with server via httpOnly cookie
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
} else {
// Dev: If no auth data exists, user is not authenticated
// Development: If no auth data exists, user is not authenticated
if (!hasAuthData) {
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) {
checkAuthStatus();
@ -211,7 +200,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// Handle callback from OAuth redirect
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
const callbackProcessedRef = useRef(false);
useEffect(() => {
// Skip if already processed or not on callback page
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
@ -219,57 +208,24 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
}
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
callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const errorParam = urlParams.get('error');
// Clean URL immediately to prevent re-running on re-renders
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) {
setError(new Error(`Authentication error: ${errorParam}`));
setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return;
}
if (!code) {
setIsLoading(false);
// Clear provider flag
sessionStorage.removeItem('auth_provider');
return;
}
@ -277,21 +233,18 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(true);
setIsAuthenticated(false);
setError(null);
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request
// This is the frontend callback URL, NOT the backend URL
// Backend will use this same URI when exchanging code with Okta
const redirectUri = `${window.location.origin}/login/callback`;
const result = await exchangeCodeForTokens(code, redirectUri);
setUser(result.user);
setIsAuthenticated(true);
setError(null);
// Clear provider flag after successful authentication
sessionStorage.removeItem('auth_provider');
// Clean URL after success
window.history.replaceState({}, document.title, '/');
} catch (err: any) {
@ -299,8 +252,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setError(err);
setIsAuthenticated(false);
setUser(null);
// Clear provider flag on error
sessionStorage.removeItem('auth_provider');
// Reset ref on error so user can retry if needed
callbackProcessedRef.current = false;
} finally {
@ -317,17 +268,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
setIsLoading(false);
return;
}
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
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)
if (isProductionMode) {
const storedUser = TokenManager.getUserData();
// Try to get current user from server - this validates the httpOnly cookie
try {
const userData = await getCurrentUser();
@ -368,8 +319,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
}
return;
}
// Dev MODE: Check local token
// DEVELOPMENT MODE: Check local token
const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData();
@ -454,27 +405,24 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
try {
setError(null);
// 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 redirectUri = `${window.location.origin}/login/callback`;
const responseType = 'code';
const scope = 'openid profile email';
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
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?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=${responseType}&` +
`scope=${encodeURIComponent(scope)}&` +
`state=${state}`;
// Add prompt=login if coming from logout to force re-authentication
// This ensures Okta requires login even if a session still exists
if (isAfterLogout) {
@ -490,84 +438,50 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const logout = async () => {
try {
//: Get id_token from TokenManager before clearing anything
// Needed for both Okta and Tanflow logout endpoints
const idToken = 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
// CRITICAL: Get id_token from TokenManager before clearing anything
// Okta logout endpoint works better with id_token_hint to properly end the session
// Note: Currently not used but kept for future Okta integration
void TokenManager.getIdToken();
// Set logout flag to prevent auto-authentication after redirect
// This must be set BEFORE clearing storage so it survives
sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true');
setIsLoggingOut(true);
// Reset auth state FIRST to prevent any re-authentication
setIsAuthenticated(false);
setUser(null);
setError(null);
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
// 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
try {
await logoutApi();
console.log('🚪 Backend logout API called successfully');
} catch (err) {
console.error('🚪 Logout API error:', err);
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
// 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 forceLogout = sessionStorage.getItem('__force_logout__');
const storedAuthProvider = sessionStorage.getItem('auth_provider');
// 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)
// Use TokenManager.clearAll() but then restore logout flags
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 (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
await new Promise(resolve => setTimeout(resolve, 100));
// Handle provider-specific logout
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
}
// Redirect directly to login page with flags
// The okta_logged_out flag will trigger prompt=login in the login() function
// This forces re-authentication even if Okta session still exists
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 isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production mode, tokens are in httpOnly cookies
// We can't access them directly, but API calls will include them automatically
if (isProductionMode) {
@ -599,7 +513,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
if (isAuthenticated) {
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
}
// Try to refresh the session
try {
await refreshTokenSilently();
@ -608,8 +522,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return null;
}
}
// Dev mode: tokens in localStorage
// Development mode: tokens in localStorage
const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) {
return token;
@ -626,17 +540,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
const refreshTokenSilently = async (): Promise<void> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
const newToken = await refreshAccessToken();
// In production, refresh might not return token (it's in httpOnly cookie)
// but if the call succeeded, the session is valid
if (isProductionMode) {
// Session refreshed via cookies
return;
}
if (newToken) {
// Token refreshed successfully (development mode)
return;
@ -672,7 +586,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
return (
<Auth0Provider
domain="{{IDP_DOMAIN}}/oauth2/default/v1"
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
clientId="0oa2j8slwj5S4bG5k0h8"
authorizationParams={{
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)
export { CustomRequestDetail } from './pages/RequestDetail';
// Filters
export { StandardRequestsFilters } from './components/RequestsFilters';
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
// Re-export 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 { useModalManager } from '@/hooks/useModalManager';
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)
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
@ -114,24 +112,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = 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();
// Custom hooks
@ -197,37 +177,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached,
} = 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
useEffect(() => {
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 isClosed = request?.status === 'closed';
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// Fetch summary details if request is closed
useEffect(() => {
@ -513,9 +464,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
currentUserIsApprover={!!currentApprovalLevel}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/>
</TabsContent>
@ -573,8 +521,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/>
</TabsContent>
</div>
@ -664,8 +610,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
actionStatus={actionStatus}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal}
@ -684,19 +628,6 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
downloadDocument={downloadDocument}
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { FormattedDescription } from '@/components/common/FormattedDescription';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
@ -24,34 +23,34 @@ import {
Info,
FileText,
Users,
XCircle,
Loader2,
} from 'lucide-react';
import { format } from 'date-fns';
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 { 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 {
onBack?: () => 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 = [
'Claim Details',
'Approver Selection',
@ -61,86 +60,9 @@ const STEP_NAMES = [
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(1);
const [verifyingDealer, setVerifyingDealer] = useState(false);
const [dealerSearchResults, setDealerSearchResults] = useState<DealerInfo[]>([]);
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 [dealers, setDealers] = useState<DealerInfo[]>([]);
const [loadingDealers, setLoadingDealers] = useState(true);
const [formData, setFormData] = useState({
activityName: '',
activityType: '',
@ -163,68 +85,32 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
level: number;
tat?: number | string;
tatType?: 'hours' | 'days';
isAdditional?: boolean;
insertAfterLevel?: number;
stepName?: string;
originalStepLevel?: number;
}>
});
const totalSteps = STEP_NAMES.length;
// Handle dealer search input with debouncing
const handleDealerSearchInputChange = (value: string) => {
setDealerSearchInput(value);
// 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 () => {
// Fetch dealers from API on component mount
useEffect(() => {
const fetchDealers = async () => {
setLoadingDealers(true);
try {
const result = await searchExternalDealerByCode(value);
if (result) {
// Map external API response to DealerInfo structure
const mappedDealer: DealerInfo = {
dealerId: result.dealer || result.dealer_code || value,
dealerCode: result.dealer || result.dealer_code || value,
dealerName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
displayName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
email: result['dealer email'] || '',
phone: result['dealer phone'] || '',
city: result['re city'] || result.city || '',
state: result['re state code'] || result.state || '',
isLoggedIn: true, // We'll verify this in the next step
};
setDealerSearchResults([mappedDealer]);
} else {
setDealerSearchResults([]);
}
const fetchedDealers = await fetchDealersFromAPI();
setDealers(fetchedDealers);
} catch (error) {
console.error('Error searching external dealer:', error);
setDealerSearchResults([]);
toast.error('Failed to load dealer list.');
console.error('Error fetching dealers:', error);
} finally {
setDealerSearchLoading(false);
setLoadingDealers(false);
}
}, 300);
};
};
fetchDealers();
}, []);
const updateFormData = (field: string, value: any) => {
setFormData(prev => {
const updated = { ...prev, [field]: value };
// Validate period dates
if (field === 'periodStartDate') {
// 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 updated;
});
};
@ -249,20 +135,17 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
const isStepValid = () => {
switch (currentStep) {
case 1:
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
case 2:
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level
const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
);
const step3Approver = approvers.find((a: any) => a.level === 3);
// Step 8 is now a system step, no validation needed
return step3Approver?.email && step3Approver?.userId && step3Approver?.tat;
case 3:
@ -278,16 +161,13 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
// Show specific error messages for step 2 (approver selection)
if (currentStep === 2) {
const approvers = formData.approvers || [];
// Find step 3 approver by originalStepLevel first, then fallback to level
const step3Approver = approvers.find((a: any) =>
a.originalStepLevel === 3 || (!a.originalStepLevel && a.level === 3 && !a.isAdditional)
);
const step3Approver = approvers.find((a: any) => a.level === 3);
const missingSteps: string[] = [];
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
missingSteps.push('Department Lead Approval');
missingSteps.push('Step 3: Department Lead Approval');
}
if (missingSteps.length > 0) {
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
} else {
@ -308,161 +188,43 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
}
};
const handleDealerSelect = async (selectedDealer: DealerInfo) => {
// Verify dealer is logged in
setVerifyingDealer(true);
try {
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode);
if (!verifiedDealer.isLoggedIn) {
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 || '');
const handleDealerChange = async (dealerCode: string) => {
const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
if (selectedDealer) {
updateFormData('dealerCode', dealerCode);
updateFormData('dealerName', selectedDealer.dealerName);
updateFormData('dealerEmail', selectedDealer.email || '');
updateFormData('dealerPhone', selectedDealer.phone || '');
updateFormData('dealerAddress', ''); // Address not available in API response
// Clear search input and results
setDealerSearchInput(verifiedDealer.dealerName || verifiedDealer.displayName);
setDealerSearchResults([]);
toast.success(`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" verified and mapped to the System`);
} catch (error: any) {
const errorMessage = 'Dealer is not mapped to the system'
toast.error(errorMessage, { duration: 5000 });
// Clear the selection
setDealerSearchInput('');
setDealerSearchResults([]);
updateFormData('dealerCode', '');
updateFormData('dealerName', '');
updateFormData('dealerEmail', '');
updateFormData('dealerPhone', '');
updateFormData('dealerAddress', '');
} finally {
setVerifyingDealer(false);
// Try to fetch full dealer info from API
try {
const fullDealerInfo = await getDealerByCode(dealerCode);
if (fullDealerInfo) {
updateFormData('dealerEmail', fullDealerInfo.email || selectedDealer.email || '');
updateFormData('dealerPhone', fullDealerInfo.phone || selectedDealer.phone || '');
}
} catch (error) {
// Ignore error, use basic info from list
console.debug('Could not fetch full dealer info:', error);
}
}
};
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 = {
...formData,
templateType: 'claim-management',
submittedAt: new Date().toISOString(),
status: 'pending',
currentStep: 'initiator-review',
// Pass normalized approvers array to backend
approvers: finalApprovers
// Pass approvers array to backend
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
if (onSubmit) {
try {
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);
onSubmit(claimData);
}
};
@ -502,26 +264,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div>
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
<Select
value={formData.activityType}
onValueChange={(value) => updateFormData('activityType', value)}
disabled={loadingActivityTypes}
>
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
<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>
<SelectContent>
{activityTypes.length > 0 ? (
activityTypes.map((type) => (
<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>
)}
{CLAIM_TYPES.map((type) => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
@ -530,99 +280,38 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{/* Dealer Selection */}
<div>
<Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
<div className="mt-2">
<div className="relative">
<Input
placeholder="Type dealer code, name, or email to search..."
value={formData.dealerCode ? `${formData.dealerName} (${formData.dealerCode})` : dealerSearchInput}
onChange={(e) => {
if (formData.dealerCode) {
// If dealer is already selected, clear selection first
updateFormData('dealerCode', '');
updateFormData('dealerName', '');
updateFormData('dealerEmail', '');
updateFormData('dealerPhone', '');
updateFormData('dealerAddress', '');
setDealerSearchInput(e.target.value);
} else {
handleDealerSearchInputChange(e.target.value);
}
}}
onFocus={() => {
// When input is focused, show search results if input has value
if (dealerSearchInput && dealerSearchInput.length >= 2) {
handleDealerSearchInputChange(dealerSearchInput);
}
}}
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>
<Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="dealer-select">
<SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
{formData.dealerCode && (
<div className="flex items-center gap-2">
<span className="font-mono text-sm">{formData.dealerCode}</span>
<span className="text-gray-400"></span>
<span>{formData.dealerName}</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{dealers.length === 0 && !loadingDealers ? (
<div className="p-2 text-sm text-gray-500">No dealers available</div>
) : (
dealers.map((dealer) => (
<SelectItem key={dealer.userId} value={dealer.dealerCode}>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
<span className="text-gray-400"></span>
<span>{dealer.dealerName}</span>
</div>
</SelectItem>
))
)}
{/* Search suggestions dropdown */}
{(dealerSearchLoading || dealerSearchResults.length > 0) && !formData.dealerCode && (
<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>
</SelectContent>
</Select>
{formData.dealerCode && (
<div className="mt-2 space-y-1">
<p className="text-sm text-gray-600">
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
</p>
{formData.dealerEmail && (
<p className="text-xs text-gray-500">Email: {formData.dealerEmail}</p>
)}
</div>
<p className="text-sm text-gray-600 mt-2">
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
</p>
)}
</div>
@ -634,10 +323,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<PopoverTrigger asChild>
<Button
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" />
<span className="flex-1 text-left">{formData.activityDate ? format(formData.activityDate, 'd MMM yyyy') : 'Select date'}</span>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'Select date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -666,19 +355,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{/* Request Detail */}
<div>
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
<p className="text-sm text-gray-600 mb-3">
Explain what you need approval for, why it's needed, and any relevant background information.
<span className="block mt-1 text-xs text-blue-600">
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
</span>
</p>
<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"
<Textarea
id="requestDescription"
placeholder="Provide a detailed description of your claim requirement..."
value={formData.requestDescription}
onChange={(e) => updateFormData('requestDescription', e.target.value)}
className="mt-2 min-h-[120px]"
/>
<p className="text-xs text-gray-500 mt-1">
Include key details about the claim, objectives, and expected outcomes
</p>
</div>
{/* Period (Optional) */}
@ -694,10 +380,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<PopoverTrigger asChild>
<Button
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" />
<span className="flex-1 text-left">{formData.periodStartDate ? format(formData.periodStartDate, 'd MMM yyyy') : 'Start date'}</span>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Start date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -719,11 +405,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<PopoverTrigger asChild>
<Button
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}
>
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="flex-1 text-left">{formData.periodEndDate ? format(formData.periodEndDate, 'd MMM yyyy') : 'End date'}</span>
<CalendarIcon className="mr-2 h-4 w-4" />
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
@ -746,11 +432,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div className="mt-2">
{formData.periodStartDate && formData.periodEndDate ? (
<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 className="text-xs text-gray-500">
{formData.periodStartDate
{formData.periodStartDate
? 'Please select end date for the period'
: 'Please select start date first'}
</p>
@ -770,14 +456,12 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
currentUserEmail={(user as any)?.email || ''}
currentUserId={(user as any)?.userId || ''}
currentUserName={
(user as any)?.displayName ||
(user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName
(user as any)?.displayName ||
(user as any)?.name ||
((user as any)?.firstName && (user as any)?.lastName
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
: (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>
<CardContent className="pt-6 space-y-4">
<div className="space-y-3">
{(() => {
// Sort approvers by level and filter out system approvers
const sortedApprovers = [...(formData.approvers || [])]
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@'))
.sort((a: any, b: any) => a.level - b.level);
return sortedApprovers.map((approver: any) => {
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat;
// Find step name - handle additional approvers and shifted levels
let stepName = 'Unknown';
let stepLabel = '';
if (approver.isAdditional) {
// Additional approver - use stepName if available
stepName = approver.stepName || 'Additional Approver';
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`;
} else {
// Fixed step - find by originalStepLevel first, then fallback to level
const step = approver.originalStepLevel
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
: CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto);
stepName = step?.name || 'Unknown';
stepLabel = stepName;
}
return (
<div key={`${approver.level}-${approver.email}`} className={`p-3 rounded-lg border ${approver.isAdditional ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'
}`}>
<div 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>
{(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => {
const stepNames: Record<number, string> = {
1: 'Dealer Proposal Submission',
2: 'Requestor Evaluation',
3: 'Department Lead Approval',
4: 'Activity Creation',
5: 'Dealer Completion Documents',
6: 'Requestor Claim Approval',
7: 'E-Invoice Generation',
8: 'Credit Note Confirmation',
};
const tat = Number(approver.tat || 0);
const tatType = approver.tatType || 'hours';
const hours = tatType === 'days' ? tat * 24 : tat;
return (
<div key={approver.level} className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center justify-between">
<div>
<Label className="text-xs text-gray-600 uppercase tracking-wider">
Step {approver.level}: {stepNames[approver.level]}
</Label>
<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">
<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>
</CardContent>
</Card>
@ -975,10 +636,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<div>
<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">
<FormattedDescription
content={formData.requestDescription || ''}
className="text-sm"
/>
<p className="text-gray-900 whitespace-pre-wrap">{formData.requestDescription}</p>
</div>
</div>
</CardContent>
@ -1047,7 +705,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
<span className="hidden sm:inline">Back to Templates</span>
<span className="sm:hidden">Back</span>
</Button>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
<div>
<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" />
<div className="flex justify-between mt-2 px-1">
{STEP_NAMES.map((_name, index) => (
<span
key={index}
className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
<span
key={index}
className={`text-xs sm:text-sm ${
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
}`}
>
{index + 1}
</span>
@ -1099,10 +758,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{currentStep < totalSteps ? (
<Button
onClick={nextStep}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
!isStepValid()
? 'opacity-50 cursor-pointer hover:opacity-60'
: ''
}`}
>
Next
<ArrowRight className="w-4 h-4" />
@ -1110,37 +770,15 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
) : (
<Button
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"
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Submitting...
</>
) : (
<>
<Check className="w-4 h-4" />
Submit Claim Request
</>
)}
<Check className="w-4 h-4" />
Submit Claim Request
</Button>
)}
</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>
);
}

View File

@ -5,13 +5,14 @@
* 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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
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 { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
import { useAuth } from '@/contexts/AuthContext';
@ -30,87 +31,75 @@ interface IOBlockedDetails {
blockedDate: string;
blockedBy: string; // User who blocked
sapDocumentNumber: string;
status: 'blocked' | 'released' | 'failed' | 'pending';
ioRemark?: string; // IO remark
status: 'blocked' | 'released' | 'failed';
}
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
const { user } = useAuth();
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
const proposalDetails = apiRequest?.proposalDetails || {};
const claimDetails = apiRequest?.claimDetails || apiRequest || {};
// Calculate total base amount (needed for budget verification as requested)
// This is the taxable amount excluding GST
const totalBaseAmount = useMemo(() => {
const costBreakupRaw = proposalDetails?.costBreakup || claimDetails?.costBreakup || [];
const costBreakup = Array.isArray(costBreakupRaw)
? costBreakupRaw
: (typeof costBreakupRaw === 'string'
? JSON.parse(costBreakupRaw)
: []);
if (!Array.isArray(costBreakup) || costBreakup.length === 0) {
return Number(claimDetails?.totalProposedTaxableAmount || proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
}
return costBreakup.reduce((sum: number, item: any) => {
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
return sum + (Number(amount) * Number(quantity));
}, 0);
}, [proposalDetails?.costBreakup, claimDetails?.costBreakup, claimDetails?.totalProposedTaxableAmount, proposalDetails?.totalEstimatedBudget]);
// Use base amount as the target budget for blocking
const estimatedBudget = totalBaseAmount;
// Budget status for signaling (Scenario 2)
// Use apiRequest as the primary source of truth, fall back to request
const budgetTracking = apiRequest?.budgetTracking || request?.budgetTracking || {};
const budgetStatus = budgetTracking?.budgetStatus || budgetTracking?.budget_status || '';
const internalOrdersList = apiRequest?.internalOrders || apiRequest?.internal_orders || request?.internalOrders || [];
const isAdditionalBlockingNeeded = budgetStatus === 'PROPOSED' && internalOrdersList.length > 0;
const [ioNumber, setIoNumber] = useState('');
const organizer = internalOrder?.organizer || null;
const [ioNumber, setIoNumber] = useState(existingIONumber);
const [ioRemark, setIoRemark] = useState(existingIORemark);
const [fetchingAmount, setFetchingAmount] = useState(false);
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
const [amountToBlock, setAmountToBlock] = useState<string>('');
const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
const [blockingBudget, setBlockingBudget] = useState(false);
const maxIoRemarkChars = 300;
const ioRemarkChars = ioRemark.length;
// Load existing IO blocks
// Load existing IO block details from apiRequest
useEffect(() => {
if (internalOrdersList.length > 0) {
const formattedIOs = internalOrdersList.map((io: any) => {
const org = io.organizer || null;
const blockedByName = org?.displayName ||
org?.display_name ||
org?.name ||
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) ||
org?.email ||
'Unknown User';
return {
ioNumber: io.ioNumber || io.io_number,
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
if (internalOrder && existingIONumber) {
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
// Get blocked by user name from organizer association (who blocked the amount)
// When amount is blocked, organizedBy stores the user who blocked it
const blockedByName = organizer?.displayName ||
organizer?.display_name ||
organizer?.name ||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
organizer?.email ||
'Unknown User';
// Set IO number and remark from existing data
setIoNumber(existingIONumber);
setIoRemark(existingIORemark);
// 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,
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
status: (io.status === 'BLOCKED' ? 'blocked' :
io.status === 'RELEASED' ? 'released' :
io.status === 'PENDING' ? 'pending' : 'blocked') as any,
};
});
setBlockedIOs(formattedIOs);
// If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
sapDocumentNumber: sapDocNumber,
ioRemark: existingIORemark,
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
});
// Set fetched amount if available balance exists
if (availableBeforeBlock > 0) {
setFetchedAmount(availableBeforeBlock);
}
}
}
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
/**
* Fetch available budget from SAP
@ -132,25 +121,11 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
try {
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
const ioData = await validateIO(requestId, ioNumber.trim());
if (ioData.isValid && ioData.availableBalance > 0) {
setFetchedAmount(ioData.availableBalance);
// Calculate total already blocked amount
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)));
}
// Pre-fill amount to block with available balance
setAmountToBlock(String(ioData.availableBalance));
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
} else {
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
* 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 () => {
if (!ioNumber.trim() || fetchedAmount === null) {
@ -186,97 +194,83 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
}
const blockAmountRaw = parseFloat(amountToBlock);
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
toast.error('Please enter a valid amount to block');
return;
}
// Round to exactly 2 decimal places to avoid floating point precision issues
// Use parseFloat with toFixed to ensure exact 2 decimal precision
const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
// Round to 2 decimal places to avoid floating point precision issues
// This ensures we send clean values like 240.00 instead of 239.9999999
const blockAmount = Math.round(blockAmountRaw * 100) / 100;
if (blockAmount > fetchedAmount) {
toast.error('Amount to block exceeds available IO budget');
return;
}
// Calculate total already blocked
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
const totalPlanned = totalAlreadyBlocked + blockAmount;
// Validate that total planned must exactly match estimated budget
if (estimatedBudget > 0) {
const roundedEstimatedBudget = parseFloat(estimatedBudget.toFixed(2));
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
// Log the amount being sent to backend for debugging
console.log('[IOTab] Blocking budget:', {
ioNumber: ioNumber.trim(),
originalInput: amountToBlock,
parsedAmount: blockAmountRaw,
roundedAmount: blockAmount,
fetchedAmount,
calculatedRemaining: fetchedAmount - blockAmount,
});
setBlockingBudget(true);
try {
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
// 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
// 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 = {
ioNumber: ioNumber.trim(),
ioAvailableBalance: roundedFetchedAmount,
ioRemark: ioRemark.trim(),
ioAvailableBalance: fetchedAmount,
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);
// Fetch updated claim details to get the blocked IO data
const claimData = await getClaimDetails(requestId);
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
// Calculate expected remaining balance for validation/debugging
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
// Blocking result processed
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount));
// Log what was saved vs what we sent
console.log('[IOTab] Blocking result:', {
sentAmount: blockAmount,
savedBlockedAmount,
sentRemaining: fetchedAmount - blockAmount,
savedRemainingBalance,
availableBalance: fetchedAmount,
difference: savedBlockedAmount - blockAmount,
});
// Warn if the saved amount differs from what we sent
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
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;
// 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
const blockedByName = currentUser?.displayName ||
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
const blockedByName = currentUser?.displayName ||
currentUser?.display_name ||
currentUser?.name ||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
currentUser?.email ||
'Current User';
const savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
const blocked: IOBlockedDetails = {
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
blockedAmount: savedBlockedAmount,
@ -285,14 +279,14 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
blockedBy: blockedByName,
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
ioRemark: savedIoRemark,
status: 'blocked',
};
setBlockedIOs(prev => [...prev, blocked]);
setBlockedDetails(blocked);
setAmountToBlock(''); // Clear the input
setFetchedAmount(null); // Reset fetched state
toast.success('IO budget blocked successfully in SAP');
// Refresh request details
onRefresh?.();
} else {
@ -331,12 +325,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
placeholder="Enter IO number (e.g., IO-2024-12345)"
value={ioNumber}
onChange={(e) => setIoNumber(e.target.value)}
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
disabled={fetchingAmount || !!blockedDetails}
className="flex-1"
/>
<Button
onClick={handleFetchAmount}
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Download className="w-4 h-4 mr-2" />
@ -345,17 +339,44 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</div>
</div>
{/* Instructions when IO number is entered but not fetched */}
{!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm text-blue-800">
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
</p>
{/* IO Remark Input */}
<div className="space-y-2">
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900">
IO Remark
</Label>
<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>
{/* 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 */}
{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="flex items-center justify-between">
@ -389,25 +410,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
className="pl-8"
/>
</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>
{/* Block Button */}
<Button
onClick={handleBlockBudget}
disabled={
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)
}
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
>
<Target className="w-4 h-4 mr-2" />
@ -430,57 +438,77 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
</CardDescription>
</CardHeader>
<CardContent>
{blockedIOs.length > 0 ? (
<div className="space-y-6">
{isAdditionalBlockingNeeded && (
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
<div className="flex items-start gap-3">
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</p>
<p className="text-sm text-amber-700 mt-1">Actual expenses exceed the previously blocked amount. Please block an additional {(estimatedBudget - blockedIOs.reduce((s, i) => s + i.blockedAmount, 0)).toLocaleString('en-IN', { minimumFractionDigits: 2 })}.</p>
</div>
{blockedDetails ? (
<div className="space-y-4">
{/* Success Banner */}
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
<div className="flex items-start gap-3">
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
<div>
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
</div>
</div>
)}
</div>
{blockedIOs.slice().reverse().map((io, idx) => (
<div key={idx} className="border rounded-lg overflow-hidden">
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
<span className="font-semibold text-sm">IO: {io.ioNumber}</span>
<Badge className={
io.status === 'blocked' ? 'bg-green-100 text-green-800' :
io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
'bg-blue-100 text-blue-800'
}>
{io.status === 'blocked' ? 'Blocked' :
io.status === 'pending' ? 'Provisioned' : 'Released'}
</Badge>
</div>
<div className="grid grid-cols-2 divide-x divide-y">
<div className="p-3">
<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>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">SAP Doc</p>
<p className="text-sm font-medium">{io.sapDocumentNumber || 'N/A'}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Blocked By</p>
<p className="text-xs">{io.blockedBy}</p>
</div>
<div className="p-3">
<p className="text-[10px] text-gray-500 uppercase">Date</p>
<p className="text-[10px]">{new Date(io.blockedDate).toLocaleString()}</p>
</div>
</div>
{/* Blocked Details */}
<div className="border rounded-lg divide-y">
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
</div>
<div className="p-4">
<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>
</div>
{blockedDetails.ioRemark && (
<div className="p-4">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Remark</p>
<p className="text-sm font-medium text-gray-900 whitespace-pre-wrap">{blockedDetails.ioRemark}</p>
</div>
)}
<div className="p-4 bg-green-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Blocked Amount</p>
<p className="text-xl font-bold text-green-700">
{blockedDetails.blockedAmount.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">Available Amount (Before Block)</p>
<p className="text-sm font-medium text-gray-900">
{blockedDetails.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
</div>
<div className="p-4 bg-blue-50">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Remaining Amount (After Block)</p>
<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 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>
) : (

View File

@ -41,9 +41,6 @@ interface ClaimManagementOverviewTabProps {
aiGenerated?: boolean;
handleGenerateConclusion?: () => void;
handleFinalizeConclusion?: () => void;
generationAttempts?: number;
generationFailed?: boolean;
maxAttemptsReached?: boolean;
}
export function ClaimManagementOverviewTab({
@ -61,9 +58,6 @@ export function ClaimManagementOverviewTab({
aiGenerated = false,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts = 0,
generationFailed = false,
maxAttemptsReached = false,
}: ClaimManagementOverviewTabProps) {
// Check if this is a claim management request
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
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
@ -100,7 +103,13 @@ export function ClaimManagementOverviewTab({
// Get visibility settings based on role
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
// 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,
};
// 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 (
<div className={`space-y-6 ${className}`}>
@ -188,24 +210,17 @@ export function ClaimManagementOverviewTab({
</CardDescription>
</div>
{handleGenerateConclusion && (
<div className="flex flex-col items-end gap-1.5">
<Button
variant="outline"
size="sm"
onClick={handleGenerateConclusion}
disabled={conclusionLoading || maxAttemptsReached}
className="gap-2 shrink-0 h-9"
data-testid="generate-ai-conclusion-button"
>
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining
</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleGenerateConclusion}
disabled={conclusionLoading}
className="gap-2 shrink-0"
data-testid="generate-ai-conclusion-button"
>
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
)}
</div>
</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 { format } from 'date-fns';
import { formatDateTime } from '@/utils/dateFormatter';
import { FormattedDescription } from '@/components/common/FormattedDescription';
interface ActivityInformationCardProps {
activityInfo: ClaimActivityInfo;
@ -18,11 +17,11 @@ interface ActivityInformationCardProps {
updatedAt?: string | Date;
}
export function ActivityInformationCard({
activityInfo,
export function ActivityInformationCard({
activityInfo,
className,
createdAt,
updatedAt
updatedAt
}: ActivityInformationCardProps) {
// Defensive check: Ensure activityInfo exists
if (!activityInfo) {
@ -109,7 +108,7 @@ export function ActivityInformationCard({
</label>
<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" />
{activityInfo.estimatedBudget !== undefined && activityInfo.estimatedBudget !== null
{activityInfo.estimatedBudget
? formatCurrency(activityInfo.estimatedBudget)
: 'TBD'}
</p>
@ -123,11 +122,7 @@ export function ActivityInformationCard({
</label>
<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" />
{formatCurrency(
activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0
? activityInfo.closedExpensesBreakdown.reduce((sum, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
: activityInfo.closedExpenses
)}
{formatCurrency(activityInfo.closedExpenses)}
</p>
</div>
)}
@ -151,40 +146,23 @@ export function ActivityInformationCard({
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
Closed Expenses Breakdown
</label>
<div className="bg-blue-50 border border-blue-200 rounded-lg overflow-hidden">
<table className="w-full text-xs sm:text-sm">
<thead className="bg-blue-100/50">
<tr>
<th className="px-3 py-2 text-left font-semibold text-blue-900">Description</th>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">Base</th>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">GST</th>
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-28">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-blue-200/50">
{activityInfo.closedExpensesBreakdown.map((item: any, index: number) => (
<tr key={index} className="hover:bg-blue-100/30">
<td className="px-3 py-2 text-gray-700">
{item.description}
{item.gstRate ? <span className="text-[10px] text-gray-400 block">{item.gstRate}% GST</span> : null}
</td>
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.amount)}</td>
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.gstAmt || 0)}</td>
<td className="px-3 py-2 text-right font-medium text-gray-900">
{formatCurrency(item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0)))}
</td>
</tr>
))}
<tr className="bg-blue-100/50 font-bold">
<td colSpan={3} className="px-3 py-2 text-blue-900">Final Claim Amount</td>
<td className="px-3 py-2 text-right text-blue-700">
{formatCurrency(
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
)}
</td>
</tr>
</tbody>
</table>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
<div key={index} className="flex justify-between items-center text-sm">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
<span className="font-semibold text-gray-900">Total</span>
<span className="font-bold text-blue-600">
{formatCurrency(
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
)}
</span>
</div>
</div>
</div>
)}
@ -195,12 +173,9 @@ export function ActivityInformationCard({
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Description
</label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
<FormattedDescription
content={activityInfo.description || ''}
className="text-sm"
/>
</div>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{activityInfo.description}
</p>
</div>
)}

View File

@ -1,6 +1,6 @@
/**
* 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
*/
@ -26,11 +26,6 @@ interface DMSDetails {
remarks?: string;
createdByName?: string;
createdAt?: string;
// PWC fields
irn?: string;
ackNo?: string;
ackDate?: string;
signedInvoiceUrl?: string;
}
interface ClaimAmountDetails {
@ -42,8 +37,6 @@ interface ClaimAmountDetails {
interface CostBreakdownItem {
description: string;
amount: number;
gstAmt?: number;
totalAmt?: number;
}
interface RoleBasedVisibility {
@ -92,7 +85,7 @@ export function ProcessDetailsCard({
const calculateTotal = (items?: CostBreakdownItem[]) => {
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
@ -127,7 +120,7 @@ export function ProcessDetailsCard({
</Label>
</div>
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
{ioDetails.remarks && (
<div className="pt-2 border-t border-blue-100">
<p className="text-xs text-gray-600 mb-1">Remark:</p>
@ -172,57 +165,27 @@ export function ProcessDetailsCard({
</div>
)}
{/* E-Invoice Details */}
{/* DMS Details */}
{visibility.showDMSDetails && dmsDetails && (
<div className="bg-white rounded-lg p-3 border border-purple-200">
<div className="flex items-center gap-2 mb-2">
<Activity className="w-4 h-4 text-purple-600" />
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
E-Invoice Details
DMS Number
</Label>
</div>
<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>
)}
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
{dmsDetails.remarks && (
<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>
</div>
)}
<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-[10px] text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
</div>
</div>
)}
@ -278,10 +241,10 @@ export function ProcessDetailsCard({
</div>
<div className="space-y-1.5 pt-1">
{estimatedBudgetBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
<span className="font-medium text-gray-900 whitespace-nowrap">
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}
@ -306,10 +269,10 @@ export function ProcessDetailsCard({
</div>
<div className="space-y-1.5 pt-1">
{closedExpensesBreakdown.map((item, index) => (
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
<span className="font-medium text-gray-900 whitespace-nowrap">
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
<div key={index} className="flex justify-between items-center text-xs">
<span className="text-gray-700">{item.description}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.amount)}
</span>
</div>
))}

View File

@ -11,19 +11,11 @@ import { format } from 'date-fns';
interface ProposalCostItem {
description: string;
amount?: number | null;
gstRate?: number;
gstAmt?: number;
cgstAmt?: number;
sgstAmt?: number;
igstAmt?: number;
quantity?: number;
totalAmt?: number;
}
interface ProposalDetails {
costBreakup: ProposalCostItem[];
estimatedBudgetTotal?: number | null;
totalEstimatedBudget?: number | null;
timelineForClosure?: string | null;
dealerComments?: string | null;
submittedOn?: string | null;
@ -37,22 +29,19 @@ interface ProposalDetailsCardProps {
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
// Calculate estimated total from costBreakup if not provided
const calculateEstimatedTotal = () => {
const total = proposalDetails.totalEstimatedBudget ?? proposalDetails.estimatedBudgetTotal;
if (total !== undefined && total !== null) {
return total;
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
return proposalDetails.estimatedBudgetTotal;
}
// Calculate sum from costBreakup items
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
const total = proposalDetails.costBreakup.reduce((sum, item) => {
const amount = item.amount || 0;
const gst = item.gstAmt || 0;
const lineTotal = item.totalAmt || (Number(amount) + Number(gst));
return sum + (Number.isNaN(lineTotal) ? 0 : lineTotal);
return sum + (Number.isNaN(amount) ? 0 : amount);
}, 0);
return total;
}
return 0;
};
@ -110,13 +99,7 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
Item Description
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
Base Amount
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
GST
</th>
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
Total
Amount
</th>
</tr>
</thead>
@ -124,27 +107,16 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
{(proposalDetails.costBreakup || []).map((item, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm text-gray-900">
<div>{item.description}</div>
{item.gstRate ? (
<div className="text-[10px] text-gray-400">
{item.cgstAmt ? `CGST: ${item.gstRate / 2}%, SGST: ${item.gstRate / 2}%` : `IGST: ${item.gstRate}%`}
</div>
) : null}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">
{formatCurrency(item.amount)}
</td>
<td className="px-4 py-3 text-sm text-gray-900 text-right">
{formatCurrency(item.gstAmt)}
{item.description}
</td>
<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>
</tr>
))}
<tr className="bg-green-50 font-semibold">
<td colSpan={3} className="px-4 py-3 text-sm text-gray-900">
Estimated Budget (Total Inclusive of GST)
<td className="px-4 py-3 text-sm text-gray-900">
Estimated Budget (Total)
</td>
<td className="px-4 py-3 text-sm text-green-700 text-right">
{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;
requestId?: string;
dueDate?: string;
taxationType?: string | null;
}
export function CreditNoteSAPModal({
@ -54,16 +53,13 @@ export function CreditNoteSAPModal({
requestNumber,
requestId: _requestId,
dueDate,
taxationType,
}: CreditNoteSAPModalProps) {
const [downloading, setDownloading] = useState(false);
const [sending, setSending] = useState(false);
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
const creditNoteDate = creditNoteData?.creditNoteDate
const creditNoteDate = creditNoteData?.creditNoteDate
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
: '';
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
@ -73,7 +69,7 @@ export function CreditNoteSAPModal({
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
const activity = activityName || 'Activity';
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
const dueDateDisplay = dueDate
const dueDateDisplay = dueDate
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
: 'Jan 4, 2026';
@ -120,18 +116,11 @@ export function CreditNoteSAPModal({
return (
<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>
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap">
<div className="flex items-center gap-2">
<Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP
</div>
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
<Receipt className="w-6 h-6 text-[--re-green]" />
Credit Note from SAP
</DialogTitle>
<DialogDescription className="text-base">
Review and send credit note to dealer
@ -150,7 +139,7 @@ export function CreditNoteSAPModal({
</div>
<Badge className="bg-green-600 text-white px-4 py-2 text-base">
<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>
</div>
<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 { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
import { toast } from 'sonner';
import './DeptLeadIOApprovalModal.css';
interface DeptLeadIOApprovalModalProps {
isOpen: boolean;
onClose: () => void;
onApprove: (data: {
ioNumber: string;
ioRemark: string;
comments: string;
}) => Promise<void>;
onReject: (comments: string) => Promise<void>;
@ -35,9 +35,9 @@ interface DeptLeadIOApprovalModalProps {
requestId?: string;
// Pre-filled IO data from IO table
preFilledIONumber?: string;
preFilledIORemark?: string;
preFilledBlockedAmount?: number;
preFilledRemainingBalance?: number;
taxationType?: string | null;
}
export function DeptLeadIOApprovalModal({
@ -48,30 +48,31 @@ export function DeptLeadIOApprovalModal({
requestTitle,
requestId: _requestId,
preFilledIONumber,
preFilledIORemark,
preFilledBlockedAmount,
preFilledRemainingBalance,
taxationType,
}: DeptLeadIOApprovalModalProps) {
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
const [ioRemark, setIoRemark] = useState('');
const [comments, setComments] = useState('');
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)
const ioNumber = preFilledIONumber || '';
// Reset form when modal opens/closes
React.useEffect(() => {
if (isOpen) {
// Prefill IO remark from props if available
setIoRemark(preFilledIORemark || '');
setComments('');
setActionType('approve');
}
}, [isOpen]);
}, [isOpen, preFilledIORemark]);
const ioRemarkChars = ioRemark.length;
const commentsChars = comments.length;
const maxIoRemarkChars = 300;
const maxCommentsChars = 500;
// Validate form
@ -79,12 +80,13 @@ export function DeptLeadIOApprovalModal({
if (actionType === 'reject') {
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 (
ioNumber.trim().length > 0 && // IO number must exist from IO table
ioRemark.trim().length > 0 &&
comments.trim().length > 0
);
}, [actionType, ioNumber, comments]);
}, [actionType, ioNumber, ioRemark, comments]);
const handleSubmit = async () => {
if (!isFormValid) {
@ -93,6 +95,10 @@ export function DeptLeadIOApprovalModal({
toast.error('IO number is required. Please block amount from IO tab first.');
return;
}
if (!ioRemark.trim()) {
toast.error('Please enter IO remark');
return;
}
}
if (!comments.trim()) {
toast.error('Please provide comments');
@ -103,16 +109,17 @@ export function DeptLeadIOApprovalModal({
try {
setSubmitting(true);
if (actionType === 'approve') {
await onApprove({
ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
comments: comments.trim(),
});
} else {
await onReject(comments.trim());
}
handleReset();
onClose();
} catch (error) {
@ -125,6 +132,7 @@ export function DeptLeadIOApprovalModal({
const handleReset = () => {
setActionType('approve');
setIoRemark('');
setComments('');
};
@ -137,40 +145,35 @@ export function DeptLeadIOApprovalModal({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="dept-lead-io-modal overflow-hidden flex flex-col">
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-3">
<div className="flex items-center gap-2 lg:gap-3 mb-2">
<div className="p-1.5 lg:p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-green-100">
<CircleCheckBig className="w-6 h-6 text-green-600" />
</div>
<div className="flex-1">
<DialogTitle className="font-semibold text-lg lg:text-xl flex items-center gap-2 flex-wrap">
Review and Approve
{taxationType && (
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
</Badge>
)}
<DialogTitle className="font-semibold text-xl">
Approve and Organise IO
</DialogTitle>
<DialogDescription className="text-xs lg:text-sm mt-1">
<DialogDescription className="text-sm mt-1">
Review IO details and provide your approval comments
</DialogDescription>
</div>
</div>
{/* 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">
<span className="font-medium text-sm lg:text-base text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono text-xs">Step 3</Badge>
<span className="font-medium text-gray-900">Workflow Step:</span>
<Badge variant="outline" className="font-mono">Step 3</Badge>
</div>
<div>
<span className="font-medium text-sm lg:text-base text-gray-900">Title:</span>
<p className="text-xs lg:text-sm text-gray-700 mt-1">{requestTitle || '—'}</p>
<span className="font-medium text-gray-900">Title:</span>
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
</div>
<div className="flex items-center gap-2">
<span className="font-medium text-sm lg:text-base text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200 text-xs">
<span className="font-medium text-gray-900">Action:</span>
<Badge className="bg-green-100 text-green-800 border-green-200">
<CircleCheckBig className="w-3 h-3 mr-1" />
APPROVE
</Badge>
@ -178,150 +181,176 @@ export function DeptLeadIOApprovalModal({
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
<div className="space-y-3 lg:space-y-4">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 text-sm lg:text-base ${actionType === 'approve'
<div className="space-y-3">
{/* Action Toggle Buttons */}
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
<Button
type="button"
onClick={() => setActionType('approve')}
className={`flex-1 ${
actionType === 'approve'
? 'bg-green-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
Approve
</Button>
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 text-sm lg:text-base ${actionType === 'reject'
}`}
variant={actionType === 'approve' ? 'default' : 'ghost'}
>
<CircleCheckBig className="w-4 h-4 mr-1" />
Approve
</Button>
<Button
type="button"
onClick={() => setActionType('reject')}
className={`flex-1 ${
actionType === 'reject'
? 'bg-red-600 text-white shadow-sm'
: 'text-gray-700 hover:bg-gray-200'
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
Reject
</Button>
</div>
}`}
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
>
<CircleX className="w-4 h-4 mr-1" />
Reject
</Button>
</div>
{/* Main Content Area - Two Column Layout on Large Screens */}
<div className="space-y-3 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
{/* Left Column - IO Organisation Details (Only shown when approving) */}
{actionType === 'approve' && (
<div className="p-3 lg:p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
<div className="flex items-center gap-2">
<Receipt className="w-4 h-4 lg:w-5 lg:h-5 text-blue-600" />
<h4 className="font-semibold text-sm lg:text-base text-blue-900">IO Organisation Details</h4>
{/* IO Organisation Details - Only shown when approving */}
{actionType === 'approve' && (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
<div className="flex items-center gap-2">
<Receipt className="w-4 h-4 text-blue-600" />
<h4 className="font-semibold text-blue-900">IO Organisation Details</h4>
</div>
{/* 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>
)}
{/* IO Number - Read-only from IO table */}
<div className="space-y-1">
<Label htmlFor="ioNumber" className="text-xs lg: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 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>
)}
{/* 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-sm font-bold text-blue-700 mt-1">
{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
</div>
)}
</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-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>
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
<div className="space-y-1">
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
IO Remark <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}
id="ioRemark"
placeholder="Enter remarks about IO organization"
value={ioRemark}
onChange={(e) => {
const value = e.target.value;
if (value.length <= maxCommentsChars) {
setComments(value);
if (value.length <= maxIoRemarkChars) {
setIoRemark(value);
}
}}
rows={4}
className="text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none"
rows={3}
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 gap-1">
<TriangleAlert className="w-3 h-3" />
Required and visible to all
</div>
<span>{commentsChars}/{maxCommentsChars}</span>
<div className="flex items-center justify-between text-xs">
{preFilledIORemark && (
<span className="text-blue-600">
Prefilled from IO tab (editable)
</span>
)}
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
</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>
<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
variant="outline"
onClick={handleClose}
disabled={submitting}
className="text-sm lg:text-base"
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || submitting}
className={`text-sm lg:text-base ${actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
} text-white`}
className={`${
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: 'bg-red-600 hover:bg-red-700'
} text-white`}
>
{submitting ? (
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`

View File

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

View File

@ -32,7 +32,7 @@ export function EmailNotificationTemplateModal({
stepNumber,
stepName,
requestNumber = 'RE-REQ-2024-CM-101',
recipientEmail = `system@${import.meta.env.VITE_EMAIL_DOMAIN}`,
recipientEmail = 'system@royalenfield.com',
subject,
emailBody,
}: EmailNotificationTemplateModalProps) {
@ -53,7 +53,7 @@ This is an automated message.`;
return (
<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>
<div className="flex items-center justify-between">
<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/
*/
export { AdditionalApproverReviewModal } from './AdditionalApproverReviewModal';
export { CreditNoteSAPModal } from './CreditNoteSAPModal';
export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal';
export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal';
@ -13,6 +12,4 @@ export { DeptLeadIOApprovalModal } from './DeptLeadIOApprovalModal';
export { DMSPushModal } from './DMSPushModal';
export { EditClaimAmountModal } from './EditClaimAmountModal';
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
export { InitiatorActionModal } from './InitiatorActionModal';
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)
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
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 { useConclusionRemark } from '@/hooks/useConclusionRemark';
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)
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
@ -117,24 +113,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = 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();
// 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)
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
// Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin)
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer';
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
request?.templateType === 'claim-management';
const showIOTab = isClaimManagement && !isDealer;
// Show IO tab only for department lead (found dynamically, not hardcoded to step 3)
const showIOTab = isDeptLead;
const {
mergedMessages,
@ -179,12 +177,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setDocumentError,
} = 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 {
showApproveModal,
setShowApproveModal,
@ -202,30 +194,26 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setSkipApproverData,
actionStatus,
setActionStatus,
handleApproveConfirm: originalHandleApproveConfirm,
handleRejectConfirm: originalHandleRejectConfirm,
handleApproveConfirm,
handleRejectConfirm,
handleAddApprover,
handleSkipApprover,
handleAddSpectator,
} = useModalManager(requestIdentifier, effectiveApprovalLevel, 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);
};
} = useModalManager(requestIdentifier, currentApprovalLevel, refreshDetails);
// Closure functionality - only for initiator when request is approved/rejected
// Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
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 {
conclusionRemark,
setConclusionRemark,
@ -234,9 +222,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached,
} = useConclusionRemark(
request,
requestIdentifier,
@ -247,32 +232,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
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
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
@ -325,7 +284,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
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
useEffect(() => {
@ -339,7 +298,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try {
setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
try {
@ -365,37 +324,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
fetchSummaryDetails();
}, [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
const currentLevels = (request?.approvalFlow || [])
.filter((flow: any) => flow && typeof flow.step === 'number')
@ -431,15 +359,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message}
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -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.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -594,16 +522,13 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/>
</TabsContent>
{isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab
summary={summaryDetails}
<SummaryTab
summary={summaryDetails}
loading={loadingSummary}
onShare={handleShareSummary}
isInitiator={isInitiator}
@ -625,7 +550,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowSkipApproverModal(true);
}}
onRefresh={refreshDetails}
documentPolicy={documentPolicy}
/>
</TabsContent>
@ -665,8 +589,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
isSpectator={isSpectator}
currentLevels={currentLevels}
onAddApprover={handleAddApprover}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
/>
</TabsContent>
</div>
@ -756,8 +678,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
actionStatus={actionStatus}
existingParticipants={existingParticipants}
currentLevels={currentLevels}
maxApprovalLevels={systemPolicy.maxApprovalLevels}
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
setShowApproveModal={setShowApproveModal}
setShowRejectModal={setShowRejectModal}
setShowAddApproverModal={setShowAddApproverModal}
@ -776,19 +696,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
downloadDocument={downloadDocument}
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 { UserFilterType } from '@/utils/userFilterUtils';
// Import flow modules from src/ level
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
export { CustomFlow, DealerClaimFlow, SharedComponents };
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)
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
*
@ -62,46 +50,26 @@ export function useConclusionRemark(
* Use Case: When request is approved, final approver generates conclusion.
* Initiator needs to review and finalize it before closing request.
*
* Optimization: Check request object first before making API call
* Process:
* 1. Check if conclusion data is already in request object
* 2. If not available, fetch from API
* 1. Dynamically import conclusion API service
* 2. Fetch conclusion by request ID
* 3. Load into state if exists
* 4. Mark as AI-generated if applicable
*/
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 {
// Lazy load: Import conclusion API only when needed
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);
if (result && (result.aiGeneratedRemark || result.finalRemark)) {
if (result && result.aiGeneratedRemark) {
// 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);
}
} 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
}
};
@ -125,12 +93,8 @@ export function useConclusionRemark(
* 5. Handle errors silently (user can type manually)
*/
const handleGenerateConclusion = async () => {
// Safety check: Prevent generation if max attempts already reached
if (maxAttemptsReached) return;
try {
setConclusionLoading(true);
setGenerationFailed(false);
// Lazy load: Import conclusion API
const { generateConclusion } = await import('@/services/conclusionApi');
@ -138,74 +102,14 @@ export function useConclusionRemark(
// API Call: Generate AI conclusion based on request data
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
setConclusionRemark(result.aiGeneratedRemark);
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) {
// Fail silently: User can write conclusion manually
console.error('[useConclusionRemark] AI generation failed:', err);
const newFailures = failureAttempts + 1;
setFailureAttempts(newFailures);
setConclusionRemark('');
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 {
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)
*
* 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(() => {
const status = request?.status?.toLowerCase();
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
if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) {
fetchExistingConclusion();
}
}, [request?.status, request?.conclusionRemark, request?.aiGeneratedConclusion, isInitiator, conclusionRemark]);
}, [request?.status, isInitiator]);
return {
conclusionRemark,
@ -352,10 +236,7 @@ export function useConclusionRemark(
conclusionSubmitting,
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion,
generationAttempts,
generationFailed,
maxAttemptsReached
handleFinalizeConclusion
};
}

View File

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

View File

@ -2,7 +2,6 @@ import { useState, useEffect } from 'react';
import { uploadDocument } from '@/services/documentApi';
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner';
import { handleSecurityError } from '@/utils/securityToast';
/**
* Custom Hook: useDocumentUpload
@ -27,7 +26,7 @@ export function useDocumentUpload(
) {
// State: Indicates if document is currently being uploaded
const [uploadingDocument, setUploadingDocument] = useState(false);
// State: Stores document for preview modal
const [previewDocument, setPreviewDocument] = useState<{
fileName: string;
@ -102,7 +101,7 @@ export function useDocumentUpload(
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return {
valid: false,
@ -131,12 +130,12 @@ export function useDocumentUpload(
*/
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
// Validate: Check if file is selected
if (!files || files.length === 0) return;
const fileArray = Array.from(files);
// Validate all files against document policy
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
@ -170,11 +169,11 @@ export function useDocumentUpload(
}
setUploadingDocument(true);
try {
// Upload only the first valid file (backend currently supports single file)
const file = validFiles[0];
// Validate: Ensure request ID is available
// Note: Backend requires UUID, not request number
const requestId = apiRequest?.requestId;
@ -182,17 +181,17 @@ export function useDocumentUpload(
toast.error('Request ID not found');
return;
}
// API Call: Upload document to backend
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
if (file) {
await uploadDocument(file, requestId, 'SUPPORTING');
}
// Refresh: Reload request details to show newly uploaded document
// This also updates the activity timeline
await refreshDetails();
// Success feedback
if (validFiles.length < fileArray.length) {
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) {
console.error('[useDocumentUpload] Upload error:', error);
// Show security-specific red toast for scan errors, or generic error toast
if (!handleSecurityError(error)) {
toast.error(error?.response?.data?.message || 'Failed to upload document');
}
// Error feedback with backend error message if available
toast.error(error?.response?.data?.error || 'Failed to upload document');
} finally {
setUploadingDocument(false);
// Cleanup: Clear the file input to allow re-uploading same file
if (event.target) {
event.target.value = '';

View File

@ -85,10 +85,6 @@ export function useModalManager(
// API Call: Submit approval
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
await refreshDetails();

View File

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

View File

@ -5,7 +5,6 @@ import { AuthProvider } from './contexts/AuthContext';
import { AuthenticatedApp } from './pages/Auth';
import { store } from './redux/store';
import './styles/globals.css';
import './styles/base-layout.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<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 { formatDate, formatDateTime } from '../utils/formatters';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { navigateToRequest } from '@/utils/requestNavigation';
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
interface ApproverPerformanceRequestListProps {
@ -70,6 +69,7 @@ export function ApproverPerformanceRequestList({
key={request.requestId}
className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
const { navigateToRequest } = require('@/utils/requestNavigation');
navigateToRequest({
requestId: request.requestId,
requestTitle: request.title,
@ -166,6 +166,7 @@ export function ApproverPerformanceRequestList({
size="sm"
onClick={(e) => {
e.stopPropagation();
const { navigateToRequest } = require('@/utils/requestNavigation');
navigateToRequest({
requestId: request.requestId,
requestTitle: request.title,

View File

@ -1,39 +1,22 @@
import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { LogIn, Shield } from 'lucide-react';
import { ReLogo, LandingPageImage } from '@/assets';
import { initiateTanflowLogin } from '@/services/tanflowAuth';
import { LogIn } from 'lucide-react';
import { ReLogo } from '@/assets';
export function Auth() {
const { login, isLoading, error } = useAuth();
const [tanflowLoading, setTanflowLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
// Preload the background image
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 () => {
const handleSSOLogin = async () => {
// Clear any existing session data
localStorage.clear();
sessionStorage.clear();
try {
await login();
} catch (loginError) {
console.error('========================================');
console.error('OKTA LOGIN ERROR');
console.error('LOGIN ERROR');
console.error('Error details:', loginError);
console.error('Error message:', (loginError as Error)?.message);
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) {
console.error('Auth Error in Auth Component:', {
console.error('Auth0 Error in Auth Component:', {
message: error.message,
error: error
});
}
return (
<div
className="min-h-screen flex items-center justify-center p-4 relative"
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">
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
<Card className="w-full max-w-md shadow-xl">
<CardHeader className="space-y-1 text-center pb-6">
<div className="flex flex-col items-center justify-center mb-4">
<img
src={ReLogo}
alt="Royal Enfield Logo"
<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-300 text-center truncate">Approval Portal</p>
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
</div>
</CardHeader>
<CardContent className="space-y-4">
{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">{error.message}</p>
</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">
<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">
<div className="text-center text-sm text-gray-500 mt-4">
<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>
</CardContent>
</Card>

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Auth } from './Auth';
import { AuthCallback } from './AuthCallback';
import { TanflowCallback } from './TanflowCallback';
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
import App from '../../App';
@ -11,8 +10,7 @@ export function AuthenticatedApp() {
const [showDebugInfo, setShowDebugInfo] = useState(false);
// Check if we're on callback route (after all hooks are called)
const isCallbackRoute = typeof window !== 'undefined' &&
window.location.pathname === '/login/callback';
const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback';
const handleLogout = async () => {
try {
@ -41,35 +39,7 @@ export function AuthenticatedApp() {
}, [isAuthenticated, isLoading, error, user]);
// Always show callback loader when on callback route (after all hooks)
// Detect provider from sessionStorage to show appropriate callback component
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 />;
}

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
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
import { ClosedRequestsList } from './components/ClosedRequestsList';
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
@ -13,11 +14,6 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
// 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) {
// Data fetching hook
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
@ -27,29 +23,10 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current = closedRequests.fetchRequests;
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({
searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
@ -61,15 +38,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
fetchRef.current(storedPage, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
// Only include priority and templateType filters if user is not a dealer
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDealer]); // Re-fetch if dealer status changes
}, []); // Only on mount
// Track filter changes and refetch
useEffect(() => {
@ -80,7 +55,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter ||
prev.templateTypeFilter !== filters.templateTypeFilter ||
prev.sortBy !== filters.sortBy ||
prev.sortOrder !== filters.sortOrder;
@ -93,17 +67,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
});
// Update previous values
prevFiltersRef.current = {
searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
};
@ -111,7 +83,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
return () => clearTimeout(timeoutId);
// 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
const handlePageChange = useCallback(
@ -122,7 +94,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
@ -137,7 +108,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
sortBy: filters.sortBy,
sortOrder: filters.sortOrder,
});
@ -153,25 +123,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
onRefresh={handleRefresh}
/>
{/* Filters - Plug-and-play pattern */}
{/* Filters */}
<ClosedRequestsFiltersComponent
searchTerm={filters.searchTerm}
priorityFilter={filters.priorityFilter}
statusFilter={filters.statusFilter}
templateTypeFilter={filters.templateTypeFilter}
sortBy={filters.sortBy}
sortOrder={filters.sortOrder}
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
}
activeFiltersCount={filters.activeFiltersCount}
onSearchChange={filters.setSearchTerm}
onPriorityChange={filters.setPriorityFilter}
onStatusChange={filters.setStatusFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter}
onSortByChange={filters.setSortBy}
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
onClearFilters={filters.clearFilters}

View File

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

View File

@ -12,14 +12,12 @@ 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;
@ -29,14 +27,12 @@ export function ClosedRequestsFilters({
searchTerm,
priorityFilter,
statusFilter,
templateTypeFilter,
sortBy,
sortOrder,
activeFiltersCount,
onSearchChange,
onPriorityChange,
onStatusChange,
onTemplateTypeChange,
onSortByChange,
onSortOrderChange,
onClearFilters,
@ -86,7 +82,7 @@ export function ClosedRequestsFilters({
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" />
@ -107,7 +103,7 @@ export function ClosedRequestsFilters({
</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" />
@ -129,17 +125,6 @@ export function ClosedRequestsFilters({
</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">
@ -151,7 +136,7 @@ export function ClosedRequestsFilters({
<SelectItem value="priority">Priority</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"

View File

@ -30,7 +30,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
});
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 {
if (page === 1) {
setLoading(true);
@ -51,7 +51,6 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
search: filters?.search,
status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
priority: filters?.priority,
templateType: filters?.templateType,
sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder
});
@ -91,7 +90,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
// Initial fetch removed - component handles initial fetch using Redux stored page
// 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);
fetchRequests(pagination.currentPage, filters);
}, [fetchRequests, pagination.currentPage]);

View File

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

View File

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

View File

@ -28,7 +28,6 @@ export interface ClosedRequestsFilters {
search: string;
status: string;
priority: string;
templateType?: string;
sortBy: 'created' | 'due' | 'priority';
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
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext';
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
@ -22,7 +22,7 @@ import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { downloadDocument } from '@/services/workflowApi';
// Custom Hooks
import { useCreateRequestForm, RequestTemplate } from '@/hooks/useCreateRequestForm';
import { useCreateRequestForm } from '@/hooks/useCreateRequestForm';
import { useWizardNavigation } from '@/hooks/useWizardNavigation';
import { useRequestModals } from './hooks/useRequestModals';
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
@ -31,10 +31,6 @@ import { useCreateRequestHandlers } from './hooks/useCreateRequestHandlers';
// Constants
import { REQUEST_TEMPLATES } from './constants/requestTemplates';
// Services
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
import { FileText } from 'lucide-react';
// Components
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
@ -68,35 +64,6 @@ export function CreateRequest({
const isEditing = isEditMode && !!editRequestId;
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
const {
formData,
@ -108,7 +75,7 @@ export function CreateRequest({
documentPolicy,
existingDocuments,
setExistingDocuments,
} = useCreateRequestForm(isEditing, editRequestId, allTemplates);
} = useCreateRequestForm(isEditing, editRequestId, REQUEST_TEMPLATES);
const {
currentStep,
@ -117,7 +84,6 @@ export function CreateRequest({
isStepValid,
nextStep: wizardNextStep,
prevStep: wizardPrevStep,
goToStep,
} = useWizardNavigation(isEditing, selectedTemplate, formData);
// Document management state
@ -132,7 +98,6 @@ export function CreateRequest({
documentErrorModal,
openValidationModal,
closeValidationModal,
openPolicyViolationModal,
closePolicyViolationModal,
openDocumentErrorModal,
closeDocumentErrorModal,
@ -173,40 +138,23 @@ export function CreateRequest({
wizardPrevStep,
user: user!,
openValidationModal,
systemPolicy,
onPolicyViolation: openPolicyViolationModal,
onSubmit,
goToStep,
});
// Handle back button:
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
// - Other steps: Go to previous step in wizard
const handleBackButton = useCallback(() => {
// If on the first step (Template Selection), always go back to dashboard
// This prevents infinite loops if the user was redirected here from an error page
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 (currentStep === 1 || currentStep === 3 || currentStep === 4) {
// On steps 1, 3, or 4, navigate back to previous screen using browser history
if (onBack) {
onBack();
} else {
// Use window.history.back() as fallback for more reliable navigation
if (window.history.length > 1) {
window.history.back();
} else {
// If no history, navigate to dashboard
navigate('/dashboard', { replace: true });
}
}
@ -219,7 +167,7 @@ export function CreateRequest({
// Sync documents from formData only on initial mount (when loading draft)
const isInitialMount = useRef(true);
const documentsSyncedRef = useRef(false);
useEffect(() => {
// Only sync from formData on initial mount or when loading a draft
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
const isUpdatingFromFormData = useRef(false);
const prevDocumentsRef = useRef(documents);
useEffect(() => {
// Skip if we're currently syncing from formData
if (isUpdatingFromFormData.current) {
@ -241,7 +189,7 @@ export function CreateRequest({
prevDocumentsRef.current = documents;
return;
}
// Only update if documents actually changed
if (prevDocumentsRef.current !== documents) {
updateFormData('documents', documents);
@ -259,7 +207,6 @@ export function CreateRequest({
templates={REQUEST_TEMPLATES}
selectedTemplate={selectedTemplate}
onSelectTemplate={selectTemplate}
adminTemplates={adminTemplates}
/>
);
case 2:
@ -275,7 +222,6 @@ export function CreateRequest({
<ApprovalWorkflowStep
formData={formData}
updateFormData={updateFormData}
systemPolicy={systemPolicy}
onValidationError={(error) =>
openValidationModal(
error.type as 'error' | 'self-assign' | 'not-found',
@ -283,7 +229,6 @@ export function CreateRequest({
error.message
)
}
onPolicyViolation={openPolicyViolationModal}
/>
);
case 4:

View File

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

View File

@ -8,8 +8,7 @@
*/
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { RequestTemplate, FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
import { RequestTemplate, FormData } from '@/hooks/useCreateRequestForm';
import { PreviewDocument } from '../types/createRequest.types';
import { getDocumentPreviewUrl } from '@/services/workflowApi';
import { validateApprovers } from './useApproverValidation';
@ -29,10 +28,7 @@ interface UseHandlersOptions {
email: string,
message: string
) => void;
systemPolicy?: SystemPolicy;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
onSubmit?: (requestData: any) => void;
goToStep?: (step: number) => void;
}
export function useCreateRequestHandlers({
@ -46,12 +42,8 @@ export function useCreateRequestHandlers({
wizardPrevStep,
user,
openValidationModal,
systemPolicy,
onPolicyViolation,
onSubmit,
// goToStep,
}: UseHandlersOptions) {
const navigate = useNavigate();
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [previewDocument, setPreviewDocument] =
useState<PreviewDocument | null>(null);
@ -67,23 +59,13 @@ export function useCreateRequestHandlers({
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
updateFormData('slaEndDate', suggestedDate);
// Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep)
if (template.id !== 'custom' && template.id !== 'existing-template') {
// Redirect to dedicated Admin Request flow
navigate(`/create-admin-request/${template.id}`);
if (template.id === 'existing-template') {
setShowTemplateModal(true);
}
};
const handleTemplateSelection = (templateId: string) => {
// Navigate directly to the template-specific route when template is selected from modal
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
if (onSubmit) {
onSubmit({ templateType: templateId });
}
};
@ -92,32 +74,12 @@ export function useCreateRequestHandlers({
const nextStep = async () => {
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) {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// Special validation when leaving step 3 (Approval Workflow)
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 validation = await validateApprovers(
formData.approvers,
@ -180,7 +142,6 @@ export function useCreateRequestHandlers({
setPreviewDocument(null);
};
return {
showTemplateModal,
setShowTemplateModal,

View File

@ -3,7 +3,6 @@
*/
import { useState } from 'react';
import { toast } from 'sonner';
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
import {
buildCreatePayload,
@ -11,6 +10,8 @@ import {
validateApproversForSubmission,
} from '../utils/payloadBuilders';
import {
createAndSubmitWorkflow,
updateAndSubmitWorkflow,
createWorkflow,
updateWorkflowRequest,
} from '../services/createRequestService';
@ -57,48 +58,34 @@ export function useCreateRequestSubmission({
try {
if (isEditing && editRequestId) {
// Update existing workflow with isDraft: false (Submit)
// Update existing workflow
const updatePayload = buildUpdatePayload(
formData,
user,
documentsToDelete,
false
documentsToDelete
);
await updateWorkflowRequest(
await updateAndSubmitWorkflow(
editRequestId,
updatePayload,
documents,
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?.({
...formData,
backendId: editRequestId,
template: selectedTemplate,
});
} else {
// Create new workflow with isDraft: false (Submit)
// Create new workflow
const createPayload = buildCreatePayload(
formData,
selectedTemplate,
user,
false
user
);
const result = await createWorkflow(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,
});
const result = await createAndSubmitWorkflow(createPayload, documents);
onSubmit?.({
...formData,
@ -106,12 +93,8 @@ export function useCreateRequestSubmission({
template: selectedTemplate,
});
}
} catch (error: any) {
} catch (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);
}
};
@ -133,12 +116,11 @@ export function useCreateRequestSubmission({
try {
if (isEditing && editRequestId) {
// Update existing draft with isDraft: true
// Update existing draft
const updatePayload = buildUpdatePayload(
formData,
user,
documentsToDelete,
true
documentsToDelete
);
await updateWorkflowRequest(
@ -148,44 +130,29 @@ export function useCreateRequestSubmission({
documentsToDelete
);
toast.success('Draft Saved Successfully!', {
description: `Your request "${formData.title}" has been saved as draft.`,
duration: 5000,
});
onSubmit?.({
...formData,
backendId: editRequestId,
template: selectedTemplate,
});
} else {
// Create new draft with isDraft: true
// Create new draft
const createPayload = buildCreatePayload(
formData,
selectedTemplate,
user,
true
user
);
const result = await createWorkflow(createPayload, documents);
toast.success('Draft Saved Successfully!', {
description: `Your request "${formData.title}" has been saved as draft.`,
duration: 5000,
});
onSubmit?.({
...formData,
backendId: result.id,
template: selectedTemplate,
});
}
} catch (error: any) {
} catch (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);
}
};

View File

@ -4,6 +4,7 @@
import {
createWorkflowMultipart,
submitWorkflow,
updateWorkflow,
updateWorkflowMultipart,
} from '@/services/workflowApi';
@ -13,7 +14,7 @@ import {
} 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(
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(
requestId: string,
@ -50,3 +51,36 @@ export async function updateWorkflowRequest(
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;
}>;
participants: Participant[];
isDraft?: boolean;
}
export interface UpdateWorkflowPayload {
@ -77,7 +76,6 @@ export interface UpdateWorkflowPayload {
approvalLevels: ApprovalLevel[];
participants: Participant[];
deleteDocumentIds?: string[];
isDraft?: boolean;
}
export interface ValidationModalState {

View File

@ -17,9 +17,16 @@ import { buildApprovalLevels } from './approvalLevelBuilders';
export function buildCreatePayload(
formData: FormData,
selectedTemplate: RequestTemplate | null,
_user: any,
isDraft: boolean = false
_user: any
): 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 {
templateId: selectedTemplate?.id || null,
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
@ -31,17 +38,16 @@ export function buildCreatePayload(
userId: a?.userId || '',
email: a?.email || '',
name: a?.name,
tat: a?.tat || 24,
tat: a?.tat || '',
tatType: a?.tatType || 'hours',
})),
spectators: (formData.spectators || []).map((s: any) => ({
spectators: filteredSpectators.map((s: any) => ({
userId: s?.userId || '',
name: s?.name || '',
email: s?.email || '',
})),
ccList: [], // Auto-generated by backend
participants: [], // Auto-generated by backend from approvers and spectators
isDraft,
};
}
@ -52,8 +58,7 @@ export function buildCreatePayload(
export function buildUpdatePayload(
formData: FormData,
_user: any,
documentsToDelete: string[],
isDraft: boolean = false
documentsToDelete: string[]
): UpdateWorkflowPayload {
const approvalLevels = buildApprovalLevels(
formData.approvers || [],
@ -67,7 +72,6 @@ export function buildUpdatePayload(
approvalLevels,
participants: [], // Auto-generated by backend from approval levels
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
isDraft,
};
}
@ -80,7 +84,7 @@ export function validateApproversForSubmission(
approverCount: number
): { valid: boolean; message?: string } {
const approversToCheck = approvers.slice(0, approverCount);
// Check if all approvers have valid emails
const hasMissingEmails = approversToCheck.some(
(a: any) => !a?.email || !a.email.trim()
@ -108,3 +112,4 @@ export function validateApproversForSubmission(
return { valid: true };
}

View File

@ -4,7 +4,6 @@ import { type DateRange } from '@/services/dashboard.service';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
import { setViewAsUser } from './redux/dashboardSlice';
import { TokenManager } from '@/utils/tokenManager';
// Custom Hooks
import { useDashboardFilters } from './hooks/useDashboardFilters';
@ -162,19 +161,8 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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
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
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 { Separator } from '@/components/ui/separator';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Filter, Calendar as CalendarIcon, RefreshCw } from 'lucide-react';
import { format } from 'date-fns';
import { DateRange } from '@/services/dashboard.service';
import { CustomDatePicker } from '@/components/ui/date-picker';
interface DashboardFiltersBarProps {
isAdmin: boolean;
@ -96,10 +96,12 @@ export function DashboardFiltersBar({
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
<CustomDatePicker
value={customStartDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
<Input
id="start-date"
type="date"
value={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomStartDateChange(date);
if (customEndDate && date > customEndDate) {
@ -109,18 +111,19 @@ export function DashboardFiltersBar({
onCustomStartDateChange(undefined);
}
}}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
data-testid="start-date-input"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
<CustomDatePicker
value={customEndDate || null}
onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined;
<Input
id="end-date"
type="date"
value={customEndDate ? format(customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
onCustomEndDateChange(date);
if (customStartDate && date < customStartDate) {
@ -130,9 +133,8 @@ export function DashboardFiltersBar({
onCustomEndDateChange(undefined);
}
}}
minDate={customStartDate || undefined}
maxDate={new Date()}
placeholderText="dd/mm/yyyy"
min={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
data-testid="end-date-input"
/>
@ -179,88 +181,6 @@ export function DashboardFiltersBar({
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</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>
)}
@ -286,3 +206,4 @@ export function DashboardFiltersBar({
</Card>
);
}

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