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

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;
@ -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,
@ -184,16 +142,9 @@ 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 = {
@ -293,6 +244,10 @@ function AppRoutes({ onLogout }: AppProps) {
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) => {
@ -412,23 +367,218 @@ function AppRoutes({ onLogout }: AppProps) {
});
}
// Keep the old code below for backward compatibility (local storage fallback)
// This can be removed once API integration is fully tested
/*
// Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
// Create full request object
const newRequest = {
id: requestId,
title: `${claimData.activityName} - Claim Request`,
description: claimData.requestDescription,
category: 'Dealer Operations',
subcategory: 'Claim Management',
status: 'pending',
priority: 'standard',
amount: 'TBD',
slaProgress: 0,
slaRemaining: '7 days',
slaEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
currentStep: 1,
totalSteps: 8,
templateType: 'claim-management',
templateName: 'Claim Management',
initiator: {
name: 'Current User',
role: 'Regional Marketing Coordinator',
department: 'Marketing',
email: 'current.user@royalenfield.com',
phone: '+91 98765 43290',
avatar: 'CU'
},
department: 'Marketing',
createdAt: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}),
updatedAt: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
}),
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
conclusionRemark: '',
claimDetails: {
activityName: claimData.activityName,
activityType: claimData.activityType,
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
location: claimData.location,
dealerCode: claimData.dealerCode,
dealerName: claimData.dealerName,
dealerEmail: claimData.dealerEmail || 'N/A',
dealerPhone: claimData.dealerPhone || 'N/A',
dealerAddress: claimData.dealerAddress || 'N/A',
requestDescription: claimData.requestDescription,
estimatedBudget: claimData.estimatedBudget || 'TBD',
periodStart: claimData.periodStartDate ? new Date(claimData.periodStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
periodEnd: claimData.periodEndDate ? new Date(claimData.periodEndDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
},
approvalFlow: claimData.workflowSteps || [
{
step: 1,
approver: `${claimData.dealerName} (Dealer)`,
role: 'Dealer - Document Upload',
status: 'pending',
tatHours: 72,
elapsedHours: 0,
assignedAt: new Date().toISOString(),
comment: null,
timestamp: null,
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
},
{
step: 2,
approver: 'Current User (Initiator)',
role: 'Initiator Evaluation',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator reviews dealer documents and approves or requests modifications'
},
{
step: 3,
approver: 'System Auto-Process',
role: 'IO Confirmation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
},
{
step: 4,
approver: 'Rajesh Kumar',
role: 'Department Lead Approval',
status: 'waiting',
tatHours: 72,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Department head approves and blocks budget in IO for this activity'
},
{
step: 5,
approver: `${claimData.dealerName} (Dealer)`,
role: 'Dealer - Completion Documents',
status: 'waiting',
tatHours: 120,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Dealer submits activity completion documents and description'
},
{
step: 6,
approver: 'Current User (Initiator)',
role: 'Initiator Verification',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Initiator verifies completion documents and can modify approved amount'
},
{
step: 7,
approver: 'System Auto-Process',
role: 'E-Invoice Generation',
status: 'waiting',
tatHours: 1,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Auto-generate e-invoice based on final approved amount'
},
{
step: 8,
approver: 'Finance Team',
role: 'Credit Note Issuance',
status: 'waiting',
tatHours: 48,
elapsedHours: 0,
assignedAt: null,
comment: null,
timestamp: null,
description: 'Finance team issues credit note to dealer'
}
],
documents: [],
spectators: [],
auditTrail: [
{
type: 'created',
action: 'Request Created',
details: `Claim request for ${claimData.activityName} created`,
user: 'Current User',
timestamp: new Date().toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: 'numeric',
hour12: true
})
}
],
tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')]
};
// Add to dynamic requests
setDynamicRequests(prev => [...prev, newRequest]);
// Also add to REQUEST_DATABASE for immediate viewing
(REQUEST_DATABASE as any)[requestId] = newRequest;
toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.',
});
navigate('/my-requests');
*/
};
return (
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes>
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
{/* Auth Callback - Must be before other routes */}
<Route
path="/login/callback"
element={<AuthCallback />}
/>
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
{/* Dashboard */}
<Route
path="/"
element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout>
}
/>
@ -437,34 +587,11 @@ function AppRoutes({ onLogout }: AppProps) {
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"
@ -610,16 +737,6 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Security Settings */}
<Route
path="/settings/security"
element={
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SecuritySettings />
</PageLayout>
}
/>
{/* Notifications */}
<Route
path="/notifications"
@ -640,9 +757,15 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Admin Control Panel */}
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
</Routes>
<Toaster

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

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

@ -318,7 +318,7 @@ export function UserManagement() {
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');
};
@ -332,6 +332,7 @@ export function UserManagement() {
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" />
@ -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;
@ -31,11 +30,10 @@ export function FormattedDescription({ content, className }: FormattedDescriptio
}
// 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;
@ -37,17 +36,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
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 {
@ -72,23 +60,19 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
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);
@ -115,8 +99,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
// Work note related notifications should open Work Notes tab
if (notification.notificationType === 'mention' ||
notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') {
notification.notificationType === 'comment' ||
notification.notificationType === 'worknote') {
navigationUrl += '?tab=worknotes';
}
@ -249,24 +233,21 @@ 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>
@ -275,18 +256,16 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
</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">

View File

@ -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">
@ -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,4 +1,4 @@
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';
@ -8,16 +8,14 @@ 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);
};
@ -105,13 +84,12 @@ 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 */}
@ -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'
}`}
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">
@ -259,12 +219,13 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
</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,6 +1,5 @@
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';
@ -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 (
@ -197,10 +195,11 @@ 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>
@ -307,8 +306,9 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
<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]);
@ -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');
@ -232,9 +233,9 @@ export function RichTextEditor({
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]);
@ -272,34 +273,34 @@ export function RichTextEditor({
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') {
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) {
@ -327,8 +328,8 @@ export function RichTextEditor({
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') {
textColor !== 'rgba(0, 0, 0, 0)' &&
hexTextColor !== '#000000') {
formats.add('textColor');
// Find matching color from our palette
const matchedColor = HIGHLIGHT_COLORS.find(c => {
@ -379,7 +380,7 @@ export function RichTextEditor({
// Update content
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
// Check active formats after a short delay
@ -421,7 +422,7 @@ export function RichTextEditor({
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') {
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();
@ -531,7 +532,7 @@ export function RichTextEditor({
// Update content
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
// Close popover
@ -635,7 +636,7 @@ export function RichTextEditor({
// Update content
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
// Close popover
@ -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,7 +685,7 @@ export function RichTextEditor({
const handleBlur = React.useCallback(() => {
setIsFocused(false);
if (editorRef.current) {
onChange(sanitizeHTML(editorRef.current.innerHTML));
onChange(editorRef.current.innerHTML);
}
}, [onChange]);

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';
@ -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 }) => {
@ -143,7 +140,7 @@ 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
@ -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]);
@ -221,7 +218,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
};
}) : [];
setMessages(mapped as any);
} catch { }
} catch {}
}
setMessage('');
setSelectedFiles([]);
@ -260,7 +257,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
} as any;
});
setMessages(mapped);
} catch { }
} catch {}
} else {
(async () => {
try {
@ -397,10 +394,11 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
{!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>
@ -453,70 +451,70 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
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>
)}
</div>
{/* Download button */}
{/* 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-blue-100 hover:text-blue-600"
onClick={async (e) => {
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,10 +528,11 @@ 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')
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>

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();
@ -222,29 +218,17 @@ export function ApprovalWorkflowStep({
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>
@ -297,15 +281,17 @@ export function ApprovalWorkflowStep({
<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

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

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
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,15 +19,12 @@ 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
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">
@ -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 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 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'
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 ? (
@ -88,8 +86,9 @@ 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'
}`}
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`}
/>
)}
@ -97,14 +96,15 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
))}
</div>
<div
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2 px-1"
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2"
data-testid="wizard-stepper-desktop-labels"
>
{stepNames.map((step, index) => (
<span
key={index}
className={`${index + 1 === currentStep ? 'font-semibold text-green-600' : ''
}`}
className={`${
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
}`}
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
>
{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;
@ -101,37 +100,27 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// 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;
@ -149,14 +138,14 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// 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);
@ -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;
}
@ -289,9 +245,6 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
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 {
@ -323,7 +274,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
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();
@ -369,7 +320,7 @@ 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,19 +405,16 @@ 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}&` +
@ -490,15 +438,10 @@ 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
@ -516,58 +459,29 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
// 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()}`;
@ -609,7 +523,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
}
}
// Dev mode: tokens in localStorage
// Development mode: tokens in localStorage
const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) {
return token;
@ -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,85 +60,8 @@ 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: '',
@ -163,63 +85,27 @@ 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 => {
@ -250,19 +136,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
switch (currentStep) {
case 1:
return formData.activityName &&
formData.activityType &&
formData.dealerCode &&
formData.dealerName &&
formData.activityDate &&
formData.location &&
formData.requestDescription;
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,14 +161,11 @@ 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) {
@ -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,7 +432,7 @@ 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">
@ -776,8 +462,6 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
? `${(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);
{(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 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>
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>
@ -1065,8 +723,9 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
{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'
}`}
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 || {};
const organizer = internalOrder?.organizer || null;
// 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 [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);
// Load existing IO blocks
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(),
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);
const maxIoRemarkChars = 300;
const ioRemarkChars = ioRemark.length;
// 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);
// Load existing IO block details from apiRequest
useEffect(() => {
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: 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
@ -135,22 +124,8 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
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) {
@ -192,49 +200,39 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
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);
@ -244,38 +242,34 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
if (updatedInternalOrder) {
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount));
// Calculate expected remaining balance for validation/debugging
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
// Blocking result processed
// 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';
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,
@ -285,12 +279,12 @@ 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
@ -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;
@ -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
@ -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,18 +29,15 @@ 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;
}
@ -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,13 +53,10 @@ 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
@ -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');
@ -107,6 +113,7 @@ export function DeptLeadIOApprovalModal({
if (actionType === 'approve') {
await onApprove({
ioNumber: ioNumber.trim(),
ioRemark: ioRemark.trim(),
comments: comments.trim(),
});
} else {
@ -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(() => {
@ -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')
@ -594,9 +522,6 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion}
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
/>
</TabsContent>
@ -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;

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
@ -202,10 +201,8 @@ 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);

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';
/**
@ -211,24 +213,21 @@ export function useRequestDetails(
*/
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,20 +237,33 @@ 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;
@ -266,7 +278,24 @@ export function useRequestDetails(
(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,7 +357,6 @@ 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,
@ -495,22 +523,18 @@ export function useRequestDetails(
// Filter out TAT warnings from 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,11 +544,16 @@ 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) {
@ -532,7 +561,6 @@ export function useRequestDetails(
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;
@ -547,7 +575,17 @@ export function useRequestDetails(
(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,7 +634,6 @@ 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,
@ -655,13 +692,21 @@ export function useRequestDetails(
/**
* 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
// 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 ||

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,30 +1,13 @@
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();
@ -33,7 +16,7 @@ export function Auth() {
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,48 +24,16 @@ 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
@ -90,74 +41,42 @@ export function Auth() {
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>
)}
<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>
<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="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,
@ -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">

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