Compare commits
48 Commits
dealer_cla
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d6b2a3f9c | |||
| e11f13d248 | |||
| b04776a5f8 | |||
| 170f9a1788 | |||
| 32a486d6f4 | |||
| dfe94555ab | |||
| 5dce660f05 | |||
| 5e91b85854 | |||
| d2d75d93f7 | |||
| 3a6cc6894c | |||
| a16346effd | |||
| 2fa52b90e3 | |||
| 80ed407cd8 | |||
| 7ae9133b98 | |||
| 08cda349f3 | |||
| edd1967336 | |||
| d285ea88d8 | |||
| 81565d294b | |||
| c97053e0e3 | |||
| 1d205a4038 | |||
| fdbc8dcfa1 | |||
| efdcb18b64 | |||
| 6c5398f433 | |||
| 1b4091c3d3 | |||
| ec8987032f | |||
| 1391e2d2f5 | |||
| 66c33703e1 | |||
| a3a142d603 | |||
| fc46f32282 | |||
| e8caafa7a1 | |||
| 4c3d7fd28b | |||
| d725e523b3 | |||
| 94b7c34a7a | |||
| 22d3e8a388 | |||
| 985b755707 | |||
| 164d576ea0 | |||
| 7893b52183 | |||
| 7d3b6a9da2 | |||
| c1ec261a6d | |||
| c6bd5a19ef | |||
| c7ffc475f9 | |||
| 22cb42e06e | |||
| aedba86ae3 | |||
| 058ab97600 | |||
| 12f8affd15 | |||
| ce90fcf9ef | |||
| 01d69bb1eb | |||
| ca3f6f33d1 |
27
.env.local.backup
Normal file
27
.env.local.backup
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
#Local
|
||||||
|
VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
VITE_BASE_URL=http://localhost:3000
|
||||||
|
VITE_API_BASE_URL=http://localhost:3000/api/v1
|
||||||
|
VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
|
||||||
|
VITE_OKTA_DOMAIN=https://royalenfield.okta.com
|
||||||
|
|
||||||
|
#Development
|
||||||
|
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
# VITE_BASE_URL=https://re-workflow-nt-dev.siplsolutions.com
|
||||||
|
# VITE_API_BASE_URL=https://re-workflow-nt-dev.siplsolutions.com/api/v1
|
||||||
|
# VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
|
||||||
|
# VITE_OKTA_DOMAIN=https://royalenfield.okta.com
|
||||||
|
|
||||||
|
#Uat
|
||||||
|
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
# VITE_BASE_URL=https://reflow-uat.royalenfield.com
|
||||||
|
# VITE_API_BASE_URL=https://reflow-uat.royalenfield.com/api/v1/
|
||||||
|
# VITE_OKTA_CLIENT_ID=0oa2jgzvrpdwx2iqd0h8
|
||||||
|
# VITE_OKTA_DOMAIN=https://dev-830839.oktapreview.com
|
||||||
|
|
||||||
|
#Production
|
||||||
|
# VITE_PUBLIC_VAPID_KEY=BBb78N3tSTEw6mPbBmvEDX2bhYEDKPc_zffL-vxPV8FBSmR1qSpy9gdV8zt-WFF-q2NPpVmL4BhbUzLSHVAPjcI
|
||||||
|
# VITE_BASE_URL=https://reflow.royalenfield.com
|
||||||
|
# VITE_API_BASE_URL=https://reflow.royalenfield.com/api/v1
|
||||||
|
# VITE_OKTA_CLIENT_ID=0oa18b98aari6I6eo2p8
|
||||||
|
# VITE_OKTA_DOMAIN=https://royalenfield.okta.com
|
||||||
78
index.html
78
index.html
@ -1,61 +1,23 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
|
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
|
||||||
<meta name="theme-color" content="#2d4a3e" />
|
|
||||||
<title>Royal Enfield | Approval Portal</title>
|
|
||||||
|
|
||||||
<!-- Preload critical fonts and icons -->
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
|
|
||||||
<!-- Ensure proper icon rendering and layout -->
|
|
||||||
<style>
|
|
||||||
/* Ensure Lucide icons render properly */
|
|
||||||
svg {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix for icon alignment in buttons */
|
|
||||||
button svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure proper text rendering */
|
|
||||||
body {
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
text-rendering: optimizeLegibility;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix for mobile viewport and sidebar */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
html {
|
|
||||||
overflow-x: hidden;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure proper sidebar toggle behavior */
|
|
||||||
.sidebar-toggle {
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fix for icon button hover states */
|
|
||||||
button:hover svg {
|
|
||||||
transform: scale(1.05);
|
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description"
|
||||||
|
content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />
|
||||||
|
<meta name="theme-color" content="#2d4a3e" />
|
||||||
|
<title>Royal Enfield | Approval Portal</title>
|
||||||
|
|
||||||
|
<!-- Preload essential fonts and icons -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
4
public/robots.txt
Normal file
4
public/robots.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /api/
|
||||||
|
|
||||||
|
Sitemap: https://reflow.royalenfield.com/sitemap.xml
|
||||||
9
public/sitemap.xml
Normal file
9
public/sitemap.xml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://reflow.royalenfield.com</loc>
|
||||||
|
<lastmod>2024-03-20T12:00:00+00:00</lastmod>
|
||||||
|
<changefreq>daily</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
529
src/App.tsx
529
src/App.tsx
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, useNavigate, Outlet } from 'react-router-dom';
|
||||||
import { PageLayout } from '@/components/layout/PageLayout';
|
import { PageLayout } from '@/components/layout/PageLayout';
|
||||||
import { Dashboard } from '@/pages/Dashboard';
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
import { OpenRequests } from '@/pages/OpenRequests';
|
import { OpenRequests } from '@/pages/OpenRequests';
|
||||||
@ -10,6 +10,7 @@ import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail
|
|||||||
import { WorkNotes } from '@/pages/WorkNotes';
|
import { WorkNotes } from '@/pages/WorkNotes';
|
||||||
import { CreateRequest } from '@/pages/CreateRequest';
|
import { CreateRequest } from '@/pages/CreateRequest';
|
||||||
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
import { ClaimManagementWizard } from '@/dealer-claim/components/request-creation/ClaimManagementWizard';
|
||||||
|
import { DealerDashboard } from '@/dealer-claim/pages/Dashboard';
|
||||||
import { MyRequests } from '@/pages/MyRequests';
|
import { MyRequests } from '@/pages/MyRequests';
|
||||||
import { Requests } from '@/pages/Requests/Requests';
|
import { Requests } from '@/pages/Requests/Requests';
|
||||||
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
|
||||||
@ -17,16 +18,21 @@ import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
|||||||
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
|
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
|
||||||
import { Profile } from '@/pages/Profile';
|
import { Profile } from '@/pages/Profile';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { SecuritySettings } from '@/pages/Settings/SecuritySettings';
|
||||||
import { Notifications } from '@/pages/Notifications';
|
import { Notifications } from '@/pages/Notifications';
|
||||||
import { DetailedReports } from '@/pages/DetailedReports';
|
import { DetailedReports } from '@/pages/DetailedReports';
|
||||||
import { Admin } from '@/pages/Admin';
|
|
||||||
|
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
|
||||||
|
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
|
||||||
|
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
|
||||||
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
|
||||||
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
import { AuthCallback } from '@/pages/Auth/AuthCallback';
|
||||||
import { createClaimRequest } from '@/services/dealerClaimApi';
|
import { createClaimRequest } from '@/services/dealerClaimApi';
|
||||||
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
import { ManagerSelectionModal } from '@/components/modals/ManagerSelectionModal';
|
||||||
|
import { navigateToRequest } from '@/utils/requestNavigation';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
@ -37,7 +43,7 @@ interface AppProps {
|
|||||||
function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) {
|
function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isAdmin = hasManagementAccess(user);
|
const isAdmin = hasManagementAccess(user);
|
||||||
|
|
||||||
// Render separate screens based on user role
|
// Render separate screens based on user role
|
||||||
// Admin/Management users see all organization requests
|
// Admin/Management users see all organization requests
|
||||||
// Regular users see only their participant requests (approver/spectator, NOT initiator)
|
// Regular users see only their participant requests (approver/spectator, NOT initiator)
|
||||||
@ -48,6 +54,43 @@ function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Component to conditionally render Dashboard or DealerDashboard based on user job title
|
||||||
|
function DashboardRoute({ onNavigate, onNewRequest }: { onNavigate?: (page: string) => void; onNewRequest?: () => void }) {
|
||||||
|
const [isDealer, setIsDealer] = useState<boolean>(false);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
setIsDealer(userData?.jobTitle === 'Dealer');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[App] Error checking dealer status:', error);
|
||||||
|
setIsDealer(false);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-8 h-8 animate-spin rounded-full border-4 border-blue-600 border-t-transparent" />
|
||||||
|
<p className="text-muted-foreground">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render dealer-specific dashboard if user is a dealer
|
||||||
|
if (isDealer) {
|
||||||
|
return <DealerDashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render regular dashboard for all other users
|
||||||
|
return <Dashboard onNavigate={onNavigate} onNewRequest={onNewRequest} />;
|
||||||
|
}
|
||||||
|
|
||||||
// Main Application Routes Component
|
// Main Application Routes Component
|
||||||
function AppRoutes({ onLogout }: AppProps) {
|
function AppRoutes({ onLogout }: AppProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -109,12 +152,11 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string, request?: any) => {
|
const handleViewRequest = (requestId: string, requestTitle?: string, status?: string, request?: any) => {
|
||||||
setSelectedRequestId(requestId);
|
setSelectedRequestId(requestId);
|
||||||
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
setSelectedRequestTitle(requestTitle || 'Unknown Request');
|
||||||
|
|
||||||
// Use global navigation utility for consistent routing
|
// Use global navigation utility for consistent routing
|
||||||
const { navigateToRequest } = await import('@/utils/requestNavigation');
|
|
||||||
navigateToRequest({
|
navigateToRequest({
|
||||||
requestId,
|
requestId,
|
||||||
requestTitle,
|
requestTitle,
|
||||||
@ -141,11 +183,18 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular custom request submission
|
// 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)
|
||||||
// Generate unique ID for the new custom request
|
// Generate unique ID for the new custom request
|
||||||
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
|
const requestId = `RE-REQ-2024-${String(dynamicRequests.length + 1).padStart(3, '0')}`;
|
||||||
|
|
||||||
// Create full custom request object
|
// Create full custom request object
|
||||||
const newCustomRequest = {
|
const newCustomRequest = {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
@ -173,21 +222,21 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
avatar: 'CU'
|
avatar: 'CU'
|
||||||
},
|
},
|
||||||
department: requestData.department || 'General',
|
department: requestData.department || 'General',
|
||||||
createdAt: new Date().toLocaleDateString('en-US', {
|
createdAt: new Date().toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
hour12: true
|
hour12: true
|
||||||
}),
|
}),
|
||||||
updatedAt: new Date().toLocaleDateString('en-US', {
|
updatedAt: new Date().toLocaleDateString('en-US', {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
hour12: true
|
hour12: true
|
||||||
}),
|
}),
|
||||||
dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
submittedDate: new Date().toISOString(),
|
submittedDate: new Date().toISOString(),
|
||||||
@ -197,7 +246,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
// Extract name from email if name is not available
|
// Extract name from email if name is not available
|
||||||
const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`;
|
const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`;
|
||||||
const approverEmail = approver?.email || '';
|
const approverEmail = approver?.email || '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: index + 1,
|
step: index + 1,
|
||||||
approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`,
|
approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`,
|
||||||
@ -222,32 +271,28 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
auditTrail: [
|
auditTrail: [
|
||||||
{
|
{
|
||||||
type: 'created',
|
type: 'created',
|
||||||
action: 'Request Created',
|
action: 'Request Created',
|
||||||
details: `Custom request "${requestData.title}" created`,
|
details: `Custom request "${requestData.title}" created`,
|
||||||
user: 'Current User',
|
user: 'Current User',
|
||||||
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
|
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'assignment',
|
type: 'assignment',
|
||||||
action: 'Assigned to Approver',
|
action: 'Assigned to Approver',
|
||||||
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`,
|
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`,
|
||||||
user: 'System',
|
user: 'System',
|
||||||
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
|
timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true })
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
tags: requestData.tags || ['custom-request']
|
tags: requestData.tags || ['custom-request']
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to dynamic requests
|
// Add to dynamic requests
|
||||||
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
||||||
|
|
||||||
navigate('/my-requests');
|
navigate('/my-requests');
|
||||||
toast.success('Request Submitted Successfully!', {
|
|
||||||
description: `Your request "${requestData.title}" (${requestId}) has been created and sent for approval.`,
|
|
||||||
duration: 5000,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
|
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
|
||||||
@ -264,7 +309,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
duration: 5000,
|
duration: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setApprovalAction(null);
|
setApprovalAction(null);
|
||||||
resolve(true);
|
resolve(true);
|
||||||
}, 1000);
|
}, 1000);
|
||||||
@ -297,7 +342,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
|
|
||||||
// Call API to create claim request
|
// Call API to create claim request
|
||||||
const response = await createClaimRequest(payload);
|
const response = await createClaimRequest(payload);
|
||||||
|
|
||||||
// Validate response - ensure request was actually created successfully
|
// Validate response - ensure request was actually created successfully
|
||||||
if (!response || !response.request) {
|
if (!response || !response.request) {
|
||||||
throw new Error('Invalid response from server: Request object not found');
|
throw new Error('Invalid response from server: Request object not found');
|
||||||
@ -331,11 +376,11 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[App] Error creating claim request:', error);
|
console.error('[App] Error creating claim request:', error);
|
||||||
|
|
||||||
// Check for manager-related errors
|
// Check for manager-related errors
|
||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
const errorCode = errorData?.code || errorData?.error?.code;
|
const errorCode = errorData?.code || errorData?.error?.code;
|
||||||
|
|
||||||
if (errorCode === 'NO_MANAGER_FOUND') {
|
if (errorCode === 'NO_MANAGER_FOUND') {
|
||||||
// Show modal for no manager found
|
// Show modal for no manager found
|
||||||
setManagerModalData({
|
setManagerModalData({
|
||||||
@ -346,7 +391,7 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setManagerModalOpen(true);
|
setManagerModalOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
|
if (errorCode === 'MULTIPLE_MANAGERS_FOUND') {
|
||||||
// Show modal with manager list for selection
|
// Show modal with manager list for selection
|
||||||
const managers = errorData?.managers || errorData?.error?.managers || [];
|
const managers = errorData?.managers || errorData?.error?.managers || [];
|
||||||
@ -359,343 +404,171 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setManagerModalOpen(true);
|
setManagerModalOpen(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other errors - show toast
|
// Other errors - show toast
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
|
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request';
|
||||||
toast.error('Failed to Submit Claim Request', {
|
toast.error('Failed to Submit Claim Request', {
|
||||||
description: errorMessage,
|
description: errorMessage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the old code below for backward compatibility (local storage fallback)
|
|
||||||
// This can be removed once API integration is fully tested
|
|
||||||
/*
|
|
||||||
// Generate unique ID for the new claim request
|
|
||||||
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
|
|
||||||
|
|
||||||
// Create full request object
|
|
||||||
const newRequest = {
|
|
||||||
id: requestId,
|
|
||||||
title: `${claimData.activityName} - Claim Request`,
|
|
||||||
description: claimData.requestDescription,
|
|
||||||
category: 'Dealer Operations',
|
|
||||||
subcategory: 'Claim Management',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'standard',
|
|
||||||
amount: 'TBD',
|
|
||||||
slaProgress: 0,
|
|
||||||
slaRemaining: '7 days',
|
|
||||||
slaEndDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
currentStep: 1,
|
|
||||||
totalSteps: 8,
|
|
||||||
templateType: 'claim-management',
|
|
||||||
templateName: 'Claim Management',
|
|
||||||
initiator: {
|
|
||||||
name: 'Current User',
|
|
||||||
role: 'Regional Marketing Coordinator',
|
|
||||||
department: 'Marketing',
|
|
||||||
email: 'current.user@royalenfield.com',
|
|
||||||
phone: '+91 98765 43290',
|
|
||||||
avatar: 'CU'
|
|
||||||
},
|
|
||||||
department: 'Marketing',
|
|
||||||
createdAt: new Date().toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: 'numeric',
|
|
||||||
hour12: true
|
|
||||||
}),
|
|
||||||
updatedAt: new Date().toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: 'numeric',
|
|
||||||
hour12: true
|
|
||||||
}),
|
|
||||||
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
||||||
conclusionRemark: '',
|
|
||||||
claimDetails: {
|
|
||||||
activityName: claimData.activityName,
|
|
||||||
activityType: claimData.activityType,
|
|
||||||
activityDate: claimData.activityDate ? new Date(claimData.activityDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
|
|
||||||
location: claimData.location,
|
|
||||||
dealerCode: claimData.dealerCode,
|
|
||||||
dealerName: claimData.dealerName,
|
|
||||||
dealerEmail: claimData.dealerEmail || 'N/A',
|
|
||||||
dealerPhone: claimData.dealerPhone || 'N/A',
|
|
||||||
dealerAddress: claimData.dealerAddress || 'N/A',
|
|
||||||
requestDescription: claimData.requestDescription,
|
|
||||||
estimatedBudget: claimData.estimatedBudget || 'TBD',
|
|
||||||
periodStart: claimData.periodStartDate ? new Date(claimData.periodStartDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : '',
|
|
||||||
periodEnd: claimData.periodEndDate ? new Date(claimData.periodEndDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) : ''
|
|
||||||
},
|
|
||||||
approvalFlow: claimData.workflowSteps || [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: `${claimData.dealerName} (Dealer)`,
|
|
||||||
role: 'Dealer - Document Upload',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: new Date().toISOString(),
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Dealer uploads proposal document, cost breakup, timeline for closure, and other supporting documents'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 2,
|
|
||||||
approver: 'Current User (Initiator)',
|
|
||||||
role: 'Initiator Evaluation',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Initiator reviews dealer documents and approves or requests modifications'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 3,
|
|
||||||
approver: 'System Auto-Process',
|
|
||||||
role: 'IO Confirmation',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 1,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Automatic IO (Internal Order) confirmation generated upon initiator approval'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 4,
|
|
||||||
approver: 'Rajesh Kumar',
|
|
||||||
role: 'Department Lead Approval',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 72,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Department head approves and blocks budget in IO for this activity'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 5,
|
|
||||||
approver: `${claimData.dealerName} (Dealer)`,
|
|
||||||
role: 'Dealer - Completion Documents',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 120,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Dealer submits activity completion documents and description'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 6,
|
|
||||||
approver: 'Current User (Initiator)',
|
|
||||||
role: 'Initiator Verification',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Initiator verifies completion documents and can modify approved amount'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 7,
|
|
||||||
approver: 'System Auto-Process',
|
|
||||||
role: 'E-Invoice Generation',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 1,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Auto-generate e-invoice based on final approved amount'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
step: 8,
|
|
||||||
approver: 'Finance Team',
|
|
||||||
role: 'Credit Note Issuance',
|
|
||||||
status: 'waiting',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null,
|
|
||||||
description: 'Finance team issues credit note to dealer'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [],
|
|
||||||
spectators: [],
|
|
||||||
auditTrail: [
|
|
||||||
{
|
|
||||||
type: 'created',
|
|
||||||
action: 'Request Created',
|
|
||||||
details: `Claim request for ${claimData.activityName} created`,
|
|
||||||
user: 'Current User',
|
|
||||||
timestamp: new Date().toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: 'numeric',
|
|
||||||
hour12: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add to dynamic requests
|
|
||||||
setDynamicRequests(prev => [...prev, newRequest]);
|
|
||||||
|
|
||||||
// Also add to REQUEST_DATABASE for immediate viewing
|
|
||||||
(REQUEST_DATABASE as any)[requestId] = newRequest;
|
|
||||||
|
|
||||||
toast.success('Claim Request Submitted', {
|
|
||||||
description: 'Your claim management request has been created successfully.',
|
|
||||||
});
|
|
||||||
navigate('/my-requests');
|
|
||||||
*/
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
|
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* Auth Callback - Must be before other routes */}
|
{/* Auth Callback - Unified callback for both OKTA and Tanflow */}
|
||||||
<Route
|
<Route
|
||||||
path="/login/callback"
|
path="/login/callback"
|
||||||
element={<AuthCallback />}
|
element={<AuthCallback />}
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Dashboard */}
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/dashboard"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Dashboard onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/dashboard"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<DashboardRoute 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 */}
|
{/* Open Requests */}
|
||||||
<Route
|
<Route
|
||||||
path="/open-requests"
|
path="/open-requests"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="open-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="open-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<OpenRequests onViewRequest={handleViewRequest} />
|
<OpenRequests onViewRequest={handleViewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Closed Requests */}
|
{/* Closed Requests */}
|
||||||
<Route
|
<Route
|
||||||
path="/closed-requests"
|
path="/closed-requests"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="closed-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="closed-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<ClosedRequests onViewRequest={handleViewRequest} />
|
<ClosedRequests onViewRequest={handleViewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Shared Summaries */}
|
{/* Shared Summaries */}
|
||||||
<Route
|
<Route
|
||||||
path="/shared-summaries"
|
path="/shared-summaries"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<SharedSummaries />
|
<SharedSummaries />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Shared Summary Detail */}
|
{/* Shared Summary Detail */}
|
||||||
<Route
|
<Route
|
||||||
path="/shared-summaries/:sharedSummaryId"
|
path="/shared-summaries/:sharedSummaryId"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<SharedSummaryDetail />
|
<SharedSummaryDetail />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* My Requests */}
|
{/* My Requests */}
|
||||||
<Route
|
<Route
|
||||||
path="/my-requests"
|
path="/my-requests"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="my-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="my-requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<MyRequests onViewRequest={handleViewRequest} dynamicRequests={dynamicRequests} />
|
<MyRequests onViewRequest={handleViewRequest} dynamicRequests={dynamicRequests} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Requests - Separate screens for Admin and Regular Users */}
|
{/* Requests - Separate screens for Admin and Regular Users */}
|
||||||
<Route
|
<Route
|
||||||
path="/requests"
|
path="/requests"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<RequestsRoute onViewRequest={handleViewRequest} />
|
<RequestsRoute onViewRequest={handleViewRequest} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Approver Performance - Detailed Performance Analysis */}
|
{/* Approver Performance - Detailed Performance Analysis */}
|
||||||
<Route
|
<Route
|
||||||
path="/approver-performance"
|
path="/approver-performance"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<ApproverPerformance />
|
<ApproverPerformance />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Request Detail - requestId will be read from URL params */}
|
{/* Request Detail - requestId will be read from URL params */}
|
||||||
<Route
|
<Route
|
||||||
path="/request/:requestId"
|
path="/request/:requestId"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<RequestDetail
|
<RequestDetail
|
||||||
requestId=""
|
requestId=""
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
dynamicRequests={dynamicRequests}
|
dynamicRequests={dynamicRequests}
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Work Notes - Dedicated Full-Screen Page */}
|
{/* Work Notes - Dedicated Full-Screen Page */}
|
||||||
<Route
|
<Route
|
||||||
path="/work-notes/:requestId"
|
path="/work-notes/:requestId"
|
||||||
element={<WorkNotes />}
|
element={<WorkNotes />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* New Request (Custom) */}
|
{/* New Request (Custom) */}
|
||||||
<Route
|
<Route
|
||||||
path="/new-request"
|
path="/new-request"
|
||||||
element={
|
element={
|
||||||
<CreateRequest
|
<CreateRequest
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onSubmit={handleNewRequestSubmit}
|
onSubmit={handleNewRequestSubmit}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Edit Draft Request */}
|
{/* Edit Draft Request */}
|
||||||
<Route
|
<Route
|
||||||
path="/edit-request/:requestId"
|
path="/edit-request/:requestId"
|
||||||
element={
|
element={
|
||||||
<CreateRequest
|
<CreateRequest
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
@ -703,72 +576,76 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
requestId={undefined} // Will be read from URL params
|
requestId={undefined} // Will be read from URL params
|
||||||
isEditMode={true}
|
isEditMode={true}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Claim Management Wizard */}
|
{/* Claim Management Wizard */}
|
||||||
<Route
|
<Route
|
||||||
path="/claim-management"
|
path="/claim-management"
|
||||||
element={
|
element={
|
||||||
<ClaimManagementWizard
|
<ClaimManagementWizard
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
onSubmit={handleClaimManagementSubmit}
|
onSubmit={handleClaimManagementSubmit}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Profile */}
|
{/* Profile */}
|
||||||
<Route
|
<Route
|
||||||
path="/profile"
|
path="/profile"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="profile" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="profile" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<Profile />
|
<Profile />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Settings */}
|
{/* Settings */}
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<Settings />
|
<Settings />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Security Settings */}
|
||||||
|
<Route
|
||||||
|
path="/settings/security"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="settings" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<SecuritySettings />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<Route
|
<Route
|
||||||
path="/notifications"
|
path="/notifications"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<Notifications onNavigate={handleNavigate} />
|
<Notifications onNavigate={handleNavigate} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Detailed Reports */}
|
{/* Detailed Reports */}
|
||||||
<Route
|
<Route
|
||||||
path="/detailed-reports"
|
path="/detailed-reports"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<DetailedReports />
|
<DetailedReports />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin Control Panel */}
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
@ -822,7 +699,7 @@ interface MainAppProps {
|
|||||||
|
|
||||||
export default function App(props?: MainAppProps) {
|
export default function App(props?: MainAppProps) {
|
||||||
const { onLogout } = props || {};
|
const { onLogout } = props || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes onLogout={onLogout} />
|
<AppRoutes onLogout={onLogout} />
|
||||||
|
|||||||
BIN
src/assets/images/landing_page_image.jpg
Normal file
BIN
src/assets/images/landing_page_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@ -8,6 +8,7 @@
|
|||||||
// Images
|
// Images
|
||||||
export { default as ReLogo } from './images/Re_Logo.png';
|
export { default as ReLogo } from './images/Re_Logo.png';
|
||||||
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
|
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
|
||||||
|
export { default as LandingPageImage } from './images/landing_page_image.jpg';
|
||||||
|
|
||||||
// Fonts
|
// Fonts
|
||||||
// Add font exports here when fonts are added to the assets/fonts folder
|
// Add font exports here when fonts are added to the assets/fonts folder
|
||||||
|
|||||||
464
src/components/admin/ActivityTypeManager.tsx
Normal file
464
src/components/admin/ActivityTypeManager.tsx
Normal file
@ -0,0 +1,464 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -31,7 +31,7 @@ export function AnalyticsConfig() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save configuration
|
|
||||||
toast.success('Analytics configuration saved successfully');
|
toast.success('Analytics configuration saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
export type Role = 'Initiator' | 'Approver' | 'Spectator';
|
export type Role = 'Initiator' | 'Approver' | 'Spectator';
|
||||||
|
|
||||||
export type KPICard =
|
export type KPICard =
|
||||||
| 'Total Requests'
|
| 'Total Requests'
|
||||||
| 'Open Requests'
|
| 'Open Requests'
|
||||||
| 'Approved Requests'
|
| 'Approved Requests'
|
||||||
@ -59,7 +59,7 @@ export function DashboardConfig() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save dashboard configuration
|
|
||||||
toast.success('Dashboard layout saved successfully');
|
toast.success('Dashboard layout saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,7 @@ export function NotificationConfig() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save notification configuration
|
|
||||||
toast.success('Notification configuration saved successfully');
|
toast.success('Notification configuration saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function SharingConfig() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
// TODO: Implement API call to save sharing configuration
|
|
||||||
toast.success('Sharing policy saved successfully');
|
toast.success('Sharing policy saved successfully');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -2,18 +2,18 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Users,
|
Users,
|
||||||
Shield,
|
Shield,
|
||||||
Loader2,
|
Loader2,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@ -75,7 +75,7 @@ export function UserManagement() {
|
|||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
|
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
|
||||||
|
|
||||||
// Pagination and filtering
|
// Pagination and filtering
|
||||||
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@ -135,14 +135,14 @@ export function UserManagement() {
|
|||||||
// We'll search with a broader filter to find the user
|
// We'll search with a broader filter to find the user
|
||||||
const response = await userApi.getUsersByRole('ALL', 1, 1000);
|
const response = await userApi.getUsersByRole('ALL', 1, 1000);
|
||||||
const allUsers = response.data?.data?.users || [];
|
const allUsers = response.data?.data?.users || [];
|
||||||
const foundUser = allUsers.find((u: any) =>
|
const foundUser = allUsers.find((u: any) =>
|
||||||
u.email?.toLowerCase() === email.toLowerCase()
|
u.email?.toLowerCase() === email.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (foundUser && foundUser.role) {
|
if (foundUser && foundUser.role) {
|
||||||
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
|
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null; // User not found in system, no role assigned
|
return null; // User not found in system, no role assigned
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch user role:', error);
|
console.error('Failed to fetch user role:', error);
|
||||||
@ -156,7 +156,7 @@ export function UserManagement() {
|
|||||||
setSearchQuery(user.email);
|
setSearchQuery(user.email);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setFetchingRole(true);
|
setFetchingRole(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch and set the user's current role if they have one
|
// Fetch and set the user's current role if they have one
|
||||||
const currentRole = await fetchUserRole(user.email);
|
const currentRole = await fetchUserRole(user.email);
|
||||||
@ -186,7 +186,7 @@ export function UserManagement() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await userApi.assignRole(selectedUser.email, selectedRole);
|
await userApi.assignRole(selectedUser.email, selectedRole);
|
||||||
|
|
||||||
setMessage({
|
setMessage({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
|
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
|
||||||
@ -200,7 +200,7 @@ export function UserManagement() {
|
|||||||
// Refresh the users list
|
// Refresh the users list
|
||||||
await fetchUsers();
|
await fetchUsers();
|
||||||
await fetchRoleStatistics();
|
await fetchRoleStatistics();
|
||||||
|
|
||||||
toast.success(`Role assigned successfully`);
|
toast.success(`Role assigned successfully`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Role assignment failed:', error);
|
console.error('Role assignment failed:', error);
|
||||||
@ -220,7 +220,7 @@ export function UserManagement() {
|
|||||||
setLoadingUsers(true);
|
setLoadingUsers(true);
|
||||||
try {
|
try {
|
||||||
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||||
|
|
||||||
const usersData = response.data?.data?.users || [];
|
const usersData = response.data?.data?.users || [];
|
||||||
const paginationData = response.data?.data?.pagination;
|
const paginationData = response.data?.data?.pagination;
|
||||||
const summaryData = response.data?.data?.summary;
|
const summaryData = response.data?.data?.summary;
|
||||||
@ -234,13 +234,13 @@ export function UserManagement() {
|
|||||||
designation: u.designation,
|
designation: u.designation,
|
||||||
isActive: u.isActive !== false // Default to true if not specified
|
isActive: u.isActive !== false // Default to true if not specified
|
||||||
})));
|
})));
|
||||||
|
|
||||||
if (paginationData) {
|
if (paginationData) {
|
||||||
setCurrentPage(paginationData.currentPage);
|
setCurrentPage(paginationData.currentPage);
|
||||||
setTotalPages(paginationData.totalPages);
|
setTotalPages(paginationData.totalPages);
|
||||||
setTotalUsers(paginationData.totalUsers);
|
setTotalUsers(paginationData.totalUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update summary stats if available
|
// Update summary stats if available
|
||||||
if (summaryData) {
|
if (summaryData) {
|
||||||
setRoleStats(prev => ({
|
setRoleStats(prev => ({
|
||||||
@ -264,13 +264,13 @@ export function UserManagement() {
|
|||||||
try {
|
try {
|
||||||
const response = await userApi.getRoleStatistics();
|
const response = await userApi.getRoleStatistics();
|
||||||
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
||||||
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
|
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
|
||||||
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
|
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
|
||||||
};
|
};
|
||||||
|
|
||||||
setRoleStats(prev => ({
|
setRoleStats(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
...stats,
|
...stats,
|
||||||
@ -317,8 +317,8 @@ export function UserManagement() {
|
|||||||
const handleToggleUserStatus = async (userId: string) => {
|
const handleToggleUserStatus = async (userId: string) => {
|
||||||
const user = users.find(u => u.userId === userId);
|
const user = users.find(u => u.userId === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
// TODO: Implement backend API for toggling user status
|
|
||||||
toast.info('User status toggle functionality coming soon');
|
toast.info('User status toggle functionality coming soon');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -326,13 +326,12 @@ export function UserManagement() {
|
|||||||
const handleDeleteUser = async (userId: string) => {
|
const handleDeleteUser = async (userId: string) => {
|
||||||
const user = users.find(u => u.userId === userId);
|
const user = users.find(u => u.userId === userId);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
|
|
||||||
if (user.role === 'ADMIN') {
|
if (user.role === 'ADMIN') {
|
||||||
toast.error('Cannot delete admin user');
|
toast.error('Cannot delete admin user');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement backend API for deleting users
|
|
||||||
toast.info('User deletion functionality coming soon');
|
toast.info('User deletion functionality coming soon');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -515,11 +514,10 @@ export function UserManagement() {
|
|||||||
|
|
||||||
{/* Message */}
|
{/* Message */}
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`border-2 rounded-lg p-4 ${
|
<div className={`border-2 rounded-lg p-4 ${message.type === 'success'
|
||||||
message.type === 'success'
|
? 'border-green-200 bg-green-50'
|
||||||
? 'border-green-200 bg-green-50'
|
: 'border-red-200 bg-red-50'
|
||||||
: 'border-red-200 bg-red-50'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
{message.type === 'success' ? (
|
{message.type === 'success' ? (
|
||||||
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
@ -602,7 +600,7 @@ export function UserManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="font-medium text-gray-700">No users found</p>
|
<p className="font-medium text-gray-700">No users found</p>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
{roleFilter === 'ELEVATED'
|
{roleFilter === 'ELEVATED'
|
||||||
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
||||||
: 'No users match the selected filter'
|
: 'No users match the selected filter'
|
||||||
}
|
}
|
||||||
@ -664,11 +662,10 @@ export function UserManagement() {
|
|||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handlePageChange(pageNum)}
|
onClick={() => handlePageChange(pageNum)}
|
||||||
className={`w-9 h-9 p-0 ${
|
className={`w-9 h-9 p-0 ${currentPage === pageNum
|
||||||
currentPage === pageNum
|
? 'bg-re-green hover:bg-re-green/90'
|
||||||
? 'bg-re-green hover:bg-re-green/90'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{pageNum}
|
{pageNum}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
export { ConfigurationManager } from './ConfigurationManager';
|
export { ConfigurationManager } from './ConfigurationManager';
|
||||||
export { HolidayManager } from './HolidayManager';
|
export { HolidayManager } from './HolidayManager';
|
||||||
|
export { ActivityTypeManager } from './ActivityTypeManager';
|
||||||
|
|
||||||
|
|||||||
194
src/components/common/AntivirusScanStatus.tsx
Normal file
194
src/components/common/AntivirusScanStatus.tsx
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* AntivirusScanStatus Component
|
||||||
|
* Displays the antivirus scan result badge/status for uploaded files.
|
||||||
|
* Shows ClamAV scan result and XSS content scan result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export interface ScanResultData {
|
||||||
|
malwareScan?: {
|
||||||
|
scanned: boolean;
|
||||||
|
isInfected: boolean;
|
||||||
|
skipped?: boolean;
|
||||||
|
virusNames?: string[];
|
||||||
|
scanDuration?: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
contentScan?: {
|
||||||
|
scanned: boolean;
|
||||||
|
safe: boolean;
|
||||||
|
scanType: string;
|
||||||
|
severity: 'SAFE' | 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
|
threats?: Array<{ description: string; severity: string }>;
|
||||||
|
patternsChecked: number;
|
||||||
|
};
|
||||||
|
scanEventId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AntivirusScanStatusProps {
|
||||||
|
scanResult?: ScanResultData;
|
||||||
|
compact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
|
||||||
|
function getStatusColor(result?: ScanResultData): string {
|
||||||
|
if (!result) return '#94a3b8'; // gray — no scan data
|
||||||
|
|
||||||
|
// Check malware first
|
||||||
|
if (result.malwareScan?.isInfected) return '#ef4444'; // red
|
||||||
|
if (result.malwareScan?.error) return '#f59e0b'; // amber
|
||||||
|
|
||||||
|
// Then XSS
|
||||||
|
if (result.contentScan && !result.contentScan.safe) {
|
||||||
|
if (result.contentScan.severity === 'CRITICAL') return '#ef4444';
|
||||||
|
if (result.contentScan.severity === 'HIGH') return '#ef4444';
|
||||||
|
if (result.contentScan.severity === 'MEDIUM') return '#f59e0b';
|
||||||
|
return '#f59e0b';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skipped
|
||||||
|
if (result.malwareScan?.skipped) return '#94a3b8';
|
||||||
|
|
||||||
|
return '#22c55e'; // green — all clear
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusIcon(result?: ScanResultData): string {
|
||||||
|
if (!result) return '⏳';
|
||||||
|
if (result.malwareScan?.isInfected) return '🛑';
|
||||||
|
if (result.contentScan && !result.contentScan.safe) return '⚠️';
|
||||||
|
if (result.malwareScan?.skipped) return '⏭️';
|
||||||
|
if (result.malwareScan?.error) return '❌';
|
||||||
|
if (result.malwareScan?.scanned && result.contentScan?.scanned) return '✅';
|
||||||
|
return '⏳';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(result?: ScanResultData): string {
|
||||||
|
if (!result) return 'Pending scan';
|
||||||
|
if (result.malwareScan?.isInfected) return 'Malware detected';
|
||||||
|
if (result.contentScan && !result.contentScan.safe) return 'Content threat detected';
|
||||||
|
if (result.malwareScan?.skipped) return 'Scan skipped';
|
||||||
|
if (result.malwareScan?.error) return 'Scan error';
|
||||||
|
if (result.malwareScan?.scanned && result.contentScan?.scanned) return 'Clean';
|
||||||
|
return 'Scanning…';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Component ──
|
||||||
|
|
||||||
|
const AntivirusScanStatus: React.FC<AntivirusScanStatusProps> = ({
|
||||||
|
scanResult,
|
||||||
|
compact = false,
|
||||||
|
className = '',
|
||||||
|
}) => {
|
||||||
|
const color = getStatusColor(scanResult);
|
||||||
|
const icon = getStatusIcon(scanResult);
|
||||||
|
const label = getStatusLabel(scanResult);
|
||||||
|
|
||||||
|
// Compact mode: just a badge
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
backgroundColor: `${color}15`,
|
||||||
|
color,
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
}}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '11px' }}>{icon}</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full mode: detailed card
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={{
|
||||||
|
border: `1px solid ${color}30`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
backgroundColor: `${color}08`,
|
||||||
|
fontSize: '13px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
|
||||||
|
<span style={{ fontSize: '16px' }}>{icon}</span>
|
||||||
|
<span style={{ fontWeight: 600, color }}>{label}</span>
|
||||||
|
{scanResult?.malwareScan?.scanDuration && (
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: '11px', color: '#94a3b8' }}>
|
||||||
|
{scanResult.malwareScan.scanDuration}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
{scanResult && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||||
|
{/* ClamAV Result */}
|
||||||
|
{scanResult.malwareScan?.scanned && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
|
||||||
|
<span>🦠</span>
|
||||||
|
<span>
|
||||||
|
ClamAV:{' '}
|
||||||
|
{scanResult.malwareScan.isInfected
|
||||||
|
? `Infected — ${scanResult.malwareScan.virusNames?.join(', ')}`
|
||||||
|
: 'Clean'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* XSS Result */}
|
||||||
|
{scanResult.contentScan?.scanned && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', fontSize: '12px', color: '#64748b' }}>
|
||||||
|
<span>🔍</span>
|
||||||
|
<span>
|
||||||
|
Content scan ({scanResult.contentScan.scanType}):{' '}
|
||||||
|
{scanResult.contentScan.safe
|
||||||
|
? `Safe — ${scanResult.contentScan.patternsChecked} patterns checked`
|
||||||
|
: `${scanResult.contentScan.threats?.length || 0} threats found (${scanResult.contentScan.severity})`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Threats list */}
|
||||||
|
{scanResult.contentScan?.threats && scanResult.contentScan.threats.length > 0 && (
|
||||||
|
<ul style={{ margin: '4px 0 0 24px', padding: 0, fontSize: '11px', color: '#ef4444' }}>
|
||||||
|
{scanResult.contentScan.threats.slice(0, 5).map((threat, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
{threat.description} ({threat.severity})
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{scanResult.contentScan.threats.length > 5 && (
|
||||||
|
<li>…and {scanResult.contentScan.threats.length - 5} more</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Scan event ID */}
|
||||||
|
{scanResult.scanEventId && (
|
||||||
|
<div style={{ fontSize: '10px', color: '#94a3b8', marginTop: '4px' }}>
|
||||||
|
Scan ID: {scanResult.scanEventId}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AntivirusScanStatus;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cn } from "@/components/ui/utils";
|
import { cn } from "@/components/ui/utils";
|
||||||
|
import { sanitizeHTML } from "@/utils/sanitizer";
|
||||||
|
|
||||||
interface FormattedDescriptionProps {
|
interface FormattedDescriptionProps {
|
||||||
content: string;
|
content: string;
|
||||||
@ -15,25 +16,26 @@ interface FormattedDescriptionProps {
|
|||||||
export function FormattedDescription({ content, className }: FormattedDescriptionProps) {
|
export function FormattedDescription({ content, className }: FormattedDescriptionProps) {
|
||||||
const processedContent = React.useMemo(() => {
|
const processedContent = React.useMemo(() => {
|
||||||
if (!content) return '';
|
if (!content) return '';
|
||||||
|
|
||||||
// Wrap tables that aren't already wrapped in a scrollable container using regex
|
// Wrap tables that aren't already wrapped in a scrollable container using regex
|
||||||
// Match <table> tags that aren't already inside a .table-wrapper
|
// Match <table> tags that aren't already inside a .table-wrapper
|
||||||
let processed = content;
|
let processed = content;
|
||||||
|
|
||||||
// Pattern to match table tags that aren't already wrapped
|
// Pattern to match table tags that aren't already wrapped
|
||||||
const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi;
|
const tablePattern = /<table[^>]*>[\s\S]*?<\/table>/gi;
|
||||||
|
|
||||||
processed = processed.replace(tablePattern, (match) => {
|
processed = processed.replace(tablePattern, (match) => {
|
||||||
// Check if this table is already wrapped
|
// Check if this table is already wrapped
|
||||||
if (match.includes('table-wrapper')) {
|
if (match.includes('table-wrapper')) {
|
||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap the table in a scrollable container
|
// Wrap the table in a scrollable container
|
||||||
return `<div class="table-wrapper" style="overflow-x: auto; max-width: 100%; margin: 8px 0;">${match}</div>`;
|
return `<div class="table-wrapper">${match}</div>`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return processed;
|
// Sanitize the content to prevent CSP violations (onclick, style tags, etc.)
|
||||||
|
return sanitizeHTML(processed);
|
||||||
}, [content]);
|
}, [content]);
|
||||||
|
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { ReLogo } from '@/assets';
|
|||||||
import notificationApi, { Notification } from '@/services/notificationApi';
|
import notificationApi, { Notification } from '@/services/notificationApi';
|
||||||
import { getSocket, joinUserRoom } from '@/utils/socket';
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -35,7 +36,18 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Check if user is a Dealer
|
||||||
|
const isDealer = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
return userData?.jobTitle === 'Dealer';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PageLayout] Error checking dealer status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Get user initials for avatar
|
// Get user initials for avatar
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
try {
|
try {
|
||||||
@ -55,24 +67,28 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
return 'U';
|
return 'U';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const menuItems = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const items = [
|
const items = [
|
||||||
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
||||||
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
|
||||||
{ id: 'requests', label: 'All Requests', icon: List },
|
{ id: 'requests', label: 'All Requests', icon: List, adminOnly: false }
|
||||||
|
// { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add remaining menu items
|
// Add remaining menu items (exclude "My Requests" for dealers)
|
||||||
|
if (!isDealer) {
|
||||||
|
items.push({ id: 'my-requests', label: 'My Requests', icon: User });
|
||||||
|
}
|
||||||
|
|
||||||
items.push(
|
items.push(
|
||||||
{ id: 'my-requests', label: 'My Requests', icon: User },
|
|
||||||
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||||
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
|
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
|
||||||
);
|
);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}, []);
|
}, [isDealer]);
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarOpen(!sidebarOpen);
|
setSidebarOpen(!sidebarOpen);
|
||||||
@ -83,7 +99,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
// Mark as read
|
// Mark as read
|
||||||
if (!notification.isRead) {
|
if (!notification.isRead) {
|
||||||
await notificationApi.markAsRead(notification.notificationId);
|
await notificationApi.markAsRead(notification.notificationId);
|
||||||
setNotifications(prev =>
|
setNotifications(prev =>
|
||||||
prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n)
|
prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n)
|
||||||
);
|
);
|
||||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
@ -96,14 +112,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
if (requestNumber) {
|
if (requestNumber) {
|
||||||
// Determine which tab to open based on notification type
|
// Determine which tab to open based on notification type
|
||||||
let navigationUrl = `request/${requestNumber}`;
|
let navigationUrl = `request/${requestNumber}`;
|
||||||
|
|
||||||
// Work note related notifications should open Work Notes tab
|
// Work note related notifications should open Work Notes tab
|
||||||
if (notification.notificationType === 'mention' ||
|
if (notification.notificationType === 'mention' ||
|
||||||
notification.notificationType === 'comment' ||
|
notification.notificationType === 'comment' ||
|
||||||
notification.notificationType === 'worknote') {
|
notification.notificationType === 'worknote') {
|
||||||
navigationUrl += '?tab=worknotes';
|
navigationUrl += '?tab=worknotes';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigate to request detail page
|
// Navigate to request detail page
|
||||||
onNavigate(navigationUrl);
|
onNavigate(navigationUrl);
|
||||||
}
|
}
|
||||||
@ -137,7 +153,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
try {
|
try {
|
||||||
const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false });
|
const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false });
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
const notifs = result.data?.notifications || [];
|
const notifs = result.data?.notifications || [];
|
||||||
setNotifications(notifs);
|
setNotifications(notifs);
|
||||||
setUnreadCount(result.data?.unreadCount || 0);
|
setUnreadCount(result.data?.unreadCount || 0);
|
||||||
@ -158,7 +174,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
// Listen for new notifications
|
// Listen for new notifications
|
||||||
const handleNewNotification = (data: { notification: Notification }) => {
|
const handleNewNotification = (data: { notification: Notification }) => {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
||||||
setUnreadCount(prev => prev + 1);
|
setUnreadCount(prev => prev + 1);
|
||||||
};
|
};
|
||||||
@ -199,7 +215,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
<div className="min-h-screen flex w-full bg-background">
|
<div className="min-h-screen flex w-full bg-background">
|
||||||
{/* Mobile Overlay */}
|
{/* Mobile Overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||||
onClick={() => setSidebarOpen(false)}
|
onClick={() => setSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
@ -223,9 +239,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
|
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
|
||||||
<div className="p-4 border-b border-gray-800 flex-shrink-0">
|
<div className="p-4 border-b border-gray-800 flex-shrink-0">
|
||||||
<div className="flex flex-col items-center justify-center">
|
<div className="flex flex-col items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={ReLogo}
|
src={ReLogo}
|
||||||
alt="Royal Enfield Logo"
|
alt="Royal Enfield Logo"
|
||||||
className="h-10 w-auto max-w-[168px] object-contain"
|
className="h-10 w-auto max-w-[168px] object-contain"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 text-center mt-1 truncate">RE Flow</p>
|
<p className="text-xs text-gray-400 text-center mt-1 truncate">RE Flow</p>
|
||||||
@ -233,39 +249,44 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex-1 overflow-y-auto">
|
<div className="p-3 flex-1 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{menuItems.map((item) => (
|
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onNavigate?.(item.id);
|
if (item.id === 'admin/templates') {
|
||||||
|
onNavigate?.('admin/templates');
|
||||||
|
} else {
|
||||||
|
onNavigate?.(item.id);
|
||||||
|
}
|
||||||
// Close sidebar on mobile after navigation
|
// Close sidebar on mobile after navigation
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
|
||||||
currentPage === item.id
|
? 'bg-re-green text-white font-medium'
|
||||||
? 'bg-re-green text-white font-medium'
|
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<item.icon className="w-4 h-4 shrink-0" />
|
<item.icon className="w-4 h-4 shrink-0" />
|
||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Action in Sidebar - Right below menu items */}
|
{/* Quick Action in Sidebar - Right below menu items */}
|
||||||
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
{!isDealer && (
|
||||||
<Button
|
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
||||||
onClick={onNewRequest}
|
<Button
|
||||||
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
|
onClick={onNewRequest}
|
||||||
size="sm"
|
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
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Raise New Request
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@ -275,14 +296,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="h-16 border-b border-gray-200 bg-white flex items-center justify-between px-6 shrink-0">
|
<header className="h-16 border-b border-gray-200 bg-white flex items-center justify-between px-6 shrink-0">
|
||||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
className="shrink-0 h-10 w-10 sidebar-toggle"
|
className="shrink-0 h-10 w-10 sidebar-toggle"
|
||||||
>
|
>
|
||||||
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
|
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
|
||||||
</Button>
|
</Button>
|
||||||
{/* Search bar commented out */}
|
{/* Search bar commented out */}
|
||||||
{/* <div className="relative max-w-md flex-1">
|
{/* <div className="relative max-w-md flex-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
@ -294,14 +315,16 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4 shrink-0">
|
<div className="flex items-center gap-4 shrink-0">
|
||||||
<Button
|
{!isDealer && (
|
||||||
onClick={onNewRequest}
|
<Button
|
||||||
className="bg-re-green hover:bg-re-green/90 text-white gap-2 hidden md:flex text-sm"
|
onClick={onNewRequest}
|
||||||
size="sm"
|
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
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
New Request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@ -342,9 +365,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{notifications.map((notif) => (
|
{notifications.map((notif) => (
|
||||||
<div
|
<div
|
||||||
key={notif.notificationId}
|
key={notif.notificationId}
|
||||||
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${
|
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${!notif.isRead ? 'bg-blue-50' : ''
|
||||||
!notif.isRead ? 'bg-blue-50' : ''
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => handleNotificationClick(notif)}
|
onClick={() => handleNotificationClick(notif)}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@ -403,8 +425,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
<Settings className="w-4 h-4 mr-2" />
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => setShowLogoutDialog(true)}
|
onClick={() => setShowLogoutDialog(true)}
|
||||||
className="text-red-600 focus:text-red-600"
|
className="text-red-600 focus:text-red-600"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@ -12,12 +12,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
|||||||
import { Switch } from '../ui/switch';
|
import { Switch } from '../ui/switch';
|
||||||
import { Calendar } from '../ui/calendar';
|
import { Calendar } from '../ui/calendar';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Calendar as CalendarIcon,
|
Calendar as CalendarIcon,
|
||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
FileText,
|
FileText,
|
||||||
Check,
|
Check,
|
||||||
Users
|
Users
|
||||||
@ -150,7 +150,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
onChange={(e) => updateFormData('title', e.target.value)}
|
onChange={(e) => updateFormData('title', e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="description">Description *</Label>
|
<Label htmlFor="description">Description *</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@ -183,7 +183,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="w-full justify-start text-left">
|
<Button variant="outline" className="w-full justify-start text-left">
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||||
{formData.slaEndDate ? format(formData.slaEndDate, 'PPP') : 'Pick a date'}
|
{formData.slaEndDate ? format(formData.slaEndDate, 'd MMM yyyy') : 'Pick a date'}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0">
|
<PopoverContent className="w-auto p-0">
|
||||||
@ -215,9 +215,9 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
|
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
|
||||||
{formData.workflowType === 'sequential'
|
{formData.workflowType === 'sequential'
|
||||||
? 'Approvers will review the request one after another in the order you specify.'
|
? 'Approvers will review the request one after another in the order you specify.'
|
||||||
: 'All approvers will review the request simultaneously.'
|
: 'All approvers will review the request simultaneously.'
|
||||||
}
|
}
|
||||||
@ -311,7 +311,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{availableUsers
|
{availableUsers
|
||||||
.filter(user =>
|
.filter(user =>
|
||||||
!formData.spectators.find(s => s.id === user.id) &&
|
!formData.spectators.find(s => s.id === user.id) &&
|
||||||
!formData.approvers.find(a => a.id === user.id)
|
!formData.approvers.find(a => a.id === user.id)
|
||||||
)
|
)
|
||||||
@ -378,7 +378,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
|||||||
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
||||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
Drag and drop files here, or click to browse
|
click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
|
|||||||
@ -1,21 +1,23 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../ui/dialog';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import {
|
import {
|
||||||
Receipt,
|
Receipt,
|
||||||
Package,
|
Package,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Target,
|
Target,
|
||||||
X,
|
|
||||||
Sparkles,
|
Sparkles,
|
||||||
Check
|
Check,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { TokenManager } from '../../utils/tokenManager';
|
||||||
|
|
||||||
interface TemplateSelectionModalProps {
|
interface TemplateSelectionModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -39,7 +41,8 @@ const AVAILABLE_TEMPLATES = [
|
|||||||
'Document verification',
|
'Document verification',
|
||||||
'E-invoice generation',
|
'E-invoice generation',
|
||||||
'Credit note issuance'
|
'Credit note issuance'
|
||||||
]
|
],
|
||||||
|
disabled: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'vendor-payment',
|
id: 'vendor-payment',
|
||||||
@ -55,14 +58,32 @@ const AVAILABLE_TEMPLATES = [
|
|||||||
'Invoice verification',
|
'Invoice verification',
|
||||||
'Multi-level approvals',
|
'Multi-level approvals',
|
||||||
'Payment scheduling'
|
'Payment scheduling'
|
||||||
]
|
],
|
||||||
|
disabled: true,
|
||||||
|
comingSoon: true
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
|
export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: TemplateSelectionModalProps) {
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<string | null>(null);
|
||||||
|
const [isDealer, setIsDealer] = useState(false);
|
||||||
|
|
||||||
|
// Check if user is a Dealer
|
||||||
|
useEffect(() => {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
setIsDealer(userData?.jobTitle === 'Dealer');
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSelect = (templateId: string) => {
|
const handleSelect = (templateId: string) => {
|
||||||
|
// Don't allow selection if user is a dealer
|
||||||
|
if (isDealer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Don't allow selection if template is disabled
|
||||||
|
const template = AVAILABLE_TEMPLATES.find(t => t.id === templateId);
|
||||||
|
if (template?.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setSelectedTemplate(templateId);
|
setSelectedTemplate(templateId);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -75,7 +96,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onClose}>
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
|
className="!fixed !inset-0 !top-0 !left-0 !right-0 !bottom-0 !w-screen !h-screen !max-w-none !translate-x-0 !translate-y-0 p-0 gap-0 border-0 !rounded-none bg-gradient-to-br from-gray-50 to-white [&>button]:hidden !m-0"
|
||||||
>
|
>
|
||||||
{/* Accessibility - Hidden Title and Description */}
|
{/* Accessibility - Hidden Title and Description */}
|
||||||
@ -84,19 +105,20 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
|
Choose from pre-configured templates with predefined workflows and approval chains for faster processing.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
||||||
{/* Custom Close button */}
|
{/* Back arrow button - Top left */}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-gray-600" />
|
<ArrowLeft className="w-5 h-5 text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Full Screen Content Container */}
|
{/* Full Screen Content Container */}
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
|
<div className="min-h-full flex flex-col items-center justify-center px-6 py-12">
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
className="text-center mb-12 max-w-3xl"
|
className="text-center mb-12 max-w-3xl"
|
||||||
@ -117,6 +139,7 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
{AVAILABLE_TEMPLATES.map((template, index) => {
|
{AVAILABLE_TEMPLATES.map((template, index) => {
|
||||||
const Icon = template.icon;
|
const Icon = template.icon;
|
||||||
const isSelected = selectedTemplate === template.id;
|
const isSelected = selectedTemplate === template.id;
|
||||||
|
const isDisabled = isDealer || template.disabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@ -124,15 +147,16 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: index * 0.1 }}
|
transition={{ delay: index * 0.1 }}
|
||||||
whileHover={{ scale: 1.03 }}
|
whileHover={isDisabled ? {} : { scale: 1.03 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={isDisabled ? {} : { scale: 0.98 }}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
|
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
||||||
isSelected
|
? 'opacity-50 cursor-not-allowed border-gray-200'
|
||||||
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
: isSelected
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
? 'cursor-pointer border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
||||||
}`}
|
: 'cursor-pointer border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
||||||
|
}`}
|
||||||
onClick={() => handleSelect(template.id)}
|
onClick={() => handleSelect(template.id)}
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-4 pb-4">
|
<CardHeader className="space-y-4 pb-4">
|
||||||
@ -157,6 +181,22 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
<CardDescription className="text-sm leading-relaxed">
|
<CardDescription className="text-sm leading-relaxed">
|
||||||
{template.description}
|
{template.description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
|
{isDealer && (
|
||||||
|
<div className="mt-3 flex items-start gap-2 p-2 bg-amber-50 border border-amber-200 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
Not accessible for Dealers
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{template.comingSoon && !isDealer && (
|
||||||
|
<div className="mt-3 flex items-start gap-2 p-2 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<AlertCircle className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-blue-800 font-semibold">
|
||||||
|
Coming Soon
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-0 space-y-4">
|
<CardContent className="pt-0 space-y-4">
|
||||||
@ -165,9 +205,9 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
{template.category}
|
{template.category}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
@ -203,29 +243,28 @@ export function TemplateSelectionModal({ open, onClose, onSelectTemplate }: Temp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ delay: 0.3 }}
|
transition={{ delay: 0.3 }}
|
||||||
className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
|
className="flex flex-col sm:flex-row justify-center gap-4 mt-4"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="px-8"
|
className="px-8"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleContinue}
|
onClick={handleContinue}
|
||||||
disabled={!selectedTemplate}
|
disabled={!selectedTemplate || isDealer || AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled}
|
||||||
size="lg"
|
size="lg"
|
||||||
className={`gap-2 px-8 ${
|
className={`gap-2 px-8 ${selectedTemplate && !isDealer && !AVAILABLE_TEMPLATES.find(t => t.id === selectedTemplate)?.disabled
|
||||||
selectedTemplate
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
: 'bg-gray-400 cursor-not-allowed'
|
||||||
: 'bg-gray-400'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Continue with Template
|
Continue with Template
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||||
|
import { sanitizeHTML } from '../../utils/sanitizer';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
import {
|
import {
|
||||||
Send,
|
Send,
|
||||||
Smile,
|
Smile,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
MoreHorizontal
|
MoreHorizontal
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@ -166,7 +167,8 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
// Simple mention highlighting
|
// Simple mention highlighting
|
||||||
return content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
|
const formatted = content.replace(/@(\w+\s?\w+)/g, '<span class="text-blue-600 font-medium">@$1</span>');
|
||||||
|
return sanitizeHTML(formatted);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -187,7 +189,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<TabsTrigger value="chat">Chat</TabsTrigger>
|
<TabsTrigger value="chat">Chat</TabsTrigger>
|
||||||
<TabsTrigger value="media">Media</TabsTrigger>
|
<TabsTrigger value="media">Media</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="chat" className="flex-1 flex flex-col">
|
<TabsContent value="chat" className="flex-1 flex flex-col">
|
||||||
<ScrollArea className="flex-1 p-4 border rounded-lg">
|
<ScrollArea className="flex-1 p-4 border rounded-lg">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -195,16 +197,15 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
|
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : ''}`}>
|
||||||
{!msg.isSystem && (
|
{!msg.isSystem && (
|
||||||
<Avatar className="h-8 w-8 flex-shrink-0">
|
<Avatar className="h-8 w-8 flex-shrink-0">
|
||||||
<AvatarFallback className={`text-white text-xs ${
|
<AvatarFallback className={`text-white text-xs ${msg.user.role === 'Initiator' ? 'bg-re-green' :
|
||||||
msg.user.role === 'Initiator' ? 'bg-re-green' :
|
|
||||||
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
||||||
'bg-re-light-green'
|
'bg-re-light-green'
|
||||||
}`}>
|
}`}>
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
|
<div className={`flex-1 ${msg.isSystem ? 'text-center' : ''}`}>
|
||||||
{msg.isSystem ? (
|
{msg.isSystem ? (
|
||||||
<div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground">
|
<div className="inline-flex items-center gap-2 px-3 py-1 bg-muted rounded-full text-sm text-muted-foreground">
|
||||||
@ -222,7 +223,7 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
{msg.timestamp}
|
{msg.timestamp}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="text-sm bg-muted/30 p-3 rounded-lg"
|
className="text-sm bg-muted/30 p-3 rounded-lg"
|
||||||
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
|
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
|
||||||
/>
|
/>
|
||||||
@ -300,15 +301,14 @@ export function WorkNoteModal({ open, onClose, requestId }: WorkNoteModalProps)
|
|||||||
<h4 className="font-medium">Participants</h4>
|
<h4 className="font-medium">Participants</h4>
|
||||||
<Badge variant="outline">{participants.length}</Badge>
|
<Badge variant="outline">{participants.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{participants.map((participant, index) => (
|
{participants.map((participant, index) => (
|
||||||
<div key={index} className="flex items-center gap-3">
|
<div key={index} className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
<AvatarFallback className={`text-white text-xs ${
|
<AvatarFallback className={`text-white text-xs ${participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
|
||||||
participant.role === 'Initiator' ? 'bg-re-green' : 'bg-re-light-green'
|
}`}>
|
||||||
}`}>
|
|
||||||
{participant.avatar}
|
{participant.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
|
|||||||
@ -24,6 +24,8 @@ interface AddApproverModalProps {
|
|||||||
requestTitle?: string;
|
requestTitle?: string;
|
||||||
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
existingParticipants?: Array<{ email: string; participantType: string; name?: string }>;
|
||||||
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
currentLevels?: ApprovalLevelInfo[]; // Current approval levels
|
||||||
|
maxApprovalLevels?: number; // Maximum allowed approval levels from system policy
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddApproverModal({
|
export function AddApproverModal({
|
||||||
@ -31,7 +33,9 @@ export function AddApproverModal({
|
|||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
existingParticipants = [],
|
existingParticipants = [],
|
||||||
currentLevels = []
|
currentLevels = [],
|
||||||
|
maxApprovalLevels,
|
||||||
|
onPolicyViolation
|
||||||
}: AddApproverModalProps) {
|
}: AddApproverModalProps) {
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [tatHours, setTatHours] = useState<number>(24);
|
const [tatHours, setTatHours] = useState<number>(24);
|
||||||
@ -140,6 +144,36 @@ export function AddApproverModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate against maxApprovalLevels policy
|
||||||
|
// Calculate the new total levels after adding this approver
|
||||||
|
// If inserting at a level that already exists, levels shift down, so total stays same
|
||||||
|
// If inserting at a new level (beyond current), total increases
|
||||||
|
const currentMaxLevel = currentLevels.length > 0
|
||||||
|
? Math.max(...currentLevels.map(l => l.levelNumber), 0)
|
||||||
|
: 0;
|
||||||
|
const newTotalLevels = selectedLevel > currentMaxLevel
|
||||||
|
? selectedLevel // New level beyond current max
|
||||||
|
: currentMaxLevel + 1; // Existing level, shifts everything down, adds one more
|
||||||
|
|
||||||
|
if (maxApprovalLevels && newTotalLevels > maxApprovalLevels) {
|
||||||
|
if (onPolicyViolation) {
|
||||||
|
onPolicyViolation([{
|
||||||
|
type: 'Maximum Approval Levels Exceeded',
|
||||||
|
message: `Adding an approver at level ${selectedLevel} would result in ${newTotalLevels} approval levels, which exceeds the maximum allowed (${maxApprovalLevels}). Please remove an approver or contact your administrator.`,
|
||||||
|
currentValue: newTotalLevels,
|
||||||
|
maxValue: maxApprovalLevels
|
||||||
|
}]);
|
||||||
|
} else {
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: `Cannot add approver. This would exceed the maximum allowed approval levels (${maxApprovalLevels}). Current request has ${currentMaxLevel} level(s).`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is already a participant
|
// Check if user is already a participant
|
||||||
const existingParticipant = existingParticipants.find(
|
const existingParticipant = existingParticipants.find(
|
||||||
p => (p.email || '').toLowerCase() === emailToAdd
|
p => (p.email || '').toLowerCase() === emailToAdd
|
||||||
@ -394,6 +428,20 @@ export function AddApproverModal({
|
|||||||
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Max Approval Levels Note */}
|
||||||
|
{maxApprovalLevels && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-2">
|
||||||
|
<p className="text-xs text-blue-800">
|
||||||
|
ℹ️ Max: {maxApprovalLevels} level{maxApprovalLevels !== 1 ? 's' : ''}
|
||||||
|
{currentLevels.length > 0 && (
|
||||||
|
<span className="ml-2">
|
||||||
|
({Math.max(...currentLevels.map(l => l.levelNumber), 0)}/{maxApprovalLevels})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current Levels Display */}
|
{/* Current Levels Display */}
|
||||||
{currentLevels.length > 0 && (
|
{currentLevels.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
297
src/components/settings/ApiTokenManager.tsx
Normal file
297
src/components/settings/ApiTokenManager.tsx
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Key, Plus, Trash2, Copy, Check } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import axios from '@/services/authApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ApiToken {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
lastUsedAt?: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiTokenManager() {
|
||||||
|
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [newTokenName, setNewTokenName] = useState('');
|
||||||
|
const [newTokenExpiry, setNewTokenExpiry] = useState<number | ''>('');
|
||||||
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [tokenToRevoke, setTokenToRevoke] = useState<ApiToken | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTokens();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchTokens = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
const response = await axios.get('/api-tokens');
|
||||||
|
setTokens(response.data.data.tokens);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch API tokens:', error);
|
||||||
|
toast.error('Failed to load API tokens');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateToken = async () => {
|
||||||
|
if (!newTokenName.trim()) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsCreating(true);
|
||||||
|
const payload: any = { name: newTokenName };
|
||||||
|
if (newTokenExpiry) {
|
||||||
|
payload.expiresInDays = Number(newTokenExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.post('/api-tokens', payload);
|
||||||
|
setGeneratedToken(response.data.data.token);
|
||||||
|
toast.success('API Token created successfully');
|
||||||
|
fetchTokens(); // Refresh list
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create token:', error);
|
||||||
|
toast.error('Failed to create API token');
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeToken = (token: ApiToken) => {
|
||||||
|
setTokenToRevoke(token);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmRevokeToken = async () => {
|
||||||
|
if (!tokenToRevoke) return;
|
||||||
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.delete(`/api-tokens/${tokenToRevoke.id}`);
|
||||||
|
toast.success('Token revoked successfully');
|
||||||
|
setTokens(tokens.filter(t => t.id !== tokenToRevoke.id));
|
||||||
|
setTokenToRevoke(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to revoke token:', error);
|
||||||
|
toast.error('Failed to revoke token');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = () => {
|
||||||
|
if (generatedToken) {
|
||||||
|
navigator.clipboard.writeText(generatedToken);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
toast.success('Token copied to clipboard');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetCreateModal = () => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setNewTokenName('');
|
||||||
|
setNewTokenExpiry('');
|
||||||
|
setGeneratedToken(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">API Tokens</h3>
|
||||||
|
<p className="text-sm text-gray-500">Manage personal access tokens for external integrations</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)} size="sm" className="bg-re-green hover:bg-re-green/90 text-white">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="text-center py-4 text-gray-500">Loading tokens...</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<div className="text-center py-8 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||||
|
<Key className="w-10 h-10 text-gray-300 mx-auto mb-2" />
|
||||||
|
<p className="text-gray-500 font-medium">No API tokens found</p>
|
||||||
|
<p className="text-gray-400 text-sm mt-1">Generate a token to access the API programmatically</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Prefix</TableHead>
|
||||||
|
<TableHead>Last Used</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<TableRow key={token.id}>
|
||||||
|
<TableCell className="font-medium">{token.name}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs bg-slate-100 rounded px-2 py-1 w-fit">{token.prefix}...</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{token.lastUsedAt ? format(new Date(token.lastUsedAt), 'MMM d, yyyy') : 'Never'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-gray-500 text-sm">
|
||||||
|
{token.expiresAt ? format(new Date(token.expiresAt), 'MMM d, yyyy') : 'No Expiry'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRevokeToken(token)}
|
||||||
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span className="sr-only">Revoke</span>
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Token Modal */}
|
||||||
|
<Dialog open={showCreateModal} onOpenChange={(open) => !open && resetCreateModal()}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Generate API Token</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Create a new token to access the API. Treat this token like a password.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{!generatedToken ? (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token-name">Token Name</Label>
|
||||||
|
<Input
|
||||||
|
id="token-name"
|
||||||
|
placeholder="e.g., CI/CD Pipeline, Prometheus"
|
||||||
|
value={newTokenName}
|
||||||
|
onChange={(e) => setNewTokenName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="token-expiry">Expiration (Days)</Label>
|
||||||
|
<Input
|
||||||
|
id="token-expiry"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
placeholder="Leave empty for no expiry"
|
||||||
|
value={newTokenExpiry}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
if (val === '') {
|
||||||
|
setNewTokenExpiry('');
|
||||||
|
} else {
|
||||||
|
const num = parseInt(val);
|
||||||
|
// Prevent negative numbers
|
||||||
|
if (!isNaN(num) && num >= 1) {
|
||||||
|
setNewTokenExpiry(num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<Alert className="bg-green-50 border-green-200">
|
||||||
|
<Check className="h-4 w-4 text-green-600" />
|
||||||
|
<AlertTitle className="text-green-800">Token Generated Successfully</AlertTitle>
|
||||||
|
<AlertDescription className="text-green-700">
|
||||||
|
Please copy your token now. You won't be able to see it again!
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="p-4 bg-slate-900 rounded-md font-mono text-sm text-green-400 break-all pr-10">
|
||||||
|
{generatedToken}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-1 right-1 text-gray-400 hover:text-white hover:bg-slate-800"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
{!generatedToken ? (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={resetCreateModal}>Cancel</Button>
|
||||||
|
<Button onClick={handleCreateToken} disabled={!newTokenName.trim() || isCreating}>
|
||||||
|
{isCreating ? 'Generating...' : 'Generate Token'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={resetCreateModal}>Done</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={!!tokenToRevoke} onOpenChange={(open) => !open && setTokenToRevoke(null)}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Revoke API Token</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to revoke the token <strong>{tokenToRevoke?.name}</strong>?
|
||||||
|
This action cannot be undone and any applications using this token will lose access immediately.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmRevokeToken} className="bg-red-600 hover:bg-red-700 text-white">
|
||||||
|
Revoke Token
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -54,13 +54,13 @@ function ChartContainer({
|
|||||||
<div
|
<div
|
||||||
data-slot="chart"
|
data-slot="chart"
|
||||||
data-chart={chartId}
|
data-chart={chartId}
|
||||||
|
style={getChartStyle(config)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChartStyle id={chartId} config={config} />
|
|
||||||
<RechartsPrimitive.ResponsiveContainer>
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
{children}
|
{children}
|
||||||
</RechartsPrimitive.ResponsiveContainer>
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
@ -69,37 +69,39 @@ function ChartContainer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
const getChartStyle = (config: ChartConfig) => {
|
||||||
const colorConfig = Object.entries(config).filter(
|
const colorConfig = Object.entries(config).filter(
|
||||||
([, config]) => config.theme || config.color,
|
([, config]) => config.theme || config.color,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!colorConfig.length) {
|
if (!colorConfig.length) {
|
||||||
return null;
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const styles: Record<string, string> = {};
|
||||||
<style
|
|
||||||
dangerouslySetInnerHTML={{
|
colorConfig.forEach(([key, itemConfig]) => {
|
||||||
__html: Object.entries(THEMES)
|
// For simplicity, we'll use the default color or the light theme color
|
||||||
.map(
|
// If you need per-theme variables, they should be handled via CSS classes or media queries
|
||||||
([theme, prefix]) => `
|
// but applying them here as inline styles is CSP-safe.
|
||||||
${prefix} [data-chart=${id}] {
|
const color = itemConfig.color || itemConfig.theme?.light;
|
||||||
${colorConfig
|
if (color) {
|
||||||
.map(([key, itemConfig]) => {
|
styles[`--color-${key}`] = color;
|
||||||
const color =
|
}
|
||||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
|
||||||
itemConfig.color;
|
// Handle dark theme if present
|
||||||
return color ? ` --color-${key}: ${color};` : null;
|
const darkColor = itemConfig.theme?.dark;
|
||||||
})
|
if (darkColor) {
|
||||||
.join("\n")}
|
styles[`--color-${key}-dark`] = darkColor;
|
||||||
}
|
}
|
||||||
`,
|
});
|
||||||
)
|
|
||||||
.join("\n"),
|
return styles as React.CSSProperties;
|
||||||
}}
|
};
|
||||||
/>
|
|
||||||
);
|
// Deprecated: Kept for backward compatibility if needed in other files.
|
||||||
|
const ChartStyle = () => {
|
||||||
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
@ -316,8 +318,8 @@ function getPayloadConfigFromPayload(
|
|||||||
|
|
||||||
const payloadPayload =
|
const payloadPayload =
|
||||||
"payload" in payload &&
|
"payload" in payload &&
|
||||||
typeof payload.payload === "object" &&
|
typeof payload.payload === "object" &&
|
||||||
payload.payload !== null
|
payload.payload !== null
|
||||||
? payload.payload
|
? payload.payload
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
186
src/components/ui/date-picker.tsx
Normal file
186
src/components/ui/date-picker.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"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;
|
||||||
|
|
||||||
@ -3,6 +3,7 @@ import { cn } from "./utils";
|
|||||||
import { Button } from "./button";
|
import { Button } from "./button";
|
||||||
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
|
import { Bold, Italic, Underline, List, ListOrdered, Heading1, Heading2, Heading3, AlignLeft, AlignCenter, AlignRight, Highlighter, X, Type } from "lucide-react";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||||
|
import { sanitizeHTML } from "@/utils/sanitizer";
|
||||||
|
|
||||||
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
interface RichTextEditorProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
|
||||||
value: string;
|
value: string;
|
||||||
@ -59,7 +60,8 @@ export function RichTextEditor({
|
|||||||
// Only update if the value actually changed externally
|
// Only update if the value actually changed externally
|
||||||
const currentValue = editorRef.current.innerHTML;
|
const currentValue = editorRef.current.innerHTML;
|
||||||
if (currentValue !== value) {
|
if (currentValue !== value) {
|
||||||
editorRef.current.innerHTML = value || '';
|
// Sanitize incoming content
|
||||||
|
editorRef.current.innerHTML = sanitizeHTML(value || '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [value]);
|
}, [value]);
|
||||||
@ -68,55 +70,55 @@ export function RichTextEditor({
|
|||||||
const cleanWordHTML = React.useCallback((html: string): string => {
|
const cleanWordHTML = React.useCallback((html: string): string => {
|
||||||
// Remove HTML comments (like Word style definitions)
|
// Remove HTML comments (like Word style definitions)
|
||||||
html = html.replace(/<!--[\s\S]*?-->/g, '');
|
html = html.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
|
|
||||||
// Remove style tags (Word CSS)
|
// Remove style tags (Word CSS)
|
||||||
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
html = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
|
||||||
|
|
||||||
// Remove script tags
|
// Remove script tags
|
||||||
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
html = html.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||||
|
|
||||||
// Remove meta tags
|
// Remove meta tags
|
||||||
html = html.replace(/<meta[^>]*>/gi, '');
|
html = html.replace(/<meta[^>]*>/gi, '');
|
||||||
|
|
||||||
// Remove Word-specific classes and attributes
|
// Remove Word-specific classes and attributes
|
||||||
html = html.replace(/\s*class="Mso[^"]*"/gi, '');
|
html = html.replace(/\s*class="Mso[^"]*"/gi, '');
|
||||||
html = html.replace(/\s*class="mso[^"]*"/gi, '');
|
html = html.replace(/\s*class="mso[^"]*"/gi, '');
|
||||||
html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, '');
|
html = html.replace(/\s*style="[^"]*mso-[^"]*"/gi, '');
|
||||||
html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, '');
|
html = html.replace(/\s*style="[^"]*font-family:[^"]*"/gi, '');
|
||||||
|
|
||||||
// Remove xmlns attributes
|
// Remove xmlns attributes
|
||||||
html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, '');
|
html = html.replace(/\s*xmlns[^=]*="[^"]*"/gi, '');
|
||||||
|
|
||||||
// Remove o:p tags (Word paragraph markers)
|
// Remove o:p tags (Word paragraph markers)
|
||||||
html = html.replace(/<\/?o:p[^>]*>/gi, '');
|
html = html.replace(/<\/?o:p[^>]*>/gi, '');
|
||||||
|
|
||||||
// Remove v:shapes and other Word-specific elements
|
// Remove v:shapes and other Word-specific elements
|
||||||
html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
html = html.replace(/<v:[^>]*>[\s\S]*?<\/v:[^>]*>/gi, '');
|
||||||
html = html.replace(/<v:[^>]*\/>/gi, '');
|
html = html.replace(/<v:[^>]*\/>/gi, '');
|
||||||
|
|
||||||
// Clean up empty paragraphs
|
// Clean up empty paragraphs
|
||||||
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
|
html = html.replace(/<p[^>]*>\s*<\/p>/gi, '');
|
||||||
html = html.replace(/<div[^>]*>\s*<\/div>/gi, '');
|
html = html.replace(/<div[^>]*>\s*<\/div>/gi, '');
|
||||||
|
|
||||||
// Remove excessive whitespace
|
// Remove excessive whitespace
|
||||||
html = html.replace(/\s+/g, ' ');
|
html = html.replace(/\s+/g, ' ');
|
||||||
html = html.trim();
|
html = html.trim();
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Handle paste event to preserve formatting
|
// Handle paste event to preserve formatting
|
||||||
const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
const handlePaste = React.useCallback((e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const clipboardData = e.clipboardData;
|
const clipboardData = e.clipboardData;
|
||||||
let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain');
|
let pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain');
|
||||||
|
|
||||||
// Clean Word/Office metadata if HTML
|
// Clean Word/Office metadata if HTML
|
||||||
if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) {
|
if (pastedData.includes('<!--') || pastedData.includes('<style') || pastedData.includes('MsoNormal')) {
|
||||||
pastedData = cleanWordHTML(pastedData);
|
pastedData = cleanWordHTML(pastedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
@ -131,12 +133,12 @@ export function RichTextEditor({
|
|||||||
|
|
||||||
// Clean and preserve formatting
|
// Clean and preserve formatting
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
// Process each node to preserve lists, tables, and basic formatting
|
// Process each node to preserve lists, tables, and basic formatting
|
||||||
Array.from(tempDiv.childNodes).forEach((node) => {
|
Array.from(tempDiv.childNodes).forEach((node) => {
|
||||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
const element = node as HTMLElement;
|
const element = node as HTMLElement;
|
||||||
|
|
||||||
// Preserve lists (ul, ol)
|
// Preserve lists (ul, ol)
|
||||||
if (element.tagName === 'UL' || element.tagName === 'OL') {
|
if (element.tagName === 'UL' || element.tagName === 'OL') {
|
||||||
const list = element.cloneNode(true) as HTMLElement;
|
const list = element.cloneNode(true) as HTMLElement;
|
||||||
@ -169,9 +171,6 @@ export function RichTextEditor({
|
|||||||
// Wrap table in scrollable container for mobile
|
// Wrap table in scrollable container for mobile
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.className = 'table-wrapper';
|
wrapper.className = 'table-wrapper';
|
||||||
wrapper.style.overflowX = 'auto';
|
|
||||||
wrapper.style.maxWidth = '100%';
|
|
||||||
wrapper.style.margin = '8px 0';
|
|
||||||
wrapper.appendChild(table);
|
wrapper.appendChild(table);
|
||||||
fragment.appendChild(wrapper);
|
fragment.appendChild(wrapper);
|
||||||
}
|
}
|
||||||
@ -182,7 +181,7 @@ export function RichTextEditor({
|
|||||||
const innerHTML = element.innerHTML;
|
const innerHTML = element.innerHTML;
|
||||||
// Remove style tags and comments from inner HTML
|
// Remove style tags and comments from inner HTML
|
||||||
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
const cleaned = innerHTML.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
|
||||||
.replace(/<!--[\s\S]*?-->/g, '');
|
.replace(/<!--[\s\S]*?-->/g, '');
|
||||||
p.innerHTML = cleaned;
|
p.innerHTML = cleaned;
|
||||||
p.removeAttribute('style');
|
p.removeAttribute('style');
|
||||||
p.removeAttribute('class');
|
p.removeAttribute('class');
|
||||||
@ -227,36 +226,36 @@ export function RichTextEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
range.insertNode(fragment);
|
range.insertNode(fragment);
|
||||||
|
|
||||||
// Move cursor to end of inserted content
|
// Move cursor to end of inserted content
|
||||||
range.collapse(false);
|
range.collapse(false);
|
||||||
selection.removeAllRanges();
|
selection.removeAllRanges();
|
||||||
selection.addRange(range);
|
selection.addRange(range);
|
||||||
|
|
||||||
// Trigger onChange
|
// Trigger onChange with sanitized content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(editorRef.current.innerHTML);
|
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
||||||
}
|
}
|
||||||
}, [onChange, cleanWordHTML]);
|
}, [onChange, cleanWordHTML]);
|
||||||
|
|
||||||
// Check active formats (bold, italic, etc.)
|
// Check active formats (bold, italic, etc.)
|
||||||
const checkActiveFormats = React.useCallback(() => {
|
const checkActiveFormats = React.useCallback(() => {
|
||||||
if (!editorRef.current || !isFocused) return;
|
if (!editorRef.current || !isFocused) return;
|
||||||
|
|
||||||
const formats = new Set<string>();
|
const formats = new Set<string>();
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
|
|
||||||
if (selection && selection.rangeCount > 0) {
|
if (selection && selection.rangeCount > 0) {
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
const commonAncestor = range.commonAncestorContainer;
|
const commonAncestor = range.commonAncestorContainer;
|
||||||
let element: HTMLElement | null = null;
|
let element: HTMLElement | null = null;
|
||||||
|
|
||||||
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
||||||
element = commonAncestor.parentElement;
|
element = commonAncestor.parentElement;
|
||||||
} else {
|
} else {
|
||||||
element = commonAncestor as HTMLElement;
|
element = commonAncestor as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (element && element !== editorRef.current) {
|
while (element && element !== editorRef.current) {
|
||||||
const tagName = element.tagName.toLowerCase();
|
const tagName = element.tagName.toLowerCase();
|
||||||
if (tagName === 'strong' || tagName === 'b') formats.add('bold');
|
if (tagName === 'strong' || tagName === 'b') formats.add('bold');
|
||||||
@ -267,40 +266,40 @@ export function RichTextEditor({
|
|||||||
if (tagName === 'h3') formats.add('h3');
|
if (tagName === 'h3') formats.add('h3');
|
||||||
if (tagName === 'ul') formats.add('ul');
|
if (tagName === 'ul') formats.add('ul');
|
||||||
if (tagName === 'ol') formats.add('ol');
|
if (tagName === 'ol') formats.add('ol');
|
||||||
|
|
||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
if (style.textAlign === 'center') formats.add('center');
|
if (style.textAlign === 'center') formats.add('center');
|
||||||
if (style.textAlign === 'right') formats.add('right');
|
if (style.textAlign === 'right') formats.add('right');
|
||||||
if (style.textAlign === 'left') formats.add('left');
|
if (style.textAlign === 'left') formats.add('left');
|
||||||
|
|
||||||
// Convert RGB/RGBA to hex for comparison
|
// Convert RGB/RGBA to hex for comparison
|
||||||
const colorToHex = (color: string): string | null => {
|
const colorToHex = (color: string): string | null => {
|
||||||
// If already hex format
|
// If already hex format
|
||||||
if (color.startsWith('#')) {
|
if (color.startsWith('#')) {
|
||||||
return color.toUpperCase();
|
return color.toUpperCase();
|
||||||
}
|
}
|
||||||
// If RGB/RGBA format
|
// If RGB/RGBA format
|
||||||
const result = color.match(/\d+/g);
|
const result = color.match(/\d+/g);
|
||||||
if (!result || result.length < 3) return null;
|
if (!result || result.length < 3) return null;
|
||||||
const r = result[0];
|
const r = result[0];
|
||||||
const g = result[1];
|
const g = result[1];
|
||||||
const b = result[2];
|
const b = result[2];
|
||||||
if (!r || !g || !b) return null;
|
if (!r || !g || !b) return null;
|
||||||
const rHex = parseInt(r).toString(16).padStart(2, '0');
|
const rHex = parseInt(r).toString(16).padStart(2, '0');
|
||||||
const gHex = parseInt(g).toString(16).padStart(2, '0');
|
const gHex = parseInt(g).toString(16).padStart(2, '0');
|
||||||
const bHex = parseInt(b).toString(16).padStart(2, '0');
|
const bHex = parseInt(b).toString(16).padStart(2, '0');
|
||||||
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
return `#${rHex}${gHex}${bHex}`.toUpperCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check for background color (highlight)
|
// Check for background color (highlight)
|
||||||
const bgColor = style.backgroundColor;
|
const bgColor = style.backgroundColor;
|
||||||
// Check if background color is set and not transparent/default
|
// Check if background color is set and not transparent/default
|
||||||
if (bgColor &&
|
if (bgColor &&
|
||||||
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
bgColor !== 'rgba(0, 0, 0, 0)' &&
|
||||||
bgColor !== 'transparent' &&
|
bgColor !== 'transparent' &&
|
||||||
bgColor !== 'rgb(255, 255, 255)' &&
|
bgColor !== 'rgb(255, 255, 255)' &&
|
||||||
bgColor !== '#ffffff' &&
|
bgColor !== '#ffffff' &&
|
||||||
bgColor !== '#FFFFFF') {
|
bgColor !== '#FFFFFF') {
|
||||||
formats.add('highlight');
|
formats.add('highlight');
|
||||||
const hexColor = colorToHex(bgColor);
|
const hexColor = colorToHex(bgColor);
|
||||||
if (hexColor) {
|
if (hexColor) {
|
||||||
@ -321,15 +320,15 @@ export function RichTextEditor({
|
|||||||
// Only reset if we haven't found a highlight yet
|
// Only reset if we haven't found a highlight yet
|
||||||
setCurrentHighlightColor(null);
|
setCurrentHighlightColor(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for text color
|
// Check for text color
|
||||||
const textColor = style.color;
|
const textColor = style.color;
|
||||||
// Convert to hex for comparison
|
// Convert to hex for comparison
|
||||||
const hexTextColor = colorToHex(textColor);
|
const hexTextColor = colorToHex(textColor);
|
||||||
// Check if text color is set and not default black
|
// Check if text color is set and not default black
|
||||||
if (textColor && hexTextColor &&
|
if (textColor && hexTextColor &&
|
||||||
textColor !== 'rgba(0, 0, 0, 0)' &&
|
textColor !== 'rgba(0, 0, 0, 0)' &&
|
||||||
hexTextColor !== '#000000') {
|
hexTextColor !== '#000000') {
|
||||||
formats.add('textColor');
|
formats.add('textColor');
|
||||||
// Find matching color from our palette
|
// Find matching color from our palette
|
||||||
const matchedColor = HIGHLIGHT_COLORS.find(c => {
|
const matchedColor = HIGHLIGHT_COLORS.find(c => {
|
||||||
@ -350,23 +349,23 @@ export function RichTextEditor({
|
|||||||
setCurrentTextColor(null);
|
setCurrentTextColor(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
element = element.parentElement;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveFormats(formats);
|
setActiveFormats(formats);
|
||||||
}, [isFocused]);
|
}, [isFocused]);
|
||||||
|
|
||||||
// Apply formatting command
|
// Apply formatting command
|
||||||
const applyFormat = React.useCallback((command: string, value?: string) => {
|
const applyFormat = React.useCallback((command: string, value?: string) => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
// Restore focus if needed
|
// Restore focus if needed
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current selection
|
// Save current selection
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
@ -374,15 +373,15 @@ export function RichTextEditor({
|
|||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute formatting command
|
// Execute formatting command
|
||||||
document.execCommand(command, false, value);
|
document.execCommand(command, false, value);
|
||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(editorRef.current.innerHTML);
|
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check active formats after a short delay
|
// Check active formats after a short delay
|
||||||
setTimeout(checkActiveFormats, 10);
|
setTimeout(checkActiveFormats, 10);
|
||||||
}, [isFocused, onChange, checkActiveFormats]);
|
}, [isFocused, onChange, checkActiveFormats]);
|
||||||
@ -390,12 +389,12 @@ export function RichTextEditor({
|
|||||||
// Apply highlight color
|
// Apply highlight color
|
||||||
const applyHighlight = React.useCallback((color: string) => {
|
const applyHighlight = React.useCallback((color: string) => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
// Restore focus if needed
|
// Restore focus if needed
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current selection
|
// Save current selection
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
@ -403,26 +402,26 @@ export function RichTextEditor({
|
|||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this color is already applied by checking the selection's style
|
// Check if this color is already applied by checking the selection's style
|
||||||
let isAlreadyApplied = false;
|
let isAlreadyApplied = false;
|
||||||
if (selection.rangeCount > 0) {
|
if (selection.rangeCount > 0) {
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
const commonAncestor = range.commonAncestorContainer;
|
const commonAncestor = range.commonAncestorContainer;
|
||||||
let element: HTMLElement | null = null;
|
let element: HTMLElement | null = null;
|
||||||
|
|
||||||
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
||||||
element = commonAncestor.parentElement;
|
element = commonAncestor.parentElement;
|
||||||
} else {
|
} else {
|
||||||
element = commonAncestor as HTMLElement;
|
element = commonAncestor as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the selected element has the same background color
|
// Check if the selected element has the same background color
|
||||||
while (element && element !== editorRef.current) {
|
while (element && element !== editorRef.current) {
|
||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
const bgColor = style.backgroundColor;
|
const bgColor = style.backgroundColor;
|
||||||
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent' &&
|
||||||
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
|
bgColor !== 'rgb(255, 255, 255)' && bgColor !== '#ffffff' && bgColor !== '#FFFFFF') {
|
||||||
// Convert to hex and compare
|
// Convert to hex and compare
|
||||||
const colorToHex = (c: string): string | null => {
|
const colorToHex = (c: string): string | null => {
|
||||||
if (c.startsWith('#')) return c.toUpperCase();
|
if (c.startsWith('#')) return c.toUpperCase();
|
||||||
@ -446,7 +445,7 @@ export function RichTextEditor({
|
|||||||
element = element.parentElement;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use backColor command for highlight (background color)
|
// Use backColor command for highlight (background color)
|
||||||
if (color === 'transparent' || isAlreadyApplied) {
|
if (color === 'transparent' || isAlreadyApplied) {
|
||||||
// Remove highlight - use a more aggressive approach to fully remove
|
// Remove highlight - use a more aggressive approach to fully remove
|
||||||
@ -454,10 +453,10 @@ export function RichTextEditor({
|
|||||||
if (!range.collapsed) {
|
if (!range.collapsed) {
|
||||||
// Store the range before manipulation
|
// Store the range before manipulation
|
||||||
const contents = range.extractContents();
|
const contents = range.extractContents();
|
||||||
|
|
||||||
// Create a new text node or span without background color
|
// Create a new text node or span without background color
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
// Process extracted contents to remove background colors
|
// Process extracted contents to remove background colors
|
||||||
const processNode = (node: Node) => {
|
const processNode = (node: Node) => {
|
||||||
if (node.nodeType === Node.TEXT_NODE) {
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
@ -465,14 +464,14 @@ export function RichTextEditor({
|
|||||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
const el = node as HTMLElement;
|
const el = node as HTMLElement;
|
||||||
const newEl = document.createElement(el.tagName.toLowerCase());
|
const newEl = document.createElement(el.tagName.toLowerCase());
|
||||||
|
|
||||||
// Copy all attributes except style-related ones
|
// Copy all attributes except style-related ones
|
||||||
Array.from(el.attributes).forEach(attr => {
|
Array.from(el.attributes).forEach(attr => {
|
||||||
if (attr.name !== 'style' && attr.name !== 'class') {
|
if (attr.name !== 'style' && attr.name !== 'class') {
|
||||||
newEl.setAttribute(attr.name, attr.value);
|
newEl.setAttribute(attr.name, attr.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process children and copy without background color
|
// Process children and copy without background color
|
||||||
Array.from(el.childNodes).forEach(child => {
|
Array.from(el.childNodes).forEach(child => {
|
||||||
const processed = processNode(child);
|
const processed = processNode(child);
|
||||||
@ -480,27 +479,27 @@ export function RichTextEditor({
|
|||||||
newEl.appendChild(processed);
|
newEl.appendChild(processed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove background color if present
|
// Remove background color if present
|
||||||
if (el.style.backgroundColor) {
|
if (el.style.backgroundColor) {
|
||||||
newEl.style.backgroundColor = '';
|
newEl.style.backgroundColor = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return newEl;
|
return newEl;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
Array.from(contents.childNodes).forEach(child => {
|
Array.from(contents.childNodes).forEach(child => {
|
||||||
const processed = processNode(child);
|
const processed = processNode(child);
|
||||||
if (processed) {
|
if (processed) {
|
||||||
fragment.appendChild(processed);
|
fragment.appendChild(processed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Insert the cleaned fragment
|
// Insert the cleaned fragment
|
||||||
range.insertNode(fragment);
|
range.insertNode(fragment);
|
||||||
|
|
||||||
// Also use execCommand to ensure removal
|
// Also use execCommand to ensure removal
|
||||||
document.execCommand('removeFormat', false);
|
document.execCommand('removeFormat', false);
|
||||||
} else {
|
} else {
|
||||||
@ -523,21 +522,21 @@ export function RichTextEditor({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear selection immediately after applying to prevent "sticky" highlight mode
|
// Clear selection immediately after applying to prevent "sticky" highlight mode
|
||||||
const sel = window.getSelection();
|
const sel = window.getSelection();
|
||||||
if (sel) {
|
if (sel) {
|
||||||
sel.removeAllRanges();
|
sel.removeAllRanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(editorRef.current.innerHTML);
|
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
setHighlightColorOpen(false);
|
setHighlightColorOpen(false);
|
||||||
|
|
||||||
// Refocus editor after a short delay and check formats
|
// Refocus editor after a short delay and check formats
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@ -550,12 +549,12 @@ export function RichTextEditor({
|
|||||||
// Apply text color
|
// Apply text color
|
||||||
const applyTextColor = React.useCallback((color: string) => {
|
const applyTextColor = React.useCallback((color: string) => {
|
||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
|
|
||||||
// Restore focus if needed
|
// Restore focus if needed
|
||||||
if (!isFocused) {
|
if (!isFocused) {
|
||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save current selection
|
// Save current selection
|
||||||
const selection = window.getSelection();
|
const selection = window.getSelection();
|
||||||
if (!selection || selection.rangeCount === 0) {
|
if (!selection || selection.rangeCount === 0) {
|
||||||
@ -563,20 +562,20 @@ export function RichTextEditor({
|
|||||||
editorRef.current.focus();
|
editorRef.current.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this color is already applied by checking the selection's style
|
// Check if this color is already applied by checking the selection's style
|
||||||
let isAlreadyApplied = false;
|
let isAlreadyApplied = false;
|
||||||
if (selection.rangeCount > 0) {
|
if (selection.rangeCount > 0) {
|
||||||
const range = selection.getRangeAt(0);
|
const range = selection.getRangeAt(0);
|
||||||
const commonAncestor = range.commonAncestorContainer;
|
const commonAncestor = range.commonAncestorContainer;
|
||||||
let element: HTMLElement | null = null;
|
let element: HTMLElement | null = null;
|
||||||
|
|
||||||
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
if (commonAncestor.nodeType === Node.TEXT_NODE) {
|
||||||
element = commonAncestor.parentElement;
|
element = commonAncestor.parentElement;
|
||||||
} else {
|
} else {
|
||||||
element = commonAncestor as HTMLElement;
|
element = commonAncestor as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the selected element has the same text color
|
// Check if the selected element has the same text color
|
||||||
while (element && element !== editorRef.current) {
|
while (element && element !== editorRef.current) {
|
||||||
const style = window.getComputedStyle(element);
|
const style = window.getComputedStyle(element);
|
||||||
@ -612,7 +611,7 @@ export function RichTextEditor({
|
|||||||
element = element.parentElement;
|
element = element.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use foreColor command for text color
|
// Use foreColor command for text color
|
||||||
if (color === 'transparent' || color === 'default' || isAlreadyApplied) {
|
if (color === 'transparent' || color === 'default' || isAlreadyApplied) {
|
||||||
// Remove text color by removing format or setting to default
|
// Remove text color by removing format or setting to default
|
||||||
@ -633,15 +632,15 @@ export function RichTextEditor({
|
|||||||
setCustomTextColor(color);
|
setCustomTextColor(color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update content
|
// Update content
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(editorRef.current.innerHTML);
|
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close popover
|
// Close popover
|
||||||
setTextColorOpen(false);
|
setTextColorOpen(false);
|
||||||
|
|
||||||
// Check active formats after a short delay
|
// Check active formats after a short delay
|
||||||
setTimeout(checkActiveFormats, 10);
|
setTimeout(checkActiveFormats, 10);
|
||||||
}, [isFocused, onChange, checkActiveFormats]);
|
}, [isFocused, onChange, checkActiveFormats]);
|
||||||
@ -649,7 +648,7 @@ export function RichTextEditor({
|
|||||||
// Handle input changes
|
// Handle input changes
|
||||||
const handleInput = React.useCallback(() => {
|
const handleInput = React.useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(editorRef.current.innerHTML);
|
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
||||||
}
|
}
|
||||||
checkActiveFormats();
|
checkActiveFormats();
|
||||||
}, [onChange, checkActiveFormats]);
|
}, [onChange, checkActiveFormats]);
|
||||||
@ -685,18 +684,18 @@ export function RichTextEditor({
|
|||||||
const handleBlur = React.useCallback(() => {
|
const handleBlur = React.useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
onChange(editorRef.current.innerHTML);
|
onChange(sanitizeHTML(editorRef.current.innerHTML));
|
||||||
}
|
}
|
||||||
}, [onChange]);
|
}, [onChange]);
|
||||||
|
|
||||||
// Handle selection change to update active formats
|
// Handle selection change to update active formats
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!isFocused) return;
|
if (!isFocused) return;
|
||||||
|
|
||||||
const handleSelectionChange = () => {
|
const handleSelectionChange = () => {
|
||||||
checkActiveFormats();
|
checkActiveFormats();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('selectionchange', handleSelectionChange);
|
document.addEventListener('selectionchange', handleSelectionChange);
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('selectionchange', handleSelectionChange);
|
document.removeEventListener('selectionchange', handleSelectionChange);
|
||||||
@ -748,7 +747,7 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<Underline className="h-4 w-4" />
|
<Underline className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Highlight Color Picker */}
|
{/* Highlight Color Picker */}
|
||||||
<Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}>
|
<Popover open={highlightColorOpen} onOpenChange={setHighlightColorOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -765,8 +764,8 @@ export function RichTextEditor({
|
|||||||
<Highlighter className="h-4 w-4" />
|
<Highlighter className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-auto p-2"
|
className="w-auto p-2"
|
||||||
align="start"
|
align="start"
|
||||||
onPointerDownOutside={(e) => {
|
onPointerDownOutside={(e) => {
|
||||||
// Prevent closing when clicking inside popover
|
// Prevent closing when clicking inside popover
|
||||||
@ -791,7 +790,7 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-gray-500" />
|
<X className="h-4 w-4 text-gray-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div>
|
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Highlight Color</div>
|
||||||
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
|
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
|
||||||
{HIGHLIGHT_COLORS.map((color) => {
|
{HIGHLIGHT_COLORS.map((color) => {
|
||||||
@ -833,7 +832,7 @@ export function RichTextEditor({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remove Highlight Button - Standard pattern */}
|
{/* Remove Highlight Button - Standard pattern */}
|
||||||
{currentHighlightColor && currentHighlightColor !== 'transparent' && (
|
{currentHighlightColor && currentHighlightColor !== 'transparent' && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@ -852,7 +851,7 @@ export function RichTextEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom Color Picker */}
|
{/* Custom Color Picker */}
|
||||||
<div className="border-t border-gray-200 pt-2 mt-2">
|
<div className="border-t border-gray-200 pt-2 mt-2">
|
||||||
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
|
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
|
||||||
@ -899,7 +898,7 @@ export function RichTextEditor({
|
|||||||
// Get pasted text from clipboard
|
// Get pasted text from clipboard
|
||||||
const pastedText = e.clipboardData.getData('text').trim();
|
const pastedText = e.clipboardData.getData('text').trim();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Process after paste event completes
|
// Process after paste event completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Check if it's a valid hex color with #
|
// Check if it's a valid hex color with #
|
||||||
@ -980,7 +979,7 @@ export function RichTextEditor({
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Text Color Picker */}
|
{/* Text Color Picker */}
|
||||||
<Popover open={textColorOpen} onOpenChange={setTextColorOpen}>
|
<Popover open={textColorOpen} onOpenChange={setTextColorOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@ -997,8 +996,8 @@ export function RichTextEditor({
|
|||||||
<Type className="h-4 w-4" />
|
<Type className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-auto p-2"
|
className="w-auto p-2"
|
||||||
align="start"
|
align="start"
|
||||||
onPointerDownOutside={(e) => {
|
onPointerDownOutside={(e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
@ -1022,7 +1021,7 @@ export function RichTextEditor({
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-gray-500" />
|
<X className="h-4 w-4 text-gray-500" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div>
|
<div className="text-xs font-semibold text-gray-700 mb-2 pr-6">Text Color</div>
|
||||||
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
|
<div className="grid grid-cols-6 gap-1.5 pr-1 mb-2">
|
||||||
{/* Default/Black Color Option - First position (standard) */}
|
{/* Default/Black Color Option - First position (standard) */}
|
||||||
@ -1085,7 +1084,7 @@ export function RichTextEditor({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remove Text Color Button - Standard pattern */}
|
{/* Remove Text Color Button - Standard pattern */}
|
||||||
{currentTextColor && currentTextColor !== '#000000' && (
|
{currentTextColor && currentTextColor !== '#000000' && (
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
@ -1104,7 +1103,7 @@ export function RichTextEditor({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom Text Color Picker */}
|
{/* Custom Text Color Picker */}
|
||||||
<div className="border-t border-gray-200 pt-2 mt-2">
|
<div className="border-t border-gray-200 pt-2 mt-2">
|
||||||
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
|
<div className="text-xs font-semibold text-gray-700 mb-1.5">Custom Color</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
|
||||||
|
import { sanitizeHTML } from '@/utils/sanitizer';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
@ -8,12 +9,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
Send,
|
Send,
|
||||||
Smile,
|
Smile,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
FileText,
|
FileText,
|
||||||
Download,
|
Download,
|
||||||
Clock,
|
Clock,
|
||||||
Flag,
|
Flag,
|
||||||
X,
|
X,
|
||||||
@ -58,9 +59,11 @@ interface WorkNoteChatSimpleProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
return content
|
const formattedContent = content
|
||||||
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
|
|
||||||
|
return sanitizeHTML(formattedContent);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FileIcon = ({ type }: { type: string }) => {
|
const FileIcon = ({ type }: { type: string }) => {
|
||||||
@ -102,7 +105,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const filteredMessages = messages.filter(msg =>
|
const filteredMessages = messages.filter(msg =>
|
||||||
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
@ -132,7 +135,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
// Realtime updates via Socket.IO
|
// Realtime updates via Socket.IO
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
||||||
|
|
||||||
let joinedId = requestId;
|
let joinedId = requestId;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@ -140,39 +143,39 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
if (details?.workflow?.requestId) {
|
if (details?.workflow?.requestId) {
|
||||||
joinedId = details.workflow.requestId;
|
joinedId = details.workflow.requestId;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch { }
|
||||||
try {
|
try {
|
||||||
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
||||||
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
||||||
|
|
||||||
joinRequestRoom(s, joinedId, currentUserId || undefined);
|
joinRequestRoom(s, joinedId, currentUserId || undefined);
|
||||||
|
|
||||||
const noteHandler = (payload: any) => {
|
const noteHandler = (payload: any) => {
|
||||||
const n = payload?.note || payload;
|
const n = payload?.note || payload;
|
||||||
if (!n) return;
|
if (!n) return;
|
||||||
|
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
|
if (prev.some(m => m.id === (n.noteId || n.note_id || n.id))) {
|
||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userName = n.userName || n.user_name || 'User';
|
const userName = n.userName || n.user_name || 'User';
|
||||||
const userRole = n.userRole || n.user_role;
|
const userRole = n.userRole || n.user_role;
|
||||||
const participantRole = formatParticipantRole(userRole);
|
const participantRole = formatParticipantRole(userRole);
|
||||||
const noteUserId = n.userId || n.user_id;
|
const noteUserId = n.userId || n.user_id;
|
||||||
|
|
||||||
const newMsg = {
|
const newMsg = {
|
||||||
id: n.noteId || n.note_id || String(Date.now()),
|
id: n.noteId || n.note_id || String(Date.now()),
|
||||||
user: {
|
user: {
|
||||||
name: userName,
|
name: userName,
|
||||||
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
||||||
role: participantRole
|
role: participantRole
|
||||||
},
|
},
|
||||||
content: n.message || '',
|
content: n.message || '',
|
||||||
timestamp: n.createdAt || n.created_at || new Date().toISOString(),
|
timestamp: n.createdAt || n.created_at || new Date().toISOString(),
|
||||||
isCurrentUser: noteUserId === currentUserId
|
isCurrentUser: noteUserId === currentUserId
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
return [...prev, newMsg];
|
return [...prev, newMsg];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -189,7 +192,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
try { (window as any).__wn_cleanup?.(); } catch {}
|
try { (window as any).__wn_cleanup?.(); } catch { }
|
||||||
};
|
};
|
||||||
}, [requestId, currentUserId]);
|
}, [requestId, currentUserId]);
|
||||||
|
|
||||||
@ -205,20 +208,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const userName = m.userName || m.user_name || 'User';
|
const userName = m.userName || m.user_name || 'User';
|
||||||
const userRole = m.userRole || m.user_role;
|
const userRole = m.userRole || m.user_role;
|
||||||
const participantRole = formatParticipantRole(userRole);
|
const participantRole = formatParticipantRole(userRole);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
||||||
user: {
|
user: {
|
||||||
name: userName,
|
name: userName,
|
||||||
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
||||||
role: participantRole
|
role: participantRole
|
||||||
},
|
},
|
||||||
content: m.message || '',
|
content: m.message || '',
|
||||||
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
|
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}) : [];
|
}) : [];
|
||||||
setMessages(mapped as any);
|
setMessages(mapped as any);
|
||||||
} catch {}
|
} catch { }
|
||||||
}
|
}
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setSelectedFiles([]);
|
setSelectedFiles([]);
|
||||||
@ -234,21 +237,21 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const userRole = m.userRole || m.user_role;
|
const userRole = m.userRole || m.user_role;
|
||||||
const participantRole = formatParticipantRole(userRole);
|
const participantRole = formatParticipantRole(userRole);
|
||||||
const noteUserId = m.userId || m.user_id;
|
const noteUserId = m.userId || m.user_id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
||||||
user: {
|
user: {
|
||||||
name: userName,
|
name: userName,
|
||||||
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
||||||
role: participantRole
|
role: participantRole
|
||||||
},
|
},
|
||||||
content: m.message || m.content || '',
|
content: m.message || m.content || '',
|
||||||
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
|
timestamp: m.createdAt || m.created_at || m.timestamp || new Date().toISOString(),
|
||||||
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
|
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
|
||||||
attachmentId: a.attachmentId || a.attachment_id,
|
attachmentId: a.attachmentId || a.attachment_id,
|
||||||
name: a.fileName || a.file_name || a.name,
|
name: a.fileName || a.file_name || a.name,
|
||||||
fileName: a.fileName || a.file_name || a.name,
|
fileName: a.fileName || a.file_name || a.name,
|
||||||
url: a.storageUrl || a.storage_url || a.url || '#',
|
url: a.storageUrl || a.storage_url || a.url || '#',
|
||||||
type: a.fileType || a.file_type || a.type || 'file',
|
type: a.fileType || a.file_type || a.type || 'file',
|
||||||
fileType: a.fileType || a.file_type || a.type || 'file',
|
fileType: a.fileType || a.file_type || a.type || 'file',
|
||||||
fileSize: a.fileSize || a.file_size
|
fileSize: a.fileSize || a.file_size
|
||||||
@ -257,24 +260,24 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
setMessages(mapped);
|
setMessages(mapped);
|
||||||
} catch {}
|
} catch { }
|
||||||
} else {
|
} else {
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const rows = await getWorkNotes(requestId);
|
const rows = await getWorkNotes(requestId);
|
||||||
|
|
||||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||||
const userName = m.userName || m.user_name || 'User';
|
const userName = m.userName || m.user_name || 'User';
|
||||||
const userRole = m.userRole || m.user_role;
|
const userRole = m.userRole || m.user_role;
|
||||||
const participantRole = formatParticipantRole(userRole);
|
const participantRole = formatParticipantRole(userRole);
|
||||||
const noteUserId = m.userId || m.user_id;
|
const noteUserId = m.userId || m.user_id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
id: m.noteId || m.note_id || m.id || String(Math.random()),
|
||||||
user: {
|
user: {
|
||||||
name: userName,
|
name: userName,
|
||||||
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
||||||
role: participantRole
|
role: participantRole
|
||||||
},
|
},
|
||||||
content: m.message || '',
|
content: m.message || '',
|
||||||
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
|
timestamp: m.createdAt || m.created_at || new Date().toISOString(),
|
||||||
@ -336,7 +339,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
if (msg.id === messageId) {
|
if (msg.id === messageId) {
|
||||||
const reactions = msg.reactions || [];
|
const reactions = msg.reactions || [];
|
||||||
const existingReaction = reactions.find(r => r.emoji === emoji);
|
const existingReaction = reactions.find(r => r.emoji === emoji);
|
||||||
|
|
||||||
if (existingReaction) {
|
if (existingReaction) {
|
||||||
if (existingReaction.users.includes('You')) {
|
if (existingReaction.users.includes('You')) {
|
||||||
existingReaction.users = existingReaction.users.filter(u => u !== 'You');
|
existingReaction.users = existingReaction.users.filter(u => u !== 'You');
|
||||||
@ -349,7 +352,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
} else {
|
} else {
|
||||||
reactions.push({ emoji, users: ['You'] });
|
reactions.push({ emoji, users: ['You'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ...msg, reactions };
|
return { ...msg, reactions };
|
||||||
}
|
}
|
||||||
return msg;
|
return msg;
|
||||||
@ -371,7 +374,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
@ -389,21 +392,20 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<div className="space-y-6 max-w-full">
|
<div className="space-y-6 max-w-full">
|
||||||
{filteredMessages.map((msg) => {
|
{filteredMessages.map((msg) => {
|
||||||
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
|
const isCurrentUser = (msg as any).isCurrentUser || msg.user.name === 'You';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
<div key={msg.id} className={`flex gap-3 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
||||||
{!msg.isSystem && !isCurrentUser && (
|
{!msg.isSystem && !isCurrentUser && (
|
||||||
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
||||||
<AvatarFallback className={`text-white font-semibold text-sm ${
|
<AvatarFallback className={`text-white font-semibold text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
|
||||||
msg.user.role === 'Initiator' ? 'bg-green-600' :
|
msg.user.role === 'Approver' ? 'bg-blue-600' :
|
||||||
msg.user.role === 'Approver' ? 'bg-blue-600' :
|
'bg-slate-600'
|
||||||
'bg-slate-600'
|
}`}>
|
||||||
}`}>
|
|
||||||
{msg.user.avatar}
|
{msg.user.avatar}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
|
<div className={`${isCurrentUser ? 'max-w-[70%]' : 'flex-1'} min-w-0 ${msg.isSystem ? 'text-center max-w-md mx-auto' : ''}`}>
|
||||||
{msg.isSystem ? (
|
{msg.isSystem ? (
|
||||||
<div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full">
|
<div className="inline-flex items-center gap-3 px-4 py-2 bg-gray-100 rounded-full">
|
||||||
@ -435,7 +437,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
|
|
||||||
{/* Message Content */}
|
{/* Message Content */}
|
||||||
<div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
|
<div className={`rounded-lg border p-4 shadow-sm ${isCurrentUser ? 'bg-blue-50 border-blue-200' : 'bg-white border-gray-200'}`}>
|
||||||
<div
|
<div
|
||||||
className="text-gray-800 leading-relaxed text-base"
|
className="text-gray-800 leading-relaxed text-base"
|
||||||
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
|
dangerouslySetInnerHTML={{ __html: formatMessage(msg.content) }}
|
||||||
/>
|
/>
|
||||||
@ -449,72 +451,72 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
const fileName = attachment.fileName || attachment.file_name || attachment.name;
|
const fileName = attachment.fileName || attachment.file_name || attachment.name;
|
||||||
const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
|
const fileType = attachment.fileType || attachment.file_type || attachment.type || '';
|
||||||
const attachmentId = attachment.attachmentId || attachment.attachment_id;
|
const attachmentId = attachment.attachmentId || attachment.attachment_id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
|
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border border-gray-200 hover:bg-gray-100 transition-colors">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<FileIcon type={fileType} />
|
<FileIcon type={fileType} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-700 truncate">
|
<p className="text-sm font-medium text-gray-700 truncate">
|
||||||
{fileName}
|
{fileName}
|
||||||
</p>
|
|
||||||
{fileSize && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{formatFileSize(fileSize)}
|
|
||||||
</p>
|
</p>
|
||||||
|
{fileSize && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{formatFileSize(fileSize)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview button for images and PDFs */}
|
||||||
|
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-purple-100 hover:text-purple-600"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
|
||||||
|
setPreviewFile({
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
fileUrl: previewUrl,
|
||||||
|
fileSize,
|
||||||
|
attachmentId
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
title="Preview file"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{/* Download button */}
|
||||||
{/* Preview button for images and PDFs */}
|
<Button
|
||||||
{attachmentId && (fileType.includes('image') || fileType.includes('pdf')) && (
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
|
||||||
size="sm"
|
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.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const previewUrl = getWorkNoteAttachmentPreviewUrl(attachmentId);
|
|
||||||
setPreviewFile({
|
if (!attachmentId) {
|
||||||
fileName,
|
toast.error('Cannot download: Attachment ID missing');
|
||||||
fileType,
|
return;
|
||||||
fileUrl: previewUrl,
|
}
|
||||||
fileSize,
|
|
||||||
attachmentId
|
try {
|
||||||
});
|
await downloadWorkNoteAttachment(attachmentId);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to download file');
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
title="Preview file"
|
title="Download file"
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Download button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-8 p-0 flex-shrink-0 hover:bg-blue-100 hover:text-blue-600"
|
|
||||||
onClick={async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!attachmentId) {
|
|
||||||
toast.error('Cannot download: Attachment ID missing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await downloadWorkNoteAttachment(attachmentId);
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to download file');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Download file"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@ -528,19 +530,18 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => addReaction(msg.id, reaction.emoji)}
|
onClick={() => addReaction(msg.id, reaction.emoji)}
|
||||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${
|
className={`flex items-center gap-1 px-2 py-1 rounded-full text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
|
||||||
reaction.users.includes('You')
|
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||||||
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{reaction.emoji}</span>
|
<span>{reaction.emoji}</span>
|
||||||
<span className="text-xs font-medium">{reaction.users.length}</span>
|
<span className="text-xs font-medium">{reaction.users.length}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 w-7 p-0 flex-shrink-0"
|
className="h-7 w-7 p-0 flex-shrink-0"
|
||||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
>
|
>
|
||||||
@ -552,7 +553,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!msg.isSystem && isCurrentUser && (
|
{!msg.isSystem && isCurrentUser && (
|
||||||
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
<Avatar className="h-10 w-10 flex-shrink-0 ring-2 ring-white shadow-sm">
|
||||||
<AvatarFallback className="bg-blue-500 text-white font-semibold text-sm">
|
<AvatarFallback className="bg-blue-500 text-white font-semibold text-sm">
|
||||||
@ -648,27 +649,27 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
{/* Left side - Action buttons */}
|
{/* Left side - Action buttons */}
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
|
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
|
||||||
onClick={handleAttachmentClick}
|
onClick={handleAttachmentClick}
|
||||||
title="Attach file"
|
title="Attach file"
|
||||||
>
|
>
|
||||||
<Paperclip className="h-4 w-4" />
|
<Paperclip className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
|
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
|
||||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||||
title="Add emoji"
|
title="Add emoji"
|
||||||
>
|
>
|
||||||
<Smile className="h-4 w-4" />
|
<Smile className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
|
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
|
||||||
onClick={() => setMessage(prev => prev + '@')}
|
onClick={() => setMessage(prev => prev + '@')}
|
||||||
title="Mention someone"
|
title="Mention someone"
|
||||||
@ -682,8 +683,8 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
{message.length}/2000
|
{message.length}/2000
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSendMessage}
|
onClick={handleSendMessage}
|
||||||
disabled={!message.trim() && selectedFiles.length === 0}
|
disabled={!message.trim() && selectedFiles.length === 0}
|
||||||
className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="bg-blue-600 hover:bg-blue-700 h-9 px-4 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -695,7 +696,7 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* File Preview Modal */}
|
{/* File Preview Modal */}
|
||||||
{previewFile && (
|
{previewFile && (
|
||||||
<FilePreview
|
<FilePreview
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
|
import { Users, Settings, Shield, User, CheckCircle, Minus, Plus, Info, Clock } from 'lucide-react';
|
||||||
import { FormData } from '@/hooks/useCreateRequestForm';
|
import { FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
|
||||||
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
import { useMultiUserSearch } from '@/hooks/useUserSearch';
|
||||||
import { ensureUserExists } from '@/services/userApi';
|
import { ensureUserExists } from '@/services/userApi';
|
||||||
|
|
||||||
@ -15,6 +15,8 @@ interface ApprovalWorkflowStepProps {
|
|||||||
formData: FormData;
|
formData: FormData;
|
||||||
updateFormData: (field: keyof FormData, value: any) => void;
|
updateFormData: (field: keyof FormData, value: any) => void;
|
||||||
onValidationError: (error: { type: string; email: string; message: string }) => void;
|
onValidationError: (error: { type: string; email: string; message: string }) => void;
|
||||||
|
systemPolicy: SystemPolicy;
|
||||||
|
onPolicyViolation: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +35,9 @@ interface ApprovalWorkflowStepProps {
|
|||||||
export function ApprovalWorkflowStep({
|
export function ApprovalWorkflowStep({
|
||||||
formData,
|
formData,
|
||||||
updateFormData,
|
updateFormData,
|
||||||
onValidationError
|
onValidationError,
|
||||||
|
systemPolicy,
|
||||||
|
onPolicyViolation
|
||||||
}: ApprovalWorkflowStepProps) {
|
}: ApprovalWorkflowStepProps) {
|
||||||
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
|
||||||
|
|
||||||
@ -41,18 +45,18 @@ export function ApprovalWorkflowStep({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const approverCount = formData.approverCount || 1;
|
const approverCount = formData.approverCount || 1;
|
||||||
const currentApprovers = formData.approvers || [];
|
const currentApprovers = formData.approvers || [];
|
||||||
|
|
||||||
// Ensure we have the correct number of approvers
|
// Ensure we have the correct number of approvers
|
||||||
if (currentApprovers.length < approverCount) {
|
if (currentApprovers.length < approverCount) {
|
||||||
const newApprovers = [...currentApprovers];
|
const newApprovers = [...currentApprovers];
|
||||||
// Fill missing approver slots
|
// Fill missing approver slots
|
||||||
for (let i = currentApprovers.length; i < approverCount; i++) {
|
for (let i = currentApprovers.length; i < approverCount; i++) {
|
||||||
if (!newApprovers[i]) {
|
if (!newApprovers[i]) {
|
||||||
newApprovers[i] = {
|
newApprovers[i] = {
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
level: i + 1,
|
level: i + 1,
|
||||||
tat: '' as any
|
tat: '' as any
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -67,7 +71,7 @@ export function ApprovalWorkflowStep({
|
|||||||
const newApprovers = [...formData.approvers];
|
const newApprovers = [...formData.approvers];
|
||||||
const previousEmail = newApprovers[index]?.email;
|
const previousEmail = newApprovers[index]?.email;
|
||||||
const emailChanged = previousEmail !== value;
|
const emailChanged = previousEmail !== value;
|
||||||
|
|
||||||
newApprovers[index] = {
|
newApprovers[index] = {
|
||||||
...newApprovers[index],
|
...newApprovers[index],
|
||||||
email: value,
|
email: value,
|
||||||
@ -90,8 +94,8 @@ export function ApprovalWorkflowStep({
|
|||||||
try {
|
try {
|
||||||
// Check for duplicates in other approver slots (excluding current index)
|
// Check for duplicates in other approver slots (excluding current index)
|
||||||
const isDuplicateApprover = formData.approvers?.some(
|
const isDuplicateApprover = formData.approvers?.some(
|
||||||
(approver: any, idx: number) =>
|
(approver: any, idx: number) =>
|
||||||
idx !== index &&
|
idx !== index &&
|
||||||
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -192,9 +196,9 @@ export function ApprovalWorkflowStep({
|
|||||||
<div data-testid="approval-workflow-count-field">
|
<div data-testid="approval-workflow-count-field">
|
||||||
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
|
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentCount = formData.approverCount || 1;
|
const currentCount = formData.approverCount || 1;
|
||||||
@ -212,23 +216,35 @@ export function ApprovalWorkflowStep({
|
|||||||
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
|
<span className="text-2xl font-semibold w-12 text-center" data-testid="approval-workflow-count-display">
|
||||||
{formData.approverCount || 1}
|
{formData.approverCount || 1}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const currentCount = formData.approverCount || 1;
|
const currentCount = formData.approverCount || 1;
|
||||||
const newCount = Math.min(10, currentCount + 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;
|
||||||
|
}
|
||||||
|
|
||||||
updateFormData('approverCount', newCount);
|
updateFormData('approverCount', newCount);
|
||||||
}}
|
}}
|
||||||
disabled={(formData.approverCount || 1) >= 10}
|
disabled={(formData.approverCount || 1) >= systemPolicy.maxApprovalLevels}
|
||||||
data-testid="approval-workflow-increase-count"
|
data-testid="approval-workflow-increase-count"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
Maximum 10 approvers allowed. Each approver will review sequentially.
|
Maximum {systemPolicy.maxApprovalLevels} approver{systemPolicy.maxApprovalLevels !== 1 ? 's' : ''} allowed. Each approver will review sequentially.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -266,13 +282,13 @@ export function ApprovalWorkflowStep({
|
|||||||
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
|
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
|
||||||
const level = index + 1;
|
const level = index + 1;
|
||||||
const isLast = level === (formData.approverCount || 1);
|
const isLast = level === (formData.approverCount || 1);
|
||||||
|
|
||||||
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
|
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
|
||||||
const approver = formData.approvers[index] || {
|
const approver = formData.approvers[index] || {
|
||||||
email: '',
|
email: '',
|
||||||
name: '',
|
name: '',
|
||||||
level: level,
|
level: level,
|
||||||
tat: '' as any
|
tat: '' as any
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -280,18 +296,16 @@ export function ApprovalWorkflowStep({
|
|||||||
<div className="flex justify-center">
|
<div className="flex justify-center">
|
||||||
<div className="w-px h-6 bg-gray-300"></div>
|
<div className="w-px h-6 bg-gray-300"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`p-4 rounded-lg border-2 transition-all ${
|
<div className={`p-4 rounded-lg border-2 transition-all ${approver.email
|
||||||
approver.email
|
? 'border-green-200 bg-green-50'
|
||||||
? 'border-green-200 bg-green-50'
|
: 'border-gray-200 bg-gray-50'
|
||||||
: 'border-gray-200 bg-gray-50'
|
}`}>
|
||||||
}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${approver.email
|
||||||
approver.email
|
? 'bg-green-600'
|
||||||
? 'bg-green-600'
|
: 'bg-gray-400'
|
||||||
: 'bg-gray-400'
|
}`}>
|
||||||
}`}>
|
|
||||||
<span className="text-white font-semibold">{level}</span>
|
<span className="text-white font-semibold">{level}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -320,7 +334,7 @@ export function ApprovalWorkflowStep({
|
|||||||
<Input
|
<Input
|
||||||
id={`approver-${level}`}
|
id={`approver-${level}`}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="approver@royalenfield.com"
|
placeholder={`approver@${import.meta.env.VITE_APP_DOMAIN}`}
|
||||||
value={approver.email || ''}
|
value={approver.email || ''}
|
||||||
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
|
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
|
||||||
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
|
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export function DocumentsStep({
|
|||||||
// Check file extension
|
// Check file extension
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
validationErrors.push({
|
validationErrors.push({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
@ -111,16 +111,16 @@ export function DocumentsStep({
|
|||||||
const type = (doc.fileType || doc.file_type || '').toLowerCase();
|
const type = (doc.fileType || doc.file_type || '').toLowerCase();
|
||||||
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
|
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
|
||||||
return type.includes('image') || type.includes('pdf') ||
|
return type.includes('image') || type.includes('pdf') ||
|
||||||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
||||||
name.endsWith('.png') || name.endsWith('.gif') ||
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
||||||
name.endsWith('.pdf');
|
name.endsWith('.pdf');
|
||||||
} else {
|
} else {
|
||||||
const type = (doc.type || '').toLowerCase();
|
const type = (doc.type || '').toLowerCase();
|
||||||
const name = (doc.name || '').toLowerCase();
|
const name = (doc.name || '').toLowerCase();
|
||||||
return type.includes('image') || type.includes('pdf') ||
|
return type.includes('image') || type.includes('pdf') ||
|
||||||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
||||||
name.endsWith('.png') || name.endsWith('.gif') ||
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
||||||
name.endsWith('.pdf');
|
name.endsWith('.pdf');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ export function DocumentsStep({
|
|||||||
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Drag and drop files here, or click to browse
|
click to browse
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@ -172,10 +172,10 @@ export function DocumentsStep({
|
|||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
data-testid="documents-file-input"
|
data-testid="documents-file-input"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
data-testid="documents-browse-button"
|
data-testid="documents-browse-button"
|
||||||
>
|
>
|
||||||
@ -206,7 +206,7 @@ export function DocumentsStep({
|
|||||||
const docId = doc.documentId || doc.document_id || '';
|
const docId = doc.documentId || doc.document_id || '';
|
||||||
const isDeleted = documentsToDelete.includes(docId);
|
const isDeleted = documentsToDelete.includes(docId);
|
||||||
if (isDeleted) return null;
|
if (isDeleted) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
|
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -222,9 +222,9 @@ export function DocumentsStep({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canPreview(doc, true) && (
|
{canPreview(doc, true) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onPreviewDocument(doc, true)}
|
onClick={() => onPreviewDocument(doc, true)}
|
||||||
data-testid={`documents-existing-${docId}-preview`}
|
data-testid={`documents-existing-${docId}-preview`}
|
||||||
>
|
>
|
||||||
@ -276,9 +276,9 @@ export function DocumentsStep({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{canPreview(file, false) && (
|
{canPreview(file, false) && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onPreviewDocument(file, false)}
|
onClick={() => onPreviewDocument(file, false)}
|
||||||
data-testid={`documents-new-${index}-preview`}
|
data-testid={`documents-new-${index}-preview`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,15 +1,19 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Check, Clock, Users, Info, Flame, Target, TrendingUp } from 'lucide-react';
|
import { Check, Clock, Users, Flame, Target, TrendingUp, FolderOpen, ArrowLeft, Info } from 'lucide-react';
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
interface TemplateSelectionStepProps {
|
interface TemplateSelectionStepProps {
|
||||||
templates: RequestTemplate[];
|
templates: RequestTemplate[];
|
||||||
selectedTemplate: RequestTemplate | null;
|
selectedTemplate: RequestTemplate | null;
|
||||||
onSelectTemplate: (template: RequestTemplate) => void;
|
onSelectTemplate: (template: RequestTemplate) => void;
|
||||||
|
adminTemplates?: RequestTemplate[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPriorityIcon = (priority: string) => {
|
const getPriorityIcon = (priority: string) => {
|
||||||
@ -21,21 +25,48 @@ const getPriorityIcon = (priority: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Component: TemplateSelectionStep
|
|
||||||
*
|
|
||||||
* Purpose: Step 1 - Template selection for request creation
|
|
||||||
*
|
|
||||||
* Features:
|
|
||||||
* - Displays available templates
|
|
||||||
* - Shows template details when selected
|
|
||||||
* - Test IDs for testing
|
|
||||||
*/
|
|
||||||
export function TemplateSelectionStep({
|
export function TemplateSelectionStep({
|
||||||
templates,
|
templates,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
onSelectTemplate
|
onSelectTemplate,
|
||||||
|
adminTemplates = []
|
||||||
}: TemplateSelectionStepProps) {
|
}: TemplateSelectionStepProps) {
|
||||||
|
const [viewMode, setViewMode] = useState<'main' | 'admin'>('main');
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleTemplateClick = (template: RequestTemplate) => {
|
||||||
|
if (template.id === 'admin-templates-category') {
|
||||||
|
setViewMode('admin');
|
||||||
|
} else {
|
||||||
|
if (viewMode === 'admin') {
|
||||||
|
// If selecting an actual admin template, redirect to dedicated flow
|
||||||
|
navigate(`/create-admin-request/${template.id}`);
|
||||||
|
} else {
|
||||||
|
// Default behavior for standard templates
|
||||||
|
onSelectTemplate(template);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayTemplates = viewMode === 'main'
|
||||||
|
? [
|
||||||
|
...templates,
|
||||||
|
// {
|
||||||
|
// id: 'admin-templates-category',
|
||||||
|
// name: 'Admin Templates',
|
||||||
|
// description: 'Browse standardized request workflows created by your organization administrators',
|
||||||
|
// category: 'Organization',
|
||||||
|
// icon: FolderOpen,
|
||||||
|
// estimatedTime: 'Variable',
|
||||||
|
// commonApprovers: [],
|
||||||
|
// suggestedSLA: 0,
|
||||||
|
// priority: 'medium',
|
||||||
|
// fields: {}
|
||||||
|
// } as any
|
||||||
|
]
|
||||||
|
: adminTemplates;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -47,100 +78,154 @@ export function TemplateSelectionStep({
|
|||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
|
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
|
||||||
Choose Your Request Type
|
{viewMode === 'main' ? 'Choose Your Request Type' : 'Organization Templates'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600" data-testid="template-selection-description">
|
<p className="text-lg text-gray-600" data-testid="template-selection-description">
|
||||||
Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.
|
{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.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{viewMode === 'admin' && (
|
||||||
|
<div className="w-full max-w-6xl mb-6 flex justify-start">
|
||||||
|
<Button variant="ghost" className="gap-2" onClick={() => setViewMode('main')}>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Back to All Types
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Template Cards Grid */}
|
{/* Template Cards Grid */}
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
|
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
|
||||||
data-testid="template-selection-grid"
|
data-testid="template-selection-grid"
|
||||||
>
|
>
|
||||||
{templates.map((template) => (
|
{displayTemplates.length === 0 && viewMode === 'admin' ? (
|
||||||
<motion.div
|
<div className="col-span-full text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
|
||||||
key={template.id}
|
<FolderOpen className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||||
whileHover={{ scale: 1.03 }}
|
<p>No admin templates available yet.</p>
|
||||||
whileTap={{ scale: 0.98 }}
|
</div>
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
) : (
|
||||||
data-testid={`template-card-${template.id}`}
|
displayTemplates.map((template) => {
|
||||||
>
|
const isComingSoon = false;
|
||||||
<Card
|
const isDisabled = isComingSoon;
|
||||||
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
|
const isCategoryCard = template.id === 'admin-templates-category';
|
||||||
selectedTemplate?.id === template.id
|
// const isCustomCard = template.id === 'custom';
|
||||||
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
const isSelected = selectedTemplate?.id === template.id;
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
|
||||||
}`}
|
return (
|
||||||
onClick={() => onSelectTemplate(template)}
|
<motion.div
|
||||||
data-testid={`template-card-${template.id}-clickable`}
|
key={template.id}
|
||||||
>
|
whileHover={!isDisabled ? { scale: 1.03 } : {}}
|
||||||
<CardHeader className="space-y-4 pb-4">
|
whileTap={!isDisabled ? { scale: 0.98 } : {}}
|
||||||
<div className="flex items-start justify-between">
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||||
<div
|
data-testid={`template-card-${template.id}`}
|
||||||
className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
>
|
||||||
selectedTemplate?.id === template.id
|
<Card
|
||||||
? 'bg-blue-100'
|
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
||||||
: 'bg-gray-100'
|
? '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'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`template-card-${template.id}-icon`}
|
onClick={!isDisabled ? () => handleTemplateClick(template) : undefined}
|
||||||
>
|
data-testid={`template-card-${template.id}-clickable`}
|
||||||
<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`}
|
|
||||||
>
|
|
||||||
<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">
|
|
||||||
<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}
|
<CardHeader className="space-y-4 pb-4">
|
||||||
</p>
|
<div className="flex items-start justify-between">
|
||||||
<Separator />
|
<div
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
className={`w-14 h-14 rounded-xl flex items-center justify-center ${isSelected
|
||||||
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
|
? 'bg-blue-100'
|
||||||
<Clock className="w-3.5 h-3.5" />
|
: isCategoryCard
|
||||||
<span>{template.estimatedTime}</span>
|
? 'bg-blue-100'
|
||||||
</div>
|
: 'bg-gray-100'
|
||||||
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
|
}`}
|
||||||
<Users className="w-3.5 h-3.5" />
|
data-testid={`template-card-${template.id}-icon`}
|
||||||
<span>{template.commonApprovers.length} approvers</span>
|
>
|
||||||
</div>
|
<template.icon
|
||||||
</div>
|
className={`w-7 h-7 ${isSelected
|
||||||
</CardContent>
|
? 'text-blue-600'
|
||||||
</Card>
|
: isCategoryCard
|
||||||
</motion.div>
|
? 'text-blue-600'
|
||||||
))}
|
: 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0 }}
|
||||||
|
animate={{ scale: 1 }}
|
||||||
|
transition={{ type: "spring", stiffness: 500, damping: 15 }}
|
||||||
|
data-testid={`template-card-${template.id}-selected-indicator`}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
||||||
|
<Check className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
|
<CardTitle className="text-xl" data-testid={`template-card-${template.id}-name`}>
|
||||||
|
{template.name}
|
||||||
|
</CardTitle>
|
||||||
|
{isComingSoon && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-yellow-100 text-yellow-700 border-yellow-300 font-semibold"
|
||||||
|
data-testid={`template-card-${template.id}-coming-soon-badge`}
|
||||||
|
>
|
||||||
|
Coming Soon
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
|
||||||
|
{template.category}
|
||||||
|
</Badge>
|
||||||
|
{getPriorityIcon(template.priority)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0 space-y-4">
|
||||||
|
<p
|
||||||
|
className="text-sm text-gray-600 leading-relaxed line-clamp-2"
|
||||||
|
data-testid={`template-card-${template.id}-description`}
|
||||||
|
>
|
||||||
|
{template.description}
|
||||||
|
</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 →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Template Details Card */}
|
{/* Template Details Card */}
|
||||||
@ -165,7 +250,7 @@ export function TemplateSelectionStep({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla">
|
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla">
|
||||||
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
|
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
|
||||||
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} days</p>
|
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} hours</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
|
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
|
||||||
<Label className="text-blue-900 font-semibold">Priority Level</Label>
|
<Label className="text-blue-900 font-semibold">Priority Level</Label>
|
||||||
@ -180,18 +265,22 @@ export function TemplateSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
|
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
|
||||||
<Label className="text-blue-900 font-semibold">Common Approvers</Label>
|
<Label className="text-blue-900 font-semibold">Approvers</Label>
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
{selectedTemplate.commonApprovers.map((approver, index) => (
|
{selectedTemplate.commonApprovers?.length > 0 ? (
|
||||||
<Badge
|
selectedTemplate.commonApprovers.map((approver, index) => (
|
||||||
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
|
<Badge
|
||||||
variant="outline"
|
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
|
||||||
className="border-blue-300 text-blue-700 bg-white"
|
variant="outline"
|
||||||
data-testid={`template-details-approver-${index}`}
|
className="border-blue-300 text-blue-700 bg-white"
|
||||||
>
|
data-testid={`template-details-approver-${index}`}
|
||||||
{approver}
|
>
|
||||||
</Badge>
|
{approver}
|
||||||
))}
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500 italic">No specific approvers defined</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -19,17 +19,20 @@ interface WizardStepperProps {
|
|||||||
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
|
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
|
||||||
const progressPercentage = Math.round((currentStep / totalSteps) * 100);
|
const progressPercentage = Math.round((currentStep / totalSteps) * 100);
|
||||||
|
|
||||||
|
// Use a narrower container for fewer steps to avoid excessive spacing
|
||||||
|
const containerMaxWidth = stepNames.length <= 3 ? 'max-w-xl' : 'max-w-6xl';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
|
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
|
||||||
data-testid="wizard-stepper"
|
data-testid="wizard-stepper"
|
||||||
>
|
>
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className={`${containerMaxWidth} mx-auto`}>
|
||||||
{/* Mobile: Current step indicator only */}
|
{/* Mobile: Current step indicator only */}
|
||||||
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
|
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold"
|
className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold"
|
||||||
data-testid="wizard-stepper-mobile-current-step"
|
data-testid="wizard-stepper-mobile-current-step"
|
||||||
>
|
>
|
||||||
@ -51,11 +54,11 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Progress bar */}
|
{/* Progress bar */}
|
||||||
<div
|
<div
|
||||||
className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden"
|
className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden"
|
||||||
data-testid="wizard-stepper-mobile-progress-bar"
|
data-testid="wizard-stepper-mobile-progress-bar"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="bg-green-600 h-full transition-all duration-300"
|
className="bg-green-600 h-full transition-all duration-300"
|
||||||
style={{ width: `${progressPercentage}%` }}
|
style={{ width: `${progressPercentage}%` }}
|
||||||
data-testid="wizard-stepper-mobile-progress-fill"
|
data-testid="wizard-stepper-mobile-progress-fill"
|
||||||
@ -65,17 +68,16 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
|
|||||||
|
|
||||||
{/* Desktop: Full step indicator */}
|
{/* Desktop: Full step indicator */}
|
||||||
<div className="hidden sm:block" data-testid="wizard-stepper-desktop">
|
<div className="hidden sm:block" data-testid="wizard-stepper-desktop">
|
||||||
<div className="flex items-center justify-between mb-2" data-testid="wizard-stepper-desktop-steps">
|
<div className="flex items-center justify-center gap-4 mb-2" data-testid="wizard-stepper-desktop-steps">
|
||||||
{stepNames.map((_, index) => (
|
{stepNames.map((_, index) => (
|
||||||
<div key={index} className="flex items-center" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
|
<div key={index} className="flex items-center flex-1 last:flex-none" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 ${index + 1 < currentStep
|
||||||
index + 1 < currentStep
|
? 'bg-green-500 text-white'
|
||||||
? 'bg-green-600 text-white'
|
: index + 1 === currentStep
|
||||||
: index + 1 === currentStep
|
? 'bg-green-500 text-white ring-2 ring-green-500/30 ring-offset-1'
|
||||||
? 'bg-blue-600 text-white'
|
|
||||||
: 'bg-gray-200 text-gray-600'
|
: 'bg-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
|
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
|
||||||
>
|
>
|
||||||
{index + 1 < currentStep ? (
|
{index + 1 < currentStep ? (
|
||||||
@ -85,26 +87,24 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{index < stepNames.length - 1 && (
|
{index < stepNames.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`w-8 md:w-12 lg:w-16 h-1 mx-1 md:mx-2 ${
|
className={`flex-1 h-0.5 mx-2 ${index + 1 < currentStep ? 'bg-green-500' : 'bg-gray-200'
|
||||||
index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
|
}`}
|
||||||
}`}
|
|
||||||
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
|
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2"
|
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2 px-1"
|
||||||
data-testid="wizard-stepper-desktop-labels"
|
data-testid="wizard-stepper-desktop-labels"
|
||||||
>
|
>
|
||||||
{stepNames.map((step, index) => (
|
{stepNames.map((step, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className={`${
|
className={`${index + 1 === currentStep ? 'font-semibold text-green-600' : ''
|
||||||
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
}`}
|
||||||
}`}
|
|
||||||
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
|
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
|
||||||
>
|
>
|
||||||
{step}
|
{step}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ export interface DocumentData {
|
|||||||
documentId: string;
|
documentId: string;
|
||||||
name: string;
|
name: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
size: string;
|
size?: string;
|
||||||
sizeBytes?: number;
|
sizeBytes?: number;
|
||||||
uploadedBy?: string;
|
uploadedBy?: string;
|
||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
@ -48,7 +48,9 @@ export function DocumentCard({
|
|||||||
{document.name}
|
{document.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
||||||
{document.size} • Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
|
{document.size && <span>{document.size} • </span>}
|
||||||
|
{document.uploadedBy && <span>Uploaded by {document.uploadedBy} on </span>}
|
||||||
|
{formatDateTime(document.uploadedAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { createContext, useContext, useEffect, useState, ReactNode, useRef } fro
|
|||||||
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
|
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
|
||||||
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
|
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
|
||||||
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
|
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
|
||||||
|
import { tanflowLogout } from '../services/tanflowAuth';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
@ -72,15 +73,15 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
|
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
|
||||||
const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
|
const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
|
||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
|
|
||||||
if (logoutFlag === 'true' || forceLogout === 'true') {
|
if (logoutFlag === 'true' || forceLogout === 'true') {
|
||||||
// Remove flags
|
// Remove flags
|
||||||
sessionStorage.removeItem('__logout_in_progress__');
|
sessionStorage.removeItem('__logout_in_progress__');
|
||||||
sessionStorage.removeItem('__force_logout__');
|
sessionStorage.removeItem('__force_logout__');
|
||||||
|
|
||||||
// Clear all tokens one more time (aggressive)
|
// Clear all tokens one more time (aggressive)
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
|
||||||
// Also manually clear everything
|
// Also manually clear everything
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
@ -88,71 +89,81 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing storage:', e);
|
console.error('Error clearing storage:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set unauthenticated state
|
// Set unauthenticated state
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
|
if (urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out')) {
|
||||||
|
console.log('🚪 Logout parameter detected in URL, clearing all tokens');
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
// Clear auth provider flag and logout-related flags
|
||||||
|
sessionStorage.removeItem('auth_provider');
|
||||||
|
sessionStorage.removeItem('tanflow_auth_state');
|
||||||
|
sessionStorage.removeItem('__logout_in_progress__');
|
||||||
|
sessionStorage.removeItem('__force_logout__');
|
||||||
|
sessionStorage.removeItem('tanflow_logged_out');
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
// Don't clear sessionStorage completely - we might need logout flags
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
// Clean URL but preserve okta_logged_out flag if it exists (for prompt=login)
|
// Clean URL but preserve logout flags if they exist (for prompt=login)
|
||||||
const cleanParams = new URLSearchParams();
|
const cleanParams = new URLSearchParams();
|
||||||
if (urlParams.has('okta_logged_out')) {
|
if (urlParams.has('okta_logged_out')) {
|
||||||
cleanParams.set('okta_logged_out', 'true');
|
cleanParams.set('okta_logged_out', 'true');
|
||||||
}
|
}
|
||||||
|
if (urlParams.has('tanflow_logged_out')) {
|
||||||
|
cleanParams.set('tanflow_logged_out', 'true');
|
||||||
|
}
|
||||||
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
|
||||||
window.history.replaceState({}, document.title, newUrl);
|
window.history.replaceState({}, document.title, newUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
|
||||||
// This is critical for production mode where we need to exchange code for tokens
|
// This is essential for production mode where we need to exchange code for tokens
|
||||||
// before we can verify session with server
|
// before we can verify session with server
|
||||||
if (window.location.pathname === '/login/callback') {
|
if (window.location.pathname === '/login/callback' || window.location.pathname === '/login/tanflow/callback') {
|
||||||
// Don't check auth status here - let the callback handler do its job
|
// Don't check auth status here - let the callback handler do its job
|
||||||
// The callback handler will set isAuthenticated after successful token exchange
|
// The callback handler will set isAuthenticated after successful token exchange
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 4: Check authentication status
|
// PRIORITY 4: Check authentication status
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
const userData = TokenManager.getUserData();
|
const userData = TokenManager.getUserData();
|
||||||
const hasAuthData = token || refreshToken || userData;
|
const hasAuthData = token || refreshToken || userData;
|
||||||
|
|
||||||
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
// In production: Always verify with server (cookies are sent automatically)
|
// In production: Always verify with server (cookies are sent automatically)
|
||||||
// In development: Check local auth data first
|
// In development: Check local auth data first
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
// Production: Verify session with server via httpOnly cookie
|
// Prod: Verify session with server via httpOnly cookie
|
||||||
if (!isLoggingOut) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Development: If no auth data exists, user is not authenticated
|
// Dev: If no auth data exists, user is not authenticated
|
||||||
if (!hasAuthData) {
|
if (!hasAuthData) {
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
|
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
|
||||||
if (!isLoggingOut) {
|
if (!isLoggingOut) {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
@ -200,7 +211,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// Handle callback from OAuth redirect
|
// Handle callback from OAuth redirect
|
||||||
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
|
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
|
||||||
const callbackProcessedRef = useRef(false);
|
const callbackProcessedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if already processed or not on callback page
|
// Skip if already processed or not on callback page
|
||||||
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
|
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
|
||||||
@ -208,24 +219,57 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCallback = async () => {
|
const handleCallback = async () => {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
|
// Check if this is a logout redirect (from Tanflow post-logout redirect)
|
||||||
|
// If it has logout parameters but no code, it's a logout redirect, not a login callback
|
||||||
|
if ((urlParams.has('logout') || urlParams.has('tanflow_logged_out') || urlParams.has('okta_logged_out')) && !urlParams.get('code')) {
|
||||||
|
// This is a logout redirect, not a login callback
|
||||||
|
// Redirect to home page - the mount useEffect will handle logout cleanup
|
||||||
|
console.log('🚪 Logout redirect detected in callback, redirecting to home');
|
||||||
|
// Extract the logout flags from current URL
|
||||||
|
const logoutFlags = new URLSearchParams();
|
||||||
|
if (urlParams.has('tanflow_logged_out')) logoutFlags.set('tanflow_logged_out', 'true');
|
||||||
|
if (urlParams.has('okta_logged_out')) logoutFlags.set('okta_logged_out', 'true');
|
||||||
|
if (urlParams.has('logout')) logoutFlags.set('logout', urlParams.get('logout') || Date.now().toString());
|
||||||
|
const redirectUrl = logoutFlags.toString() ? `/?${logoutFlags.toString()}` : '/?logout=' + Date.now();
|
||||||
|
window.location.replace(redirectUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Mark as processed immediately to prevent duplicate calls
|
// Mark as processed immediately to prevent duplicate calls
|
||||||
callbackProcessedRef.current = true;
|
callbackProcessedRef.current = true;
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
const errorParam = urlParams.get('error');
|
const errorParam = urlParams.get('error');
|
||||||
|
|
||||||
// Clean URL immediately to prevent re-running on re-renders
|
// Clean URL immediately to prevent re-running on re-renders
|
||||||
window.history.replaceState({}, document.title, '/login/callback');
|
window.history.replaceState({}, document.title, '/login/callback');
|
||||||
|
|
||||||
|
// Detect provider from sessionStorage
|
||||||
|
const authProvider = sessionStorage.getItem('auth_provider');
|
||||||
|
|
||||||
|
// If Tanflow provider, handle it separately (will be handled by TanflowCallback component)
|
||||||
|
if (authProvider === 'tanflow') {
|
||||||
|
// Clear the provider flag and let TanflowCallback handle it
|
||||||
|
// Reset ref so TanflowCallback can process
|
||||||
|
callbackProcessedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OKTA callback (default)
|
||||||
if (errorParam) {
|
if (errorParam) {
|
||||||
setError(new Error(`Authentication error: ${errorParam}`));
|
setError(new Error(`Authentication error: ${errorParam}`));
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
// Clear provider flag
|
||||||
|
sessionStorage.removeItem('auth_provider');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
|
// Clear provider flag
|
||||||
|
sessionStorage.removeItem('auth_provider');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,18 +277,21 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request
|
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request
|
||||||
// This is the frontend callback URL, NOT the backend URL
|
// This is the frontend callback URL, NOT the backend URL
|
||||||
// Backend will use this same URI when exchanging code with Okta
|
// Backend will use this same URI when exchanging code with Okta
|
||||||
const redirectUri = `${window.location.origin}/login/callback`;
|
const redirectUri = `${window.location.origin}/login/callback`;
|
||||||
|
|
||||||
const result = await exchangeCodeForTokens(code, redirectUri);
|
const result = await exchangeCodeForTokens(code, redirectUri);
|
||||||
|
|
||||||
setUser(result.user);
|
setUser(result.user);
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
// Clear provider flag after successful authentication
|
||||||
|
sessionStorage.removeItem('auth_provider');
|
||||||
|
|
||||||
// Clean URL after success
|
// Clean URL after success
|
||||||
window.history.replaceState({}, document.title, '/');
|
window.history.replaceState({}, document.title, '/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -252,6 +299,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setError(err);
|
setError(err);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
// Clear provider flag on error
|
||||||
|
sessionStorage.removeItem('auth_provider');
|
||||||
// Reset ref on error so user can retry if needed
|
// Reset ref on error so user can retry if needed
|
||||||
callbackProcessedRef.current = false;
|
callbackProcessedRef.current = false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -268,17 +317,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
// PRODUCTION MODE: Verify session via httpOnly cookie
|
// Prod MODE: Verify session via httpOnly cookie
|
||||||
// The cookie is sent automatically with the request (withCredentials: true)
|
// The cookie is sent automatically with the request (withCredentials: true)
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
const storedUser = TokenManager.getUserData();
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
// Try to get current user from server - this validates the httpOnly cookie
|
// Try to get current user from server - this validates the httpOnly cookie
|
||||||
try {
|
try {
|
||||||
const userData = await getCurrentUser();
|
const userData = await getCurrentUser();
|
||||||
@ -319,8 +368,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEVELOPMENT MODE: Check local token
|
// Dev MODE: Check local token
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const storedUser = TokenManager.getUserData();
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
@ -405,24 +454,27 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
// Redirect to Okta login
|
// Redirect to Okta login
|
||||||
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || 'https://dev-830839.oktapreview.com';
|
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || '{{IDP_DOMAIN}}';
|
||||||
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
|
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
|
||||||
const redirectUri = `${window.location.origin}/login/callback`;
|
const redirectUri = `${window.location.origin}/login/callback`;
|
||||||
const responseType = 'code';
|
const responseType = 'code';
|
||||||
const scope = 'openid profile email';
|
const scope = 'openid profile email';
|
||||||
const state = Math.random().toString(36).substring(7);
|
const state = Math.random().toString(36).substring(7);
|
||||||
|
|
||||||
|
// Store provider type to identify OKTA callback
|
||||||
|
sessionStorage.setItem('auth_provider', 'okta');
|
||||||
|
|
||||||
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out');
|
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out') || urlParams.has('tanflow_logged_out');
|
||||||
|
|
||||||
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
||||||
`client_id=${clientId}&` +
|
`client_id=${clientId}&` +
|
||||||
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
||||||
`response_type=${responseType}&` +
|
`response_type=${responseType}&` +
|
||||||
`scope=${encodeURIComponent(scope)}&` +
|
`scope=${encodeURIComponent(scope)}&` +
|
||||||
`state=${state}`;
|
`state=${state}`;
|
||||||
|
|
||||||
// Add prompt=login if coming from logout to force re-authentication
|
// Add prompt=login if coming from logout to force re-authentication
|
||||||
// This ensures Okta requires login even if a session still exists
|
// This ensures Okta requires login even if a session still exists
|
||||||
if (isAfterLogout) {
|
if (isAfterLogout) {
|
||||||
@ -438,50 +490,84 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
try {
|
try {
|
||||||
// CRITICAL: Get id_token from TokenManager before clearing anything
|
//: Get id_token from TokenManager before clearing anything
|
||||||
// Okta logout endpoint works better with id_token_hint to properly end the session
|
// Needed for both Okta and Tanflow logout endpoints
|
||||||
// Note: Currently not used but kept for future Okta integration
|
const idToken = TokenManager.getIdToken();
|
||||||
void TokenManager.getIdToken();
|
|
||||||
|
// Detect which provider was used for login (check sessionStorage or user data)
|
||||||
|
// If auth_provider is set, use it; otherwise check if we have Tanflow id_token pattern
|
||||||
|
const authProvider = sessionStorage.getItem('auth_provider') ||
|
||||||
|
(idToken && idToken.includes('tanflow') ? 'tanflow' : null) ||
|
||||||
|
'okta'; // Default to OKTA if unknown
|
||||||
|
|
||||||
// Set logout flag to prevent auto-authentication after redirect
|
// Set logout flag to prevent auto-authentication after redirect
|
||||||
// This must be set BEFORE clearing storage so it survives
|
// This must be set BEFORE clearing storage so it survives
|
||||||
sessionStorage.setItem('__logout_in_progress__', 'true');
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
||||||
sessionStorage.setItem('__force_logout__', 'true');
|
sessionStorage.setItem('__force_logout__', 'true');
|
||||||
setIsLoggingOut(true);
|
setIsLoggingOut(true);
|
||||||
|
|
||||||
// Reset auth state FIRST to prevent any re-authentication
|
// Reset auth state FIRST to prevent any re-authentication
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
||||||
|
|
||||||
// Call backend logout API to clear server-side session and httpOnly cookies
|
// Call backend logout API to clear server-side session and httpOnly cookies
|
||||||
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
|
||||||
try {
|
try {
|
||||||
await logoutApi();
|
await logoutApi();
|
||||||
|
console.log('🚪 Backend logout API called successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('🚪 Logout API error:', err);
|
console.error('🚪 Logout API error:', err);
|
||||||
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
|
||||||
// Continue with logout even if API call fails
|
// Continue with logout even if API call fails
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout)
|
// Preserve logout flags, id_token, and auth_provider BEFORE clearing tokens
|
||||||
|
|
||||||
// Clear tokens but preserve logout flags
|
|
||||||
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
|
||||||
const forceLogout = sessionStorage.getItem('__force_logout__');
|
const forceLogout = sessionStorage.getItem('__force_logout__');
|
||||||
|
const storedAuthProvider = sessionStorage.getItem('auth_provider');
|
||||||
// Use TokenManager.clearAll() but then restore logout flags
|
|
||||||
|
// Clear all tokens EXCEPT id_token (we need it for provider logout)
|
||||||
|
// Note: We'll clear id_token after provider logout
|
||||||
|
// Clear tokens (but we'll restore id_token if needed)
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
|
|
||||||
// Restore logout flags immediately after clearAll
|
// Restore logout flags and id_token immediately after clearAll
|
||||||
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
|
||||||
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
|
||||||
|
if (idToken) {
|
||||||
|
TokenManager.setIdToken(idToken); // Restore id_token for provider logout
|
||||||
|
}
|
||||||
|
if (storedAuthProvider) {
|
||||||
|
sessionStorage.setItem('auth_provider', storedAuthProvider); // Restore for logout
|
||||||
|
}
|
||||||
|
|
||||||
// Small delay to ensure sessionStorage is written before redirect
|
// Small delay to ensure sessionStorage is written before redirect
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
// Redirect directly to login page with flags
|
// 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
|
||||||
|
}
|
||||||
// The okta_logged_out flag will trigger prompt=login in the login() function
|
// The okta_logged_out flag will trigger prompt=login in the login() function
|
||||||
// This forces re-authentication even if Okta session still exists
|
// This forces re-authentication even if Okta session still exists
|
||||||
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
|
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
|
||||||
@ -504,7 +590,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const getAccessTokenSilently = async (): Promise<string | null> => {
|
const getAccessTokenSilently = async (): Promise<string | null> => {
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
// In production mode, tokens are in httpOnly cookies
|
// In production mode, tokens are in httpOnly cookies
|
||||||
// We can't access them directly, but API calls will include them automatically
|
// We can't access them directly, but API calls will include them automatically
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
@ -513,7 +599,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
|
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to refresh the session
|
// Try to refresh the session
|
||||||
try {
|
try {
|
||||||
await refreshTokenSilently();
|
await refreshTokenSilently();
|
||||||
@ -522,8 +608,8 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Development mode: tokens in localStorage
|
// Dev mode: tokens in localStorage
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
if (token && !isTokenExpired(token)) {
|
if (token && !isTokenExpired(token)) {
|
||||||
return token;
|
return token;
|
||||||
@ -540,17 +626,17 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const refreshTokenSilently = async (): Promise<void> => {
|
const refreshTokenSilently = async (): Promise<void> => {
|
||||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newToken = await refreshAccessToken();
|
const newToken = await refreshAccessToken();
|
||||||
|
|
||||||
// In production, refresh might not return token (it's in httpOnly cookie)
|
// In production, refresh might not return token (it's in httpOnly cookie)
|
||||||
// but if the call succeeded, the session is valid
|
// but if the call succeeded, the session is valid
|
||||||
if (isProductionMode) {
|
if (isProductionMode) {
|
||||||
// Session refreshed via cookies
|
// Session refreshed via cookies
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
// Token refreshed successfully (development mode)
|
// Token refreshed successfully (development mode)
|
||||||
return;
|
return;
|
||||||
@ -586,7 +672,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<Auth0Provider
|
<Auth0Provider
|
||||||
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
|
domain="{{IDP_DOMAIN}}/oauth2/default/v1"
|
||||||
clientId="0oa2j8slwj5S4bG5k0h8"
|
clientId="0oa2j8slwj5S4bG5k0h8"
|
||||||
authorizationParams={{
|
authorizationParams={{
|
||||||
redirect_uri: window.location.origin + '/login/callback',
|
redirect_uri: window.location.origin + '/login/callback',
|
||||||
|
|||||||
172
src/custom/components/ClosedRequestsFilters.tsx
Normal file
172
src/custom/components/ClosedRequestsFilters.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
161
src/custom/components/RequestsFilters.tsx
Normal file
161
src/custom/components/RequestsFilters.tsx
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
457
src/custom/components/UserAllRequestsFilters.tsx
Normal file
457
src/custom/components/UserAllRequestsFilters.tsx
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -23,5 +23,10 @@ export { CreateRequest as CustomCreateRequest } from './components/request-creat
|
|||||||
// Request Detail Screen (Complete standalone screen)
|
// Request Detail Screen (Complete standalone screen)
|
||||||
export { CustomRequestDetail } from './pages/RequestDetail';
|
export { CustomRequestDetail } from './pages/RequestDetail';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
export { StandardRequestsFilters } from './components/RequestsFilters';
|
||||||
|
export { StandardClosedRequestsFilters } from './components/ClosedRequestsFilters';
|
||||||
|
export { StandardUserAllRequestsFilters } from './components/UserAllRequestsFilters';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
||||||
|
|||||||
@ -37,6 +37,8 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
|
|||||||
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
||||||
import { useModalManager } from '@/hooks/useModalManager';
|
import { useModalManager } from '@/hooks/useModalManager';
|
||||||
import { downloadDocument } from '@/services/workflowApi';
|
import { downloadDocument } from '@/services/workflowApi';
|
||||||
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||||
|
|
||||||
// Custom Request Components (import from index to get properly aliased exports)
|
// Custom Request Components (import from index to get properly aliased exports)
|
||||||
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
|
import { CustomOverviewTab, CustomWorkflowTab } from '../index';
|
||||||
@ -112,6 +114,24 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
const [showResumeModal, setShowResumeModal] = useState(false);
|
||||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||||
|
const [systemPolicy, setSystemPolicy] = useState<{
|
||||||
|
maxApprovalLevels: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
allowSpectators: boolean;
|
||||||
|
maxSpectators: number;
|
||||||
|
}>({
|
||||||
|
maxApprovalLevels: 10,
|
||||||
|
maxParticipants: 50,
|
||||||
|
allowSpectators: true,
|
||||||
|
maxSpectators: 20
|
||||||
|
});
|
||||||
|
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
violations: []
|
||||||
|
});
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
@ -177,8 +197,37 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
aiGenerated,
|
aiGenerated,
|
||||||
handleGenerateConclusion,
|
handleGenerateConclusion,
|
||||||
handleFinalizeConclusion,
|
handleFinalizeConclusion,
|
||||||
|
generationAttempts,
|
||||||
|
generationFailed,
|
||||||
|
maxAttemptsReached,
|
||||||
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
|
} = useConclusionRemark(request, requestIdentifier, isInitiator, refreshDetails, onBack, setActionStatus, setShowActionStatusModal);
|
||||||
|
|
||||||
|
// Load system policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSystemPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSystemPolicy({
|
||||||
|
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
||||||
|
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
||||||
|
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
||||||
|
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system policy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSystemPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-switch tab when URL query parameter changes
|
// Auto-switch tab when URL query parameter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@ -232,7 +281,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
};
|
};
|
||||||
|
|
||||||
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
||||||
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
|
const isClosed = request?.status === 'closed';
|
||||||
|
|
||||||
// Fetch summary details if request is closed
|
// Fetch summary details if request is closed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -464,6 +513,9 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
currentUserIsApprover={!!currentApprovalLevel}
|
currentUserIsApprover={!!currentApprovalLevel}
|
||||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||||
currentUserId={(user as any)?.userId}
|
currentUserId={(user as any)?.userId}
|
||||||
|
generationAttempts={generationAttempts}
|
||||||
|
generationFailed={generationFailed}
|
||||||
|
maxAttemptsReached={maxAttemptsReached}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -521,6 +573,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
onAddApprover={handleAddApprover}
|
onAddApprover={handleAddApprover}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
@ -610,6 +664,8 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
actionStatus={actionStatus}
|
actionStatus={actionStatus}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
setShowApproveModal={setShowApproveModal}
|
setShowApproveModal={setShowApproveModal}
|
||||||
setShowRejectModal={setShowRejectModal}
|
setShowRejectModal={setShowRejectModal}
|
||||||
setShowAddApproverModal={setShowAddApproverModal}
|
setShowAddApproverModal={setShowAddApproverModal}
|
||||||
@ -628,6 +684,19 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
|||||||
downloadDocument={downloadDocument}
|
downloadDocument={downloadDocument}
|
||||||
documentPolicy={documentPolicy}
|
documentPolicy={documentPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Policy Violation Modal */}
|
||||||
|
<PolicyViolationModal
|
||||||
|
open={policyViolationModal.open}
|
||||||
|
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
||||||
|
violations={policyViolationModal.violations}
|
||||||
|
policyDetails={{
|
||||||
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
||||||
|
maxParticipants: systemPolicy.maxParticipants,
|
||||||
|
allowSpectators: systemPolicy.allowSpectators,
|
||||||
|
maxSpectators: systemPolicy.maxSpectators,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
142
src/dealer-claim/components/DealerClosedRequestsFilters.tsx
Normal file
142
src/dealer-claim/components/DealerClosedRequestsFilters.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
114
src/dealer-claim/components/DealerRequestsFilters.tsx
Normal file
114
src/dealer-claim/components/DealerRequestsFilters.tsx
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
389
src/dealer-claim/components/DealerUserAllRequestsFilters.tsx
Normal file
389
src/dealer-claim/components/DealerUserAllRequestsFilters.tsx
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||||
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
@ -23,34 +24,34 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { getAllDealers as fetchDealersFromAPI, getDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
import { verifyDealerLogin, searchExternalDealerByCode, type DealerInfo } from '@/services/dealerApi';
|
||||||
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
import { ClaimApproverSelectionStep } from './ClaimApproverSelectionStep';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getPublicConfigurations, AdminConfiguration, getActivityTypes, type ActivityType } from '@/services/adminApi';
|
||||||
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||||
|
|
||||||
|
// CLAIM_STEPS definition (same as in ClaimApproverSelectionStep)
|
||||||
|
const CLAIM_STEPS = [
|
||||||
|
{ level: 1, name: 'Dealer Proposal Submission', description: 'Dealer submits proposal documents', defaultTat: 72, isAuto: false, approverType: 'dealer' },
|
||||||
|
{ level: 2, name: 'Requestor Evaluation', description: 'Initiator evaluates dealer proposal', defaultTat: 48, isAuto: false, approverType: 'initiator' },
|
||||||
|
{ level: 3, name: 'Department Lead Approval', description: 'Department lead approves and blocks IO budget', defaultTat: 72, isAuto: false, approverType: 'manual' },
|
||||||
|
{ level: 4, name: 'Activity Creation', description: 'System auto-processes activity creation', defaultTat: 1, isAuto: true, approverType: 'system' },
|
||||||
|
{ level: 5, name: 'Dealer Completion Documents', description: 'Dealer submits completion documents', defaultTat: 120, isAuto: false, approverType: 'dealer' },
|
||||||
|
{ level: 6, name: 'Requestor Claim Approval', description: 'Initiator approves completion', defaultTat: 48, isAuto: false, approverType: 'initiator' },
|
||||||
|
{ level: 7, name: 'E-Invoice Generation', description: 'System generates e-invoice via DMS', defaultTat: 1, isAuto: true, approverType: 'system' },
|
||||||
|
{ level: 8, name: 'Credit Note Confirmation', description: 'System/Finance processes credit note confirmation', defaultTat: 48, isAuto: true, approverType: 'system' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ClaimManagementWizardProps {
|
interface ClaimManagementWizardProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
onSubmit?: (claimData: any) => void;
|
onSubmit?: (claimData: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLAIM_TYPES = [
|
|
||||||
'Riders Mania Claims',
|
|
||||||
'Marketing Cost – Bike to Vendor',
|
|
||||||
'Media Bike Service',
|
|
||||||
'ARAI Motorcycle Liquidation',
|
|
||||||
'ARAI Certification – STA Approval CNR',
|
|
||||||
'Procurement of Spares/Apparel/GMA for Events',
|
|
||||||
'Fuel for Media Bike Used for Event',
|
|
||||||
'Motorcycle Buyback and Goodwill Support',
|
|
||||||
'Liquidation of Used Motorcycle',
|
|
||||||
'Motorcycle Registration CNR (Owned or Gifted by RE)',
|
|
||||||
'Legal Claims Reimbursement',
|
|
||||||
'Service Camp Claims',
|
|
||||||
'Corporate Claims – Institutional Sales PDI'
|
|
||||||
];
|
|
||||||
|
|
||||||
const STEP_NAMES = [
|
const STEP_NAMES = [
|
||||||
'Claim Details',
|
'Claim Details',
|
||||||
'Approver Selection',
|
'Approver Selection',
|
||||||
@ -60,9 +61,86 @@ const STEP_NAMES = [
|
|||||||
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizardProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [dealers, setDealers] = useState<DealerInfo[]>([]);
|
const [verifyingDealer, setVerifyingDealer] = useState(false);
|
||||||
const [loadingDealers, setLoadingDealers] = useState(true);
|
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 [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
activityName: '',
|
activityName: '',
|
||||||
activityType: '',
|
activityType: '',
|
||||||
@ -85,32 +163,68 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
level: number;
|
level: number;
|
||||||
tat?: number | string;
|
tat?: number | string;
|
||||||
tatType?: 'hours' | 'days';
|
tatType?: 'hours' | 'days';
|
||||||
|
isAdditional?: boolean;
|
||||||
|
insertAfterLevel?: number;
|
||||||
|
stepName?: string;
|
||||||
|
originalStepLevel?: number;
|
||||||
}>
|
}>
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalSteps = STEP_NAMES.length;
|
const totalSteps = STEP_NAMES.length;
|
||||||
|
|
||||||
// Fetch dealers from API on component mount
|
// Handle dealer search input with debouncing
|
||||||
useEffect(() => {
|
const handleDealerSearchInputChange = (value: string) => {
|
||||||
const fetchDealers = async () => {
|
setDealerSearchInput(value);
|
||||||
setLoadingDealers(true);
|
|
||||||
|
// Clear previous timer
|
||||||
|
if (dealerSearchTimer.current) {
|
||||||
|
clearTimeout(dealerSearchTimer.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If input is empty, clear results
|
||||||
|
if (!value || value.trim().length < 2) {
|
||||||
|
setDealerSearchResults([]);
|
||||||
|
setDealerSearchLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
|
setDealerSearchLoading(true);
|
||||||
|
|
||||||
|
// Debounce search
|
||||||
|
dealerSearchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const fetchedDealers = await fetchDealersFromAPI();
|
const result = await searchExternalDealerByCode(value);
|
||||||
setDealers(fetchedDealers);
|
if (result) {
|
||||||
|
// Map external API response to DealerInfo structure
|
||||||
|
const mappedDealer: DealerInfo = {
|
||||||
|
dealerId: result.dealer || result.dealer_code || value,
|
||||||
|
dealerCode: result.dealer || result.dealer_code || value,
|
||||||
|
dealerName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
|
||||||
|
displayName: result['dealer name'] || result.dealer_name || 'Unknown Dealer',
|
||||||
|
email: result['dealer email'] || '',
|
||||||
|
phone: result['dealer phone'] || '',
|
||||||
|
city: result['re city'] || result.city || '',
|
||||||
|
state: result['re state code'] || result.state || '',
|
||||||
|
isLoggedIn: true, // We'll verify this in the next step
|
||||||
|
};
|
||||||
|
setDealerSearchResults([mappedDealer]);
|
||||||
|
} else {
|
||||||
|
setDealerSearchResults([]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to load dealer list.');
|
console.error('Error searching external dealer:', error);
|
||||||
console.error('Error fetching dealers:', error);
|
setDealerSearchResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDealers(false);
|
setDealerSearchLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, 300);
|
||||||
fetchDealers();
|
};
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateFormData = (field: string, value: any) => {
|
const updateFormData = (field: string, value: any) => {
|
||||||
setFormData(prev => {
|
setFormData(prev => {
|
||||||
const updated = { ...prev, [field]: value };
|
const updated = { ...prev, [field]: value };
|
||||||
|
|
||||||
// Validate period dates
|
// Validate period dates
|
||||||
if (field === 'periodStartDate') {
|
if (field === 'periodStartDate') {
|
||||||
// If start date is selected and end date exists, validate end date
|
// If start date is selected and end date exists, validate end date
|
||||||
@ -127,7 +241,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
return prev;
|
return prev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -135,17 +249,20 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
const isStepValid = () => {
|
const isStepValid = () => {
|
||||||
switch (currentStep) {
|
switch (currentStep) {
|
||||||
case 1:
|
case 1:
|
||||||
return formData.activityName &&
|
return formData.activityName &&
|
||||||
formData.activityType &&
|
formData.activityType &&
|
||||||
formData.dealerCode &&
|
formData.dealerCode &&
|
||||||
formData.dealerName &&
|
formData.dealerName &&
|
||||||
formData.activityDate &&
|
formData.activityDate &&
|
||||||
formData.location &&
|
formData.location &&
|
||||||
formData.requestDescription;
|
formData.requestDescription;
|
||||||
case 2:
|
case 2:
|
||||||
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
|
// Validate that all required approvers are assigned (Step 3 only, Step 8 is now system/Finance)
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
const step3Approver = approvers.find((a: any) => a.level === 3);
|
// 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)
|
||||||
|
);
|
||||||
// Step 8 is now a system step, no validation needed
|
// Step 8 is now a system step, no validation needed
|
||||||
return step3Approver?.email && step3Approver?.userId && step3Approver?.tat;
|
return step3Approver?.email && step3Approver?.userId && step3Approver?.tat;
|
||||||
case 3:
|
case 3:
|
||||||
@ -161,13 +278,16 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
// Show specific error messages for step 2 (approver selection)
|
// Show specific error messages for step 2 (approver selection)
|
||||||
if (currentStep === 2) {
|
if (currentStep === 2) {
|
||||||
const approvers = formData.approvers || [];
|
const approvers = formData.approvers || [];
|
||||||
const step3Approver = approvers.find((a: any) => a.level === 3);
|
// 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 missingSteps: string[] = [];
|
const missingSteps: string[] = [];
|
||||||
|
|
||||||
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
|
if (!step3Approver?.email || !step3Approver?.userId || !step3Approver?.tat) {
|
||||||
missingSteps.push('Step 3: Department Lead Approval');
|
missingSteps.push('Department Lead Approval');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingSteps.length > 0) {
|
if (missingSteps.length > 0) {
|
||||||
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
|
toast.error(`Please add missing approvers: ${missingSteps.join(', ')}`);
|
||||||
} else {
|
} else {
|
||||||
@ -188,43 +308,161 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDealerChange = async (dealerCode: string) => {
|
const handleDealerSelect = async (selectedDealer: DealerInfo) => {
|
||||||
const selectedDealer = dealers.find(d => d.dealerCode === dealerCode);
|
// Verify dealer is logged in
|
||||||
if (selectedDealer) {
|
setVerifyingDealer(true);
|
||||||
updateFormData('dealerCode', dealerCode);
|
try {
|
||||||
updateFormData('dealerName', selectedDealer.dealerName);
|
const verifiedDealer = await verifyDealerLogin(selectedDealer.dealerCode);
|
||||||
updateFormData('dealerEmail', selectedDealer.email || '');
|
|
||||||
updateFormData('dealerPhone', selectedDealer.phone || '');
|
if (!verifiedDealer.isLoggedIn) {
|
||||||
updateFormData('dealerAddress', ''); // Address not available in API response
|
toast.error(
|
||||||
|
`Dealer "${verifiedDealer.dealerName || verifiedDealer.displayName}" (${verifiedDealer.dealerCode}) is not mapped to the system.`,
|
||||||
// Try to fetch full dealer info from API
|
{ duration: 5000 }
|
||||||
try {
|
);
|
||||||
const fullDealerInfo = await getDealerByCode(dealerCode);
|
// Clear the selection
|
||||||
if (fullDealerInfo) {
|
setDealerSearchInput('');
|
||||||
updateFormData('dealerEmail', fullDealerInfo.email || selectedDealer.email || '');
|
setDealerSearchResults([]);
|
||||||
updateFormData('dealerPhone', fullDealerInfo.phone || selectedDealer.phone || '');
|
updateFormData('dealerCode', '');
|
||||||
}
|
updateFormData('dealerName', '');
|
||||||
} catch (error) {
|
updateFormData('dealerEmail', '');
|
||||||
// Ignore error, use basic info from list
|
updateFormData('dealerPhone', '');
|
||||||
console.debug('Could not fetch full dealer info:', error);
|
updateFormData('dealerAddress', '');
|
||||||
|
setVerifyingDealer(false);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dealer is logged in, update form data
|
||||||
|
updateFormData('dealerCode', verifiedDealer.dealerCode);
|
||||||
|
updateFormData('dealerName', verifiedDealer.dealerName || verifiedDealer.displayName);
|
||||||
|
updateFormData('dealerEmail', verifiedDealer.email || '');
|
||||||
|
updateFormData('dealerPhone', verifiedDealer.phone || '');
|
||||||
|
updateFormData('dealerAddress', ''); // Address not available in API response
|
||||||
|
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
|
// Prevent multiple submissions
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approvers are already using integer levels with proper shifting
|
||||||
|
// Just sort them and prepare for submission
|
||||||
|
const approvers = formData.approvers || [];
|
||||||
|
const sortedApprovers = [...approvers].sort((a, b) => a.level - b.level);
|
||||||
|
|
||||||
|
// Check for duplicate levels (should not happen, but safeguard)
|
||||||
|
const levelMap = new Map<number, typeof sortedApprovers[0]>();
|
||||||
|
const duplicates: number[] = [];
|
||||||
|
|
||||||
|
sortedApprovers.forEach((approver) => {
|
||||||
|
if (levelMap.has(approver.level)) {
|
||||||
|
duplicates.push(approver.level);
|
||||||
|
} else {
|
||||||
|
levelMap.set(approver.level, approver);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicates.length > 0) {
|
||||||
|
toast.error(`Duplicate approver levels detected: ${duplicates.join(', ')}. Please refresh and try again.`);
|
||||||
|
console.error('Duplicate levels found:', duplicates, sortedApprovers);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare final approvers array - preserve stepName for additional approvers
|
||||||
|
// The backend will use stepName to set the levelName for approval levels
|
||||||
|
// Also preserve originalStepLevel so backend can identify which step each approver belongs to
|
||||||
|
const finalApprovers = sortedApprovers.map((approver) => {
|
||||||
|
const result: any = {
|
||||||
|
email: approver.email,
|
||||||
|
name: approver.name,
|
||||||
|
userId: approver.userId,
|
||||||
|
level: approver.level,
|
||||||
|
tat: approver.tat,
|
||||||
|
tatType: approver.tatType,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preserve stepName for additional approvers
|
||||||
|
if (approver.isAdditional && approver.stepName) {
|
||||||
|
result.stepName = approver.stepName;
|
||||||
|
result.isAdditional = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve originalStepLevel for fixed steps (so backend can identify which step this is)
|
||||||
|
if (approver.originalStepLevel) {
|
||||||
|
result.originalStepLevel = approver.originalStepLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
const claimData = {
|
const claimData = {
|
||||||
...formData,
|
...formData,
|
||||||
templateType: 'claim-management',
|
templateType: 'claim-management',
|
||||||
submittedAt: new Date().toISOString(),
|
submittedAt: new Date().toISOString(),
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
currentStep: 'initiator-review',
|
currentStep: 'initiator-review',
|
||||||
// Pass approvers array to backend
|
// Pass normalized approvers array to backend
|
||||||
approvers: formData.approvers || []
|
approvers: finalApprovers
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Set submitting state to prevent multiple clicks
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (submitTimeoutRef.current) {
|
||||||
|
clearTimeout(submitTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a timeout as a fallback to reset loading state (30 seconds)
|
||||||
|
// In most cases, the parent component will navigate away on success,
|
||||||
|
// but this prevents the button from being stuck in loading state if there's an error
|
||||||
|
submitTimeoutRef.current = setTimeout(() => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
submitTimeoutRef.current = null;
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
// Don't show toast here - let the parent component handle success/error after API call
|
// Don't show toast here - let the parent component handle success/error after API call
|
||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
onSubmit(claimData);
|
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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -264,14 +502,26 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
|
<Label htmlFor="activityType" className="text-base font-semibold">Activity Type *</Label>
|
||||||
<Select value={formData.activityType} onValueChange={(value) => updateFormData('activityType', value)}>
|
<Select
|
||||||
|
value={formData.activityType}
|
||||||
|
onValueChange={(value) => updateFormData('activityType', value)}
|
||||||
|
disabled={loadingActivityTypes}
|
||||||
|
>
|
||||||
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="activityType">
|
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="activityType">
|
||||||
<SelectValue placeholder="Select activity type" />
|
<SelectValue placeholder={loadingActivityTypes ? "Loading activity types..." : "Select activity type"} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{CLAIM_TYPES.map((type) => (
|
{activityTypes.length > 0 ? (
|
||||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
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>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@ -280,38 +530,99 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
{/* Dealer Selection */}
|
{/* Dealer Selection */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
|
<Label className="text-base font-semibold">Dealer Code / Dealer Name *</Label>
|
||||||
<Select value={formData.dealerCode} onValueChange={handleDealerChange} disabled={loadingDealers}>
|
<div className="mt-2">
|
||||||
<SelectTrigger className="mt-2 !h-12 data-[size=default]:!h-12" id="dealer-select">
|
<div className="relative">
|
||||||
<SelectValue placeholder={loadingDealers ? "Loading dealers..." : "Select dealer"}>
|
<Input
|
||||||
{formData.dealerCode && (
|
placeholder="Type dealer code, name, or email to search..."
|
||||||
<div className="flex items-center gap-2">
|
value={formData.dealerCode ? `${formData.dealerName} (${formData.dealerCode})` : dealerSearchInput}
|
||||||
<span className="font-mono text-sm">{formData.dealerCode}</span>
|
onChange={(e) => {
|
||||||
<span className="text-gray-400">•</span>
|
if (formData.dealerCode) {
|
||||||
<span>{formData.dealerName}</span>
|
// If dealer is already selected, clear selection first
|
||||||
</div>
|
updateFormData('dealerCode', '');
|
||||||
)}
|
updateFormData('dealerName', '');
|
||||||
</SelectValue>
|
updateFormData('dealerEmail', '');
|
||||||
</SelectTrigger>
|
updateFormData('dealerPhone', '');
|
||||||
<SelectContent>
|
updateFormData('dealerAddress', '');
|
||||||
{dealers.length === 0 && !loadingDealers ? (
|
setDealerSearchInput(e.target.value);
|
||||||
<div className="p-2 text-sm text-gray-500">No dealers available</div>
|
} else {
|
||||||
) : (
|
handleDealerSearchInputChange(e.target.value);
|
||||||
dealers.map((dealer) => (
|
}
|
||||||
<SelectItem key={dealer.userId} value={dealer.dealerCode}>
|
}}
|
||||||
<div className="flex items-center gap-2">
|
onFocus={() => {
|
||||||
<span className="font-mono text-sm font-semibold">{dealer.dealerCode}</span>
|
// When input is focused, show search results if input has value
|
||||||
<span className="text-gray-400">•</span>
|
if (dealerSearchInput && dealerSearchInput.length >= 2) {
|
||||||
<span>{dealer.dealerName}</span>
|
handleDealerSearchInputChange(dealerSearchInput);
|
||||||
</div>
|
}
|
||||||
</SelectItem>
|
}}
|
||||||
))
|
className="h-12 border-2 border-gray-300 focus:border-blue-500"
|
||||||
|
disabled={verifyingDealer}
|
||||||
|
/>
|
||||||
|
{formData.dealerCode && (
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
||||||
|
<CheckCircle className="w-3 h-3 mr-1" />
|
||||||
|
Verified
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</SelectContent>
|
{/* Search suggestions dropdown */}
|
||||||
</Select>
|
{(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>
|
||||||
{formData.dealerCode && (
|
{formData.dealerCode && (
|
||||||
<p className="text-sm text-gray-600 mt-2">
|
<div className="mt-2 space-y-1">
|
||||||
Selected: <span className="font-semibold">{formData.dealerName}</span> ({formData.dealerCode})
|
<p className="text-sm text-gray-600">
|
||||||
</p>
|
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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -323,10 +634,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start text-left mt-2 h-12"
|
className="w-full justify-start text-left mt-2 h-12 pl-3"
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
{formData.activityDate ? format(formData.activityDate, 'PPP') : 'Select date'}
|
<span className="flex-1 text-left">{formData.activityDate ? format(formData.activityDate, 'd MMM yyyy') : 'Select date'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
@ -355,16 +666,19 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
{/* Request Detail */}
|
{/* Request Detail */}
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
|
<Label htmlFor="requestDescription" className="text-base font-semibold">Request in Detail - Brief Requirement *</Label>
|
||||||
<Textarea
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
id="requestDescription"
|
Explain what you need approval for, why it's needed, and any relevant background information.
|
||||||
placeholder="Provide a detailed description of your claim requirement..."
|
<span className="block mt-1 text-xs text-blue-600">
|
||||||
value={formData.requestDescription}
|
💡 Tip: You can paste formatted content (lists, tables) and the formatting will be preserved.
|
||||||
onChange={(e) => updateFormData('requestDescription', e.target.value)}
|
</span>
|
||||||
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>
|
</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"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Period (Optional) */}
|
{/* Period (Optional) */}
|
||||||
@ -380,10 +694,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start text-left mt-2 h-12"
|
className="w-full justify-start text-left mt-2 h-12 pl-3"
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
{formData.periodStartDate ? format(formData.periodStartDate, 'PPP') : 'Start date'}
|
<span className="flex-1 text-left">{formData.periodStartDate ? format(formData.periodStartDate, 'd MMM yyyy') : 'Start date'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
@ -405,11 +719,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full justify-start text-left mt-2 h-12"
|
className="w-full justify-start text-left mt-2 h-12 pl-3"
|
||||||
disabled={!formData.periodStartDate}
|
disabled={!formData.periodStartDate}
|
||||||
>
|
>
|
||||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
<CalendarIcon className="mr-2 h-4 w-4 flex-shrink-0" />
|
||||||
{formData.periodEndDate ? format(formData.periodEndDate, 'PPP') : 'End date'}
|
<span className="flex-1 text-left">{formData.periodEndDate ? format(formData.periodEndDate, 'd MMM yyyy') : 'End date'}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-auto p-0" align="start">
|
<PopoverContent className="w-auto p-0" align="start">
|
||||||
@ -432,11 +746,11 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{formData.periodStartDate && formData.periodEndDate ? (
|
{formData.periodStartDate && formData.periodEndDate ? (
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-600">
|
||||||
Period: {format(formData.periodStartDate, 'MMM dd, yyyy')} - {format(formData.periodEndDate, 'MMM dd, yyyy')}
|
Period: {format(formData.periodStartDate, 'd MMM yyyy')} - {format(formData.periodEndDate, 'd MMM yyyy')}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{formData.periodStartDate
|
{formData.periodStartDate
|
||||||
? 'Please select end date for the period'
|
? 'Please select end date for the period'
|
||||||
: 'Please select start date first'}
|
: 'Please select start date first'}
|
||||||
</p>
|
</p>
|
||||||
@ -456,12 +770,14 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
currentUserEmail={(user as any)?.email || ''}
|
currentUserEmail={(user as any)?.email || ''}
|
||||||
currentUserId={(user as any)?.userId || ''}
|
currentUserId={(user as any)?.userId || ''}
|
||||||
currentUserName={
|
currentUserName={
|
||||||
(user as any)?.displayName ||
|
(user as any)?.displayName ||
|
||||||
(user as any)?.name ||
|
(user as any)?.name ||
|
||||||
((user as any)?.firstName && (user as any)?.lastName
|
((user as any)?.firstName && (user as any)?.lastName
|
||||||
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
? `${(user as any).firstName} ${(user as any).lastName}`.trim()
|
||||||
: (user as any)?.email || 'User')
|
: (user as any)?.email || 'User')
|
||||||
}
|
}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -552,41 +868,64 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-6 space-y-4">
|
<CardContent className="pt-6 space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{(formData.approvers || []).filter((a: any) => !a.email?.includes('system@')).map((approver: any) => {
|
{(() => {
|
||||||
const stepNames: Record<number, string> = {
|
// Sort approvers by level and filter out system approvers
|
||||||
1: 'Dealer Proposal Submission',
|
const sortedApprovers = [...(formData.approvers || [])]
|
||||||
2: 'Requestor Evaluation',
|
.filter((a: any) => !a.email?.includes('system@') && !a.email?.includes('finance@'))
|
||||||
3: 'Department Lead Approval',
|
.sort((a: any, b: any) => a.level - b.level);
|
||||||
4: 'Activity Creation',
|
|
||||||
5: 'Dealer Completion Documents',
|
return sortedApprovers.map((approver: any) => {
|
||||||
6: 'Requestor Claim Approval',
|
const tat = Number(approver.tat || 0);
|
||||||
7: 'E-Invoice Generation',
|
const tatType = approver.tatType || 'hours';
|
||||||
8: 'Credit Note Confirmation',
|
const hours = tatType === 'days' ? tat * 24 : tat;
|
||||||
};
|
|
||||||
const tat = Number(approver.tat || 0);
|
// Find step name - handle additional approvers and shifted levels
|
||||||
const tatType = approver.tatType || 'hours';
|
let stepName = 'Unknown';
|
||||||
const hours = tatType === 'days' ? tat * 24 : tat;
|
let stepLabel = '';
|
||||||
|
|
||||||
return (
|
if (approver.isAdditional) {
|
||||||
<div key={approver.level} className="p-3 bg-gray-50 rounded-lg border">
|
// Additional approver - use stepName if available
|
||||||
<div className="flex items-center justify-between">
|
stepName = approver.stepName || 'Additional Approver';
|
||||||
<div>
|
const afterStep = CLAIM_STEPS.find(s => s.level === approver.insertAfterLevel);
|
||||||
<Label className="text-xs text-gray-600 uppercase tracking-wider">
|
stepLabel = approver.stepName || `Additional Approver (after "${afterStep?.name || 'Unknown'}")`;
|
||||||
Step {approver.level}: {stepNames[approver.level]}
|
} else {
|
||||||
</Label>
|
// Fixed step - find by originalStepLevel first, then fallback to level
|
||||||
<p className="font-semibold text-gray-900 mt-1">{approver.name || approver.email || 'Not selected'}</p>
|
const step = approver.originalStepLevel
|
||||||
{approver.email && (
|
? CLAIM_STEPS.find(s => s.level === approver.originalStepLevel)
|
||||||
<p className="text-xs text-gray-500 mt-1">{approver.email}</p>
|
: CLAIM_STEPS.find(s => s.level === approver.level && !s.isAuto);
|
||||||
)}
|
stepName = step?.name || 'Unknown';
|
||||||
</div>
|
stepLabel = stepName;
|
||||||
<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>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
});
|
||||||
})}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -636,7 +975,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<div>
|
<div>
|
||||||
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
|
<Label className="text-xs text-gray-600 uppercase tracking-wider">Brief Requirement</Label>
|
||||||
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
|
<div className="mt-2 p-4 bg-gray-50 rounded-lg border">
|
||||||
<p className="text-gray-900 whitespace-pre-wrap">{formData.requestDescription}</p>
|
<FormattedDescription
|
||||||
|
content={formData.requestDescription || ''}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -705,7 +1047,7 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<span className="hidden sm:inline">Back to Templates</span>
|
<span className="hidden sm:inline">Back to Templates</span>
|
||||||
<span className="sm:hidden">Back</span>
|
<span className="sm:hidden">Back</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-0">
|
||||||
<div>
|
<div>
|
||||||
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
|
<Badge variant="secondary" className="mb-2 text-xs">Claim Management Template</Badge>
|
||||||
@ -721,11 +1063,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
|
<Progress value={(currentStep / totalSteps) * 100} className="h-2" />
|
||||||
<div className="flex justify-between mt-2 px-1">
|
<div className="flex justify-between mt-2 px-1">
|
||||||
{STEP_NAMES.map((_name, index) => (
|
{STEP_NAMES.map((_name, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className={`text-xs sm:text-sm ${
|
className={`text-xs sm:text-sm ${index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
||||||
index + 1 <= currentStep ? 'text-blue-600 font-medium' : 'text-gray-400'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{index + 1}
|
{index + 1}
|
||||||
</span>
|
</span>
|
||||||
@ -758,11 +1099,10 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
{currentStep < totalSteps ? (
|
{currentStep < totalSteps ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={nextStep}
|
onClick={nextStep}
|
||||||
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${
|
className={`gap-2 w-full sm:w-auto order-1 sm:order-2 ${!isStepValid()
|
||||||
!isStepValid()
|
? 'opacity-50 cursor-pointer hover:opacity-60'
|
||||||
? 'opacity-50 cursor-pointer hover:opacity-60'
|
: ''
|
||||||
: ''
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
<ArrowRight className="w-4 h-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
@ -770,15 +1110,37 @@ export function ClaimManagementWizard({ onBack, onSubmit }: ClaimManagementWizar
|
|||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isStepValid()}
|
disabled={!isStepValid() || isSubmitting}
|
||||||
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2"
|
className="gap-2 bg-green-600 hover:bg-green-700 w-full sm:w-auto order-1 sm:order-2"
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
{isSubmitting ? (
|
||||||
Submit Claim Request
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
Submit Claim Request
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Policy Violation Modal */}
|
||||||
|
<PolicyViolationModal
|
||||||
|
open={policyViolationModal.open}
|
||||||
|
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
||||||
|
violations={policyViolationModal.violations}
|
||||||
|
policyDetails={{
|
||||||
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
||||||
|
maxParticipants: systemPolicy.maxParticipants,
|
||||||
|
allowSpectators: systemPolicy.allowSpectators,
|
||||||
|
maxSpectators: systemPolicy.maxSpectators,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,13 @@
|
|||||||
* Located in: src/dealer-claim/components/request-detail/
|
* Located in: src/dealer-claim/components/request-detail/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { DollarSign, Download, CircleCheckBig, Target } from 'lucide-react';
|
import { DollarSign, Download, CircleCheckBig, Target, CircleAlert } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
|
import { validateIO, updateIODetails, getClaimDetails } from '@/services/dealerClaimApi';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
@ -31,75 +30,87 @@ interface IOBlockedDetails {
|
|||||||
blockedDate: string;
|
blockedDate: string;
|
||||||
blockedBy: string; // User who blocked
|
blockedBy: string; // User who blocked
|
||||||
sapDocumentNumber: string;
|
sapDocumentNumber: string;
|
||||||
ioRemark?: string; // IO remark
|
status: 'blocked' | 'released' | 'failed' | 'pending';
|
||||||
status: 'blocked' | 'released' | 'failed';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const requestId = apiRequest?.requestId || request?.requestId;
|
const requestId = apiRequest?.requestId || request?.requestId;
|
||||||
|
|
||||||
// Load existing IO data from apiRequest or request
|
|
||||||
const internalOrder = apiRequest?.internalOrder || apiRequest?.internal_order || null;
|
|
||||||
const existingIONumber = internalOrder?.ioNumber || internalOrder?.io_number || request?.ioNumber || '';
|
|
||||||
const existingIORemark = internalOrder?.ioRemark || internalOrder?.io_remark || '';
|
|
||||||
const existingBlockedAmount = internalOrder?.ioBlockedAmount || internalOrder?.io_blocked_amount || 0;
|
|
||||||
const existingAvailableBalance = internalOrder?.ioAvailableBalance || internalOrder?.io_available_balance || 0;
|
|
||||||
const existingRemainingBalance = internalOrder?.ioRemainingBalance || internalOrder?.io_remaining_balance || 0;
|
|
||||||
const sapDocNumber = internalOrder?.sapDocumentNumber || internalOrder?.sap_document_number || '';
|
|
||||||
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
|
// Get organizer user object from association (organizer) or fallback to organizedBy UUID
|
||||||
const organizer = internalOrder?.organizer || null;
|
const proposalDetails = apiRequest?.proposalDetails || {};
|
||||||
|
const claimDetails = apiRequest?.claimDetails || apiRequest || {};
|
||||||
const [ioNumber, setIoNumber] = useState(existingIONumber);
|
|
||||||
const [ioRemark, setIoRemark] = useState(existingIORemark);
|
// Calculate total base amount (needed for budget verification as requested)
|
||||||
|
// This is the taxable amount excluding GST
|
||||||
|
const totalBaseAmount = useMemo(() => {
|
||||||
|
const costBreakupRaw = proposalDetails?.costBreakup || claimDetails?.costBreakup || [];
|
||||||
|
const costBreakup = Array.isArray(costBreakupRaw)
|
||||||
|
? costBreakupRaw
|
||||||
|
: (typeof costBreakupRaw === 'string'
|
||||||
|
? JSON.parse(costBreakupRaw)
|
||||||
|
: []);
|
||||||
|
|
||||||
|
if (!Array.isArray(costBreakup) || costBreakup.length === 0) {
|
||||||
|
return Number(claimDetails?.totalProposedTaxableAmount || proposalDetails?.totalEstimatedBudget || proposalDetails?.total_estimated_budget || 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return costBreakup.reduce((sum: number, item: any) => {
|
||||||
|
const amount = typeof item === 'object' ? (item.amount || 0) : 0;
|
||||||
|
const quantity = typeof item === 'object' ? (item.quantity || 1) : 1;
|
||||||
|
return sum + (Number(amount) * Number(quantity));
|
||||||
|
}, 0);
|
||||||
|
}, [proposalDetails?.costBreakup, claimDetails?.costBreakup, claimDetails?.totalProposedTaxableAmount, proposalDetails?.totalEstimatedBudget]);
|
||||||
|
|
||||||
|
// Use base amount as the target budget for blocking
|
||||||
|
const estimatedBudget = totalBaseAmount;
|
||||||
|
|
||||||
|
// Budget status for signaling (Scenario 2)
|
||||||
|
// Use apiRequest as the primary source of truth, fall back to request
|
||||||
|
const budgetTracking = apiRequest?.budgetTracking || request?.budgetTracking || {};
|
||||||
|
const budgetStatus = budgetTracking?.budgetStatus || budgetTracking?.budget_status || '';
|
||||||
|
const internalOrdersList = apiRequest?.internalOrders || apiRequest?.internal_orders || request?.internalOrders || [];
|
||||||
|
const isAdditionalBlockingNeeded = budgetStatus === 'PROPOSED' && internalOrdersList.length > 0;
|
||||||
|
|
||||||
|
const [ioNumber, setIoNumber] = useState('');
|
||||||
const [fetchingAmount, setFetchingAmount] = useState(false);
|
const [fetchingAmount, setFetchingAmount] = useState(false);
|
||||||
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
const [fetchedAmount, setFetchedAmount] = useState<number | null>(null);
|
||||||
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
const [amountToBlock, setAmountToBlock] = useState<string>('');
|
||||||
const [blockedDetails, setBlockedDetails] = useState<IOBlockedDetails | null>(null);
|
const [blockedIOs, setBlockedIOs] = useState<IOBlockedDetails[]>([]);
|
||||||
const [blockingBudget, setBlockingBudget] = useState(false);
|
const [blockingBudget, setBlockingBudget] = useState(false);
|
||||||
|
|
||||||
const maxIoRemarkChars = 300;
|
|
||||||
const ioRemarkChars = ioRemark.length;
|
|
||||||
|
|
||||||
// Load existing IO block details from apiRequest
|
// Load existing IO blocks
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (internalOrder && existingIONumber) {
|
if (internalOrdersList.length > 0) {
|
||||||
const availableBeforeBlock = Number(existingAvailableBalance) + Number(existingBlockedAmount) || Number(existingAvailableBalance);
|
const formattedIOs = internalOrdersList.map((io: any) => {
|
||||||
// Get blocked by user name from organizer association (who blocked the amount)
|
const org = io.organizer || null;
|
||||||
// When amount is blocked, organizedBy stores the user who blocked it
|
const blockedByName = org?.displayName ||
|
||||||
const blockedByName = organizer?.displayName ||
|
org?.display_name ||
|
||||||
organizer?.display_name ||
|
org?.name ||
|
||||||
organizer?.name ||
|
(org?.firstName && org?.lastName ? `${org.firstName} ${org.lastName}`.trim() : null) ||
|
||||||
(organizer?.firstName && organizer?.lastName ? `${organizer.firstName} ${organizer.lastName}`.trim() : null) ||
|
org?.email ||
|
||||||
organizer?.email ||
|
'Unknown User';
|
||||||
'Unknown User';
|
return {
|
||||||
|
ioNumber: io.ioNumber || io.io_number,
|
||||||
// Set IO number and remark from existing data
|
blockedAmount: Number(io.ioBlockedAmount || io.io_blocked_amount || 0),
|
||||||
setIoNumber(existingIONumber);
|
availableBalance: Number(io.ioAvailableBalance || io.io_available_balance || 0),
|
||||||
setIoRemark(existingIORemark);
|
remainingBalance: Number(io.ioRemainingBalance || io.io_remaining_balance || 0),
|
||||||
|
blockedDate: io.organizedAt || io.organized_at || new Date().toISOString(),
|
||||||
// Only set blocked details if amount is blocked
|
|
||||||
if (existingBlockedAmount > 0) {
|
|
||||||
setBlockedDetails({
|
|
||||||
ioNumber: existingIONumber,
|
|
||||||
blockedAmount: Number(existingBlockedAmount) || 0,
|
|
||||||
availableBalance: availableBeforeBlock, // Available amount before block
|
|
||||||
remainingBalance: Number(existingRemainingBalance) || Number(existingAvailableBalance),
|
|
||||||
blockedDate: internalOrder.organizedAt || internalOrder.organized_at || new Date().toISOString(),
|
|
||||||
blockedBy: blockedByName,
|
blockedBy: blockedByName,
|
||||||
sapDocumentNumber: sapDocNumber,
|
sapDocumentNumber: io.sapDocumentNumber || io.sap_document_number || '',
|
||||||
ioRemark: existingIORemark,
|
status: (io.status === 'BLOCKED' ? 'blocked' :
|
||||||
status: (internalOrder.status === 'BLOCKED' ? 'blocked' :
|
io.status === 'RELEASED' ? 'released' :
|
||||||
internalOrder.status === 'RELEASED' ? 'released' : 'blocked') as 'blocked' | 'released' | 'failed',
|
io.status === 'PENDING' ? 'pending' : 'blocked') as any,
|
||||||
});
|
};
|
||||||
|
});
|
||||||
// Set fetched amount if available balance exists
|
setBlockedIOs(formattedIOs);
|
||||||
if (availableBeforeBlock > 0) {
|
|
||||||
setFetchedAmount(availableBeforeBlock);
|
// If we are not in Scenario 2 (additional blocking), set the IO number from the last block for convenience
|
||||||
}
|
if (!isAdditionalBlockingNeeded && formattedIOs.length > 0) {
|
||||||
|
setIoNumber(formattedIOs[formattedIOs.length - 1].ioNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [internalOrder, existingIONumber, existingIORemark, existingBlockedAmount, existingAvailableBalance, existingRemainingBalance, sapDocNumber, organizer]);
|
}, [apiRequest, request, isAdditionalBlockingNeeded, internalOrdersList]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch available budget from SAP
|
* Fetch available budget from SAP
|
||||||
@ -121,11 +132,25 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
try {
|
try {
|
||||||
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
|
// Call validate IO endpoint - returns dummy data for now, will integrate with SAP later
|
||||||
const ioData = await validateIO(requestId, ioNumber.trim());
|
const ioData = await validateIO(requestId, ioNumber.trim());
|
||||||
|
|
||||||
if (ioData.isValid && ioData.availableBalance > 0) {
|
if (ioData.isValid && ioData.availableBalance > 0) {
|
||||||
setFetchedAmount(ioData.availableBalance);
|
setFetchedAmount(ioData.availableBalance);
|
||||||
// Pre-fill amount to block with available balance
|
|
||||||
setAmountToBlock(String(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)));
|
||||||
|
}
|
||||||
|
|
||||||
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
toast.success(`IO fetched from SAP. Available balance: ₹${ioData.availableBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Invalid IO number or no available balance found');
|
toast.error('Invalid IO number or no available balance found');
|
||||||
@ -142,45 +167,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Save IO details (IO number and remark) without blocking budget
|
|
||||||
*/
|
|
||||||
const handleSaveIODetails = async () => {
|
|
||||||
if (!ioNumber.trim()) {
|
|
||||||
toast.error('Please enter an IO number');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!requestId) {
|
|
||||||
toast.error('Request ID not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setBlockingBudget(true);
|
|
||||||
try {
|
|
||||||
// Save only IO number and remark (no balance fields)
|
|
||||||
const payload = {
|
|
||||||
ioNumber: ioNumber.trim(),
|
|
||||||
ioRemark: ioRemark.trim(),
|
|
||||||
};
|
|
||||||
|
|
||||||
await updateIODetails(requestId, payload);
|
|
||||||
|
|
||||||
toast.success('IO details saved successfully');
|
|
||||||
|
|
||||||
// Refresh request details
|
|
||||||
onRefresh?.();
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to save IO details:', error);
|
|
||||||
const errorMessage = error?.response?.data?.message || error?.message || 'Failed to save IO details';
|
|
||||||
toast.error(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setBlockingBudget(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block budget in SAP system
|
* Block budget in SAP system
|
||||||
|
* This function:
|
||||||
|
* 1. Validates the IO number and amount
|
||||||
|
* 2. Calls SAP to block the budget
|
||||||
|
* 3. Saves IO number, blocked amount, and balance details to database
|
||||||
*/
|
*/
|
||||||
const handleBlockBudget = async () => {
|
const handleBlockBudget = async () => {
|
||||||
if (!ioNumber.trim() || fetchedAmount === null) {
|
if (!ioNumber.trim() || fetchedAmount === null) {
|
||||||
@ -194,83 +186,97 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blockAmountRaw = parseFloat(amountToBlock);
|
const blockAmountRaw = parseFloat(amountToBlock);
|
||||||
|
|
||||||
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
|
if (!amountToBlock || isNaN(blockAmountRaw) || blockAmountRaw <= 0) {
|
||||||
toast.error('Please enter a valid amount to block');
|
toast.error('Please enter a valid amount to block');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round to 2 decimal places to avoid floating point precision issues
|
// Round to exactly 2 decimal places to avoid floating point precision issues
|
||||||
// This ensures we send clean values like 240.00 instead of 239.9999999
|
// Use parseFloat with toFixed to ensure exact 2 decimal precision
|
||||||
const blockAmount = Math.round(blockAmountRaw * 100) / 100;
|
const blockAmount = parseFloat(blockAmountRaw.toFixed(2));
|
||||||
|
|
||||||
if (blockAmount > fetchedAmount) {
|
if (blockAmount > fetchedAmount) {
|
||||||
toast.error('Amount to block exceeds available IO budget');
|
toast.error('Amount to block exceeds available IO budget');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log the amount being sent to backend for debugging
|
|
||||||
console.log('[IOTab] Blocking budget:', {
|
// Calculate total already blocked
|
||||||
ioNumber: ioNumber.trim(),
|
const totalAlreadyBlocked = blockedIOs.reduce((sum, io) => sum + io.blockedAmount, 0);
|
||||||
originalInput: amountToBlock,
|
const totalPlanned = totalAlreadyBlocked + blockAmount;
|
||||||
parsedAmount: blockAmountRaw,
|
|
||||||
roundedAmount: blockAmount,
|
// Validate that total planned must exactly match estimated budget
|
||||||
fetchedAmount,
|
if (estimatedBudget > 0) {
|
||||||
calculatedRemaining: fetchedAmount - blockAmount,
|
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
|
||||||
|
|
||||||
setBlockingBudget(true);
|
setBlockingBudget(true);
|
||||||
try {
|
try {
|
||||||
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
|
// Call updateIODetails with blockedAmount to block budget in SAP and store in database
|
||||||
// This will store in internal_orders and claim_budget_tracking tables
|
// This will store in internal_orders and claim_budget_tracking tables
|
||||||
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
|
// Note: Backend will recalculate remainingBalance from SAP's response, so we pass it for reference only
|
||||||
|
// Ensure all amounts are rounded to 2 decimal places for consistency
|
||||||
|
const roundedFetchedAmount = parseFloat(fetchedAmount.toFixed(2));
|
||||||
|
const calculatedRemaining = parseFloat((roundedFetchedAmount - blockAmount).toFixed(2));
|
||||||
const payload = {
|
const payload = {
|
||||||
ioNumber: ioNumber.trim(),
|
ioNumber: ioNumber.trim(),
|
||||||
ioRemark: ioRemark.trim(),
|
ioAvailableBalance: roundedFetchedAmount,
|
||||||
ioAvailableBalance: fetchedAmount,
|
|
||||||
ioBlockedAmount: blockAmount,
|
ioBlockedAmount: blockAmount,
|
||||||
ioRemainingBalance: fetchedAmount - blockAmount, // Calculated value (backend will use SAP's actual value)
|
ioRemainingBalance: calculatedRemaining, // Calculated value (backend will use SAP's actual value)
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('[IOTab] Sending to backend:', payload);
|
// Sending to backend
|
||||||
|
|
||||||
await updateIODetails(requestId, payload);
|
await updateIODetails(requestId, payload);
|
||||||
|
|
||||||
// Fetch updated claim details to get the blocked IO data
|
// Fetch updated claim details to get the blocked IO data
|
||||||
const claimData = await getClaimDetails(requestId);
|
const claimData = await getClaimDetails(requestId);
|
||||||
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
const updatedInternalOrder = claimData?.internalOrder || claimData?.internal_order;
|
||||||
|
|
||||||
if (updatedInternalOrder) {
|
if (updatedInternalOrder) {
|
||||||
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
|
const savedBlockedAmount = Number(updatedInternalOrder.ioBlockedAmount || updatedInternalOrder.io_blocked_amount || blockAmount);
|
||||||
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || (fetchedAmount - blockAmount));
|
const savedRemainingBalance = Number(updatedInternalOrder.ioRemainingBalance || updatedInternalOrder.io_remaining_balance || 0);
|
||||||
|
|
||||||
// Log what was saved vs what we sent
|
// Calculate expected remaining balance for validation/debugging
|
||||||
console.log('[IOTab] Blocking result:', {
|
const expectedRemainingBalance = fetchedAmount - savedBlockedAmount;
|
||||||
sentAmount: blockAmount,
|
|
||||||
savedBlockedAmount,
|
// Blocking result processed
|
||||||
sentRemaining: fetchedAmount - blockAmount,
|
|
||||||
savedRemainingBalance,
|
|
||||||
availableBalance: fetchedAmount,
|
|
||||||
difference: savedBlockedAmount - blockAmount,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Warn if the saved amount differs from what we sent
|
// Warn if the saved amount differs from what we sent
|
||||||
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
|
if (Math.abs(savedBlockedAmount - blockAmount) > 0.01) {
|
||||||
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
|
console.warn('[IOTab] ⚠️ Amount mismatch! Sent:', blockAmount, 'Saved:', savedBlockedAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Warn if remaining balance calculation seems incorrect (for backend debugging)
|
||||||
|
if (Math.abs(savedRemainingBalance - expectedRemainingBalance) > 0.01) {
|
||||||
|
console.warn('[IOTab] ⚠️ Remaining balance calculation issue detected!', {
|
||||||
|
availableBalance: fetchedAmount,
|
||||||
|
blockedAmount: savedBlockedAmount,
|
||||||
|
expectedRemaining: expectedRemainingBalance,
|
||||||
|
backendRemaining: savedRemainingBalance,
|
||||||
|
difference: savedRemainingBalance - expectedRemainingBalance,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const currentUser = user as any;
|
const currentUser = user as any;
|
||||||
// When blocking, always use the current user who is performing the block action
|
// When blocking, always use the current user who is performing the block action
|
||||||
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
// The organizer association may be from initial IO organization, but we want who blocked the amount
|
||||||
const blockedByName = currentUser?.displayName ||
|
const blockedByName = currentUser?.displayName ||
|
||||||
currentUser?.display_name ||
|
currentUser?.display_name ||
|
||||||
currentUser?.name ||
|
currentUser?.name ||
|
||||||
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
|
(currentUser?.firstName && currentUser?.lastName ? `${currentUser.firstName} ${currentUser.lastName}`.trim() : null) ||
|
||||||
currentUser?.email ||
|
currentUser?.email ||
|
||||||
'Current User';
|
'Current User';
|
||||||
|
|
||||||
const savedIoRemark = updatedInternalOrder.ioRemark || updatedInternalOrder.io_remark || ioRemark.trim();
|
|
||||||
|
|
||||||
const blocked: IOBlockedDetails = {
|
const blocked: IOBlockedDetails = {
|
||||||
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
ioNumber: updatedInternalOrder.ioNumber || updatedInternalOrder.io_number || ioNumber,
|
||||||
blockedAmount: savedBlockedAmount,
|
blockedAmount: savedBlockedAmount,
|
||||||
@ -279,14 +285,14 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
|
blockedDate: updatedInternalOrder.organizedAt || updatedInternalOrder.organized_at || new Date().toISOString(),
|
||||||
blockedBy: blockedByName,
|
blockedBy: blockedByName,
|
||||||
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
sapDocumentNumber: updatedInternalOrder.sapDocumentNumber || updatedInternalOrder.sap_document_number || '',
|
||||||
ioRemark: savedIoRemark,
|
|
||||||
status: 'blocked',
|
status: 'blocked',
|
||||||
};
|
};
|
||||||
|
|
||||||
setBlockedDetails(blocked);
|
setBlockedIOs(prev => [...prev, blocked]);
|
||||||
setAmountToBlock(''); // Clear the input
|
setAmountToBlock(''); // Clear the input
|
||||||
|
setFetchedAmount(null); // Reset fetched state
|
||||||
toast.success('IO budget blocked successfully in SAP');
|
toast.success('IO budget blocked successfully in SAP');
|
||||||
|
|
||||||
// Refresh request details
|
// Refresh request details
|
||||||
onRefresh?.();
|
onRefresh?.();
|
||||||
} else {
|
} else {
|
||||||
@ -325,12 +331,12 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
placeholder="Enter IO number (e.g., IO-2024-12345)"
|
placeholder="Enter IO number (e.g., IO-2024-12345)"
|
||||||
value={ioNumber}
|
value={ioNumber}
|
||||||
onChange={(e) => setIoNumber(e.target.value)}
|
onChange={(e) => setIoNumber(e.target.value)}
|
||||||
disabled={fetchingAmount || !!blockedDetails}
|
disabled={fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFetchAmount}
|
onClick={handleFetchAmount}
|
||||||
disabled={!ioNumber.trim() || fetchingAmount || !!blockedDetails}
|
disabled={!ioNumber.trim() || fetchingAmount || (blockedIOs.length > 0 && !isAdditionalBlockingNeeded)}
|
||||||
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
|
className="bg-[#2d4a3e] hover:bg-[#1f3329]"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
@ -339,44 +345,17 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IO Remark Input */}
|
{/* Instructions when IO number is entered but not fetched */}
|
||||||
<div className="space-y-2">
|
{!fetchedAmount && blockedIOs.length === 0 && ioNumber.trim() && (
|
||||||
<Label htmlFor="ioRemark" className="text-sm font-medium text-gray-900">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||||
IO Remark
|
<p className="text-sm text-blue-800">
|
||||||
</Label>
|
<strong>Next Step:</strong> Click "Fetch Amount" to validate the IO number and get available balance from SAP.
|
||||||
<Textarea
|
</p>
|
||||||
id="ioRemark"
|
|
||||||
placeholder="Enter remarks about IO organization"
|
|
||||||
value={ioRemark}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value.length <= maxIoRemarkChars) {
|
|
||||||
setIoRemark(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
rows={3}
|
|
||||||
disabled={!!blockedDetails}
|
|
||||||
className="bg-white text-sm min-h-[80px] resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end text-xs text-gray-600">
|
|
||||||
<span>{ioRemarkChars}/{maxIoRemarkChars}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save IO Details Button (shown when IO number is entered but amount not fetched) */}
|
|
||||||
{!fetchedAmount && !blockedDetails && ioNumber.trim() && (
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveIODetails}
|
|
||||||
disabled={blockingBudget || !ioNumber.trim()}
|
|
||||||
variant="outline"
|
|
||||||
className="w-full border-[#2d4a3e] text-[#2d4a3e] hover:bg-[#2d4a3e] hover:text-white"
|
|
||||||
>
|
|
||||||
{blockingBudget ? 'Saving...' : 'Save IO Details'}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Fetched Amount Display */}
|
{/* Fetched Amount Display */}
|
||||||
{fetchedAmount !== null && !blockedDetails && (
|
{fetchedAmount !== null && (blockedIOs.length === 0 || isAdditionalBlockingNeeded) && (
|
||||||
<>
|
<>
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
|
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@ -410,12 +389,25 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
className="pl-8"
|
className="pl-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{estimatedBudget > 0 && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-amber-800">
|
||||||
|
<strong>Required:</strong> Amount must be exactly equal to the estimated budget: <strong>₹{estimatedBudget.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Block Button */}
|
{/* Block Button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBlockBudget}
|
onClick={handleBlockBudget}
|
||||||
disabled={blockingBudget || !amountToBlock || parseFloat(amountToBlock) <= 0 || parseFloat(amountToBlock) > fetchedAmount}
|
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)
|
||||||
|
}
|
||||||
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
className="w-full bg-[#2d4a3e] hover:bg-[#1f3329]"
|
||||||
>
|
>
|
||||||
<Target className="w-4 h-4 mr-2" />
|
<Target className="w-4 h-4 mr-2" />
|
||||||
@ -438,77 +430,57 @@ export function IOTab({ request, apiRequest, onRefresh }: IOTabProps) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{blockedDetails ? (
|
{blockedIOs.length > 0 ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{/* Success Banner */}
|
{isAdditionalBlockingNeeded && (
|
||||||
<div className="bg-green-50 border-2 border-green-500 rounded-lg p-4">
|
<div className="bg-amber-50 border-2 border-amber-500 rounded-lg p-4 animate-pulse">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<CircleCheckBig className="w-6 h-6 text-green-600 flex-shrink-0 mt-0.5" />
|
<CircleAlert className="w-6 h-6 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-green-900">IO Blocked Successfully</p>
|
<p className="font-semibold text-amber-900">Additional Budget Blocking Required</p>
|
||||||
<p className="text-sm text-green-700 mt-1">Budget has been reserved in SAP system</p>
|
<p className="text-sm text-amber-700 mt-1">Actual expenses exceed the previously blocked amount. Please block an additional ₹{(estimatedBudget - blockedIOs.reduce((s, i) => s + i.blockedAmount, 0)).toLocaleString('en-IN', { minimumFractionDigits: 2 })}.</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Blocked Details */}
|
{blockedIOs.slice().reverse().map((io, idx) => (
|
||||||
<div className="border rounded-lg divide-y">
|
<div key={idx} className="border rounded-lg overflow-hidden">
|
||||||
<div className="p-4">
|
<div className={`p-3 flex justify-between items-center ${idx === 0 ? 'bg-green-50' : 'bg-gray-50'}`}>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Number</p>
|
<span className="font-semibold text-sm">IO: {io.ioNumber}</span>
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.ioNumber}</p>
|
<Badge className={
|
||||||
</div>
|
io.status === 'blocked' ? 'bg-green-100 text-green-800' :
|
||||||
<div className="p-4">
|
io.status === 'pending' ? 'bg-amber-100 text-amber-800' :
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">SAP Document Number</p>
|
'bg-blue-100 text-blue-800'
|
||||||
<p className="text-sm font-semibold text-gray-900">{blockedDetails.sapDocumentNumber || 'N/A'}</p>
|
}>
|
||||||
</div>
|
{io.status === 'blocked' ? 'Blocked' :
|
||||||
{blockedDetails.ioRemark && (
|
io.status === 'pending' ? 'Provisioned' : 'Released'}
|
||||||
<div className="p-4">
|
</Badge>
|
||||||
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">IO Remark</p>
|
</div>
|
||||||
<p className="text-sm font-medium text-gray-900 whitespace-pre-wrap">{blockedDetails.ioRemark}</p>
|
<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>
|
</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>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-[#2d4a3e] text-white rounded-lg flex justify-between items-center">
|
||||||
|
<span className="font-bold">Total Blocked:</span>
|
||||||
|
<span className="text-xl font-bold">₹{blockedIOs.reduce((s, i) => s + i.blockedAmount, 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -41,6 +41,9 @@ interface ClaimManagementOverviewTabProps {
|
|||||||
aiGenerated?: boolean;
|
aiGenerated?: boolean;
|
||||||
handleGenerateConclusion?: () => void;
|
handleGenerateConclusion?: () => void;
|
||||||
handleFinalizeConclusion?: () => void;
|
handleFinalizeConclusion?: () => void;
|
||||||
|
generationAttempts?: number;
|
||||||
|
generationFailed?: boolean;
|
||||||
|
maxAttemptsReached?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaimManagementOverviewTab({
|
export function ClaimManagementOverviewTab({
|
||||||
@ -58,6 +61,9 @@ export function ClaimManagementOverviewTab({
|
|||||||
aiGenerated = false,
|
aiGenerated = false,
|
||||||
handleGenerateConclusion,
|
handleGenerateConclusion,
|
||||||
handleFinalizeConclusion,
|
handleFinalizeConclusion,
|
||||||
|
generationAttempts = 0,
|
||||||
|
generationFailed = false,
|
||||||
|
maxAttemptsReached = false,
|
||||||
}: ClaimManagementOverviewTabProps) {
|
}: ClaimManagementOverviewTabProps) {
|
||||||
// Check if this is a claim management request
|
// Check if this is a claim management request
|
||||||
if (!isClaimManagementRequest(apiRequest)) {
|
if (!isClaimManagementRequest(apiRequest)) {
|
||||||
@ -86,16 +92,7 @@ export function ClaimManagementOverviewTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug: Log mapped data for troubleshooting
|
// Mapped claim data ready
|
||||||
console.debug('[ClaimManagementOverviewTab] Mapped claim data:', {
|
|
||||||
activityInfo: claimRequest.activityInfo,
|
|
||||||
dealerInfo: claimRequest.dealerInfo,
|
|
||||||
hasProposalDetails: !!claimRequest.proposalDetails,
|
|
||||||
closedExpenses: claimRequest.activityInfo?.closedExpenses,
|
|
||||||
closedExpensesBreakdown: claimRequest.activityInfo?.closedExpensesBreakdown,
|
|
||||||
hasDealerCode: !!claimRequest.dealerInfo?.dealerCode,
|
|
||||||
hasDealerName: !!claimRequest.dealerInfo?.dealerName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Determine user's role
|
// Determine user's role
|
||||||
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
|
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
|
||||||
@ -103,13 +100,7 @@ export function ClaimManagementOverviewTab({
|
|||||||
// Get visibility settings based on role
|
// Get visibility settings based on role
|
||||||
const visibility = getRoleBasedVisibility(userRole);
|
const visibility = getRoleBasedVisibility(userRole);
|
||||||
|
|
||||||
console.debug('[ClaimManagementOverviewTab] User role and visibility:', {
|
// User role and visibility determined
|
||||||
userRole,
|
|
||||||
visibility,
|
|
||||||
currentUserId,
|
|
||||||
showDealerInfo: visibility.showDealerInfo,
|
|
||||||
dealerInfoPresent: !!(claimRequest.dealerInfo?.dealerCode || claimRequest.dealerInfo?.dealerName),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extract initiator info from request
|
// Extract initiator info from request
|
||||||
// The apiRequest has initiator object with displayName, email, department, phone, etc.
|
// The apiRequest has initiator object with displayName, email, department, phone, etc.
|
||||||
@ -121,20 +112,7 @@ export function ClaimManagementOverviewTab({
|
|||||||
phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile,
|
phone: apiRequest.initiator?.phone || apiRequest.initiator?.mobile,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Debug: Log closure props to help troubleshoot
|
// Closure setup check completed
|
||||||
console.debug('[ClaimManagementOverviewTab] Closure setup check:', {
|
|
||||||
needsClosure,
|
|
||||||
requestStatus: apiRequest?.status,
|
|
||||||
requestStatusLower: (apiRequest?.status || '').toLowerCase(),
|
|
||||||
hasConclusionRemark: !!conclusionRemark,
|
|
||||||
conclusionRemarkLength: conclusionRemark?.length || 0,
|
|
||||||
conclusionLoading,
|
|
||||||
conclusionSubmitting,
|
|
||||||
aiGenerated,
|
|
||||||
hasHandleGenerate: !!handleGenerateConclusion,
|
|
||||||
hasHandleFinalize: !!handleFinalizeConclusion,
|
|
||||||
hasSetConclusion: !!setConclusionRemark,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`space-y-6 ${className}`}>
|
<div className={`space-y-6 ${className}`}>
|
||||||
@ -210,17 +188,24 @@ export function ClaimManagementOverviewTab({
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{handleGenerateConclusion && (
|
{handleGenerateConclusion && (
|
||||||
<Button
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={handleGenerateConclusion}
|
size="sm"
|
||||||
disabled={conclusionLoading}
|
onClick={handleGenerateConclusion}
|
||||||
className="gap-2 shrink-0"
|
disabled={conclusionLoading || maxAttemptsReached}
|
||||||
data-testid="generate-ai-conclusion-button"
|
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'}
|
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
|
||||||
</Button>
|
{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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ import { Calendar, MapPin, DollarSign, Receipt } from 'lucide-react';
|
|||||||
import { ClaimActivityInfo } from '@/pages/RequestDetail/types/claimManagement.types';
|
import { ClaimActivityInfo } from '@/pages/RequestDetail/types/claimManagement.types';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
|
|
||||||
interface ActivityInformationCardProps {
|
interface ActivityInformationCardProps {
|
||||||
activityInfo: ClaimActivityInfo;
|
activityInfo: ClaimActivityInfo;
|
||||||
@ -17,11 +18,11 @@ interface ActivityInformationCardProps {
|
|||||||
updatedAt?: string | Date;
|
updatedAt?: string | Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ActivityInformationCard({
|
export function ActivityInformationCard({
|
||||||
activityInfo,
|
activityInfo,
|
||||||
className,
|
className,
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt
|
updatedAt
|
||||||
}: ActivityInformationCardProps) {
|
}: ActivityInformationCardProps) {
|
||||||
// Defensive check: Ensure activityInfo exists
|
// Defensive check: Ensure activityInfo exists
|
||||||
if (!activityInfo) {
|
if (!activityInfo) {
|
||||||
@ -108,7 +109,7 @@ export function ActivityInformationCard({
|
|||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
||||||
<DollarSign className="w-4 h-4 text-green-600" />
|
<DollarSign className="w-4 h-4 text-green-600" />
|
||||||
{activityInfo.estimatedBudget
|
{activityInfo.estimatedBudget !== undefined && activityInfo.estimatedBudget !== null
|
||||||
? formatCurrency(activityInfo.estimatedBudget)
|
? formatCurrency(activityInfo.estimatedBudget)
|
||||||
: 'TBD'}
|
: 'TBD'}
|
||||||
</p>
|
</p>
|
||||||
@ -122,7 +123,11 @@ export function ActivityInformationCard({
|
|||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
<p className="text-sm text-gray-900 font-medium mt-1 flex items-center gap-2">
|
||||||
<Receipt className="w-4 h-4 text-blue-600" />
|
<Receipt className="w-4 h-4 text-blue-600" />
|
||||||
{formatCurrency(activityInfo.closedExpenses)}
|
{formatCurrency(
|
||||||
|
activityInfo.closedExpensesBreakdown && activityInfo.closedExpensesBreakdown.length > 0
|
||||||
|
? activityInfo.closedExpensesBreakdown.reduce((sum, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
|
||||||
|
: activityInfo.closedExpenses
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -146,23 +151,40 @@ export function ActivityInformationCard({
|
|||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-3 block">
|
||||||
Closed Expenses Breakdown
|
Closed Expenses Breakdown
|
||||||
</label>
|
</label>
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-2">
|
<div className="bg-blue-50 border border-blue-200 rounded-lg overflow-hidden">
|
||||||
{activityInfo.closedExpensesBreakdown.map((item: { description: string; amount: number }, index: number) => (
|
<table className="w-full text-xs sm:text-sm">
|
||||||
<div key={index} className="flex justify-between items-center text-sm">
|
<thead className="bg-blue-100/50">
|
||||||
<span className="text-gray-700">{item.description}</span>
|
<tr>
|
||||||
<span className="font-medium text-gray-900">
|
<th className="px-3 py-2 text-left font-semibold text-blue-900">Description</th>
|
||||||
{formatCurrency(item.amount)}
|
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">Base</th>
|
||||||
</span>
|
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-24">GST</th>
|
||||||
</div>
|
<th className="px-3 py-2 text-right font-semibold text-blue-900 w-28">Total</th>
|
||||||
))}
|
</tr>
|
||||||
<div className="pt-2 border-t border-blue-300 flex justify-between items-center">
|
</thead>
|
||||||
<span className="font-semibold text-gray-900">Total</span>
|
<tbody className="divide-y divide-blue-200/50">
|
||||||
<span className="font-bold text-blue-600">
|
{activityInfo.closedExpensesBreakdown.map((item: any, index: number) => (
|
||||||
{formatCurrency(
|
<tr key={index} className="hover:bg-blue-100/30">
|
||||||
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: { description: string; amount: number }) => sum + item.amount, 0)
|
<td className="px-3 py-2 text-gray-700">
|
||||||
)}
|
{item.description}
|
||||||
</span>
|
{item.gstRate ? <span className="text-[10px] text-gray-400 block">{item.gstRate}% GST</span> : null}
|
||||||
</div>
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.amount)}</td>
|
||||||
|
<td className="px-3 py-2 text-right text-gray-900">{formatCurrency(item.gstAmt || 0)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium text-gray-900">
|
||||||
|
{formatCurrency(item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0)))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
<tr className="bg-blue-100/50 font-bold">
|
||||||
|
<td colSpan={3} className="px-3 py-2 text-blue-900">Final Claim Amount</td>
|
||||||
|
<td className="px-3 py-2 text-right text-blue-700">
|
||||||
|
{formatCurrency(
|
||||||
|
activityInfo.closedExpensesBreakdown.reduce((sum: number, item: any) => sum + (item.totalAmt || (Number(item.amount) + Number(item.gstAmt || 0))), 0)
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -173,9 +195,12 @@ export function ActivityInformationCard({
|
|||||||
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
|
<div className="mt-2 bg-gray-50 p-3 rounded-lg border border-gray-200">
|
||||||
{activityInfo.description}
|
<FormattedDescription
|
||||||
</p>
|
content={activityInfo.description || ''}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* ProcessDetailsCard Component
|
* ProcessDetailsCard Component
|
||||||
* Displays process-related details: IO Number, DMS Number, Claim Amount, and Budget Breakdowns
|
* Displays process-related details: IO Number, E-Invoice, Claim Amount, and Budget Breakdowns
|
||||||
* Visibility controlled by user role
|
* Visibility controlled by user role
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@ -26,6 +26,11 @@ interface DMSDetails {
|
|||||||
remarks?: string;
|
remarks?: string;
|
||||||
createdByName?: string;
|
createdByName?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
|
// PWC fields
|
||||||
|
irn?: string;
|
||||||
|
ackNo?: string;
|
||||||
|
ackDate?: string;
|
||||||
|
signedInvoiceUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ClaimAmountDetails {
|
interface ClaimAmountDetails {
|
||||||
@ -37,6 +42,8 @@ interface ClaimAmountDetails {
|
|||||||
interface CostBreakdownItem {
|
interface CostBreakdownItem {
|
||||||
description: string;
|
description: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
totalAmt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RoleBasedVisibility {
|
interface RoleBasedVisibility {
|
||||||
@ -85,7 +92,7 @@ export function ProcessDetailsCard({
|
|||||||
|
|
||||||
const calculateTotal = (items?: CostBreakdownItem[]) => {
|
const calculateTotal = (items?: CostBreakdownItem[]) => {
|
||||||
if (!items || items.length === 0) return 0;
|
if (!items || items.length === 0) return 0;
|
||||||
return items.reduce((sum, item) => sum + (item.amount ?? 0), 0);
|
return items.reduce((sum, item) => sum + (item.totalAmt ?? (item.amount + (item.gstAmt ?? 0))), 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render if nothing to show
|
// Don't render if nothing to show
|
||||||
@ -120,7 +127,7 @@ export function ProcessDetailsCard({
|
|||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
|
<p className="font-bold text-gray-900 mb-2">{ioDetails.ioNumber}</p>
|
||||||
|
|
||||||
{ioDetails.remarks && (
|
{ioDetails.remarks && (
|
||||||
<div className="pt-2 border-t border-blue-100">
|
<div className="pt-2 border-t border-blue-100">
|
||||||
<p className="text-xs text-gray-600 mb-1">Remark:</p>
|
<p className="text-xs text-gray-600 mb-1">Remark:</p>
|
||||||
@ -165,27 +172,57 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* DMS Details */}
|
{/* E-Invoice Details */}
|
||||||
{visibility.showDMSDetails && dmsDetails && (
|
{visibility.showDMSDetails && dmsDetails && (
|
||||||
<div className="bg-white rounded-lg p-3 border border-purple-200">
|
<div className="bg-white rounded-lg p-3 border border-purple-200">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<Activity className="w-4 h-4 text-purple-600" />
|
<Activity className="w-4 h-4 text-purple-600" />
|
||||||
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
|
<Label className="text-xs font-semibold text-purple-900 uppercase tracking-wide">
|
||||||
DMS Number
|
E-Invoice Details
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<p className="font-bold text-gray-900 mb-2">{dmsDetails.dmsNumber}</p>
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 mb-2">
|
||||||
|
|
||||||
|
{dmsDetails.ackNo && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-gray-500 uppercase">Ack No</p>
|
||||||
|
<p className="font-bold text-sm text-purple-700">{dmsDetails.ackNo}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dmsDetails.irn && (
|
||||||
|
<div className="mb-2 p-2 bg-purple-50 rounded border border-purple-100">
|
||||||
|
<p className="text-[10px] text-purple-600 uppercase font-semibold">IRN</p>
|
||||||
|
<p className="text-[10px] font-mono break-all text-gray-700 leading-tight">
|
||||||
|
{dmsDetails.irn}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{dmsDetails.signedInvoiceUrl && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full h-8 text-xs gap-2 mb-2 border-purple-200 text-purple-700 hover:bg-purple-50"
|
||||||
|
onClick={() => window.open(dmsDetails.signedInvoiceUrl, '_blank')}
|
||||||
|
>
|
||||||
|
<Receipt className="w-3.5 h-3.5" />
|
||||||
|
View E-Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{dmsDetails.remarks && (
|
{dmsDetails.remarks && (
|
||||||
<div className="pt-2 border-t border-purple-100">
|
<div className="pt-2 border-t border-purple-100">
|
||||||
<p className="text-xs text-gray-600 mb-1">Remarks:</p>
|
<p className="text-[10px] text-gray-500 uppercase mb-1">Remarks</p>
|
||||||
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
|
<p className="text-xs text-gray-900">{dmsDetails.remarks}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-2 border-t border-purple-100 mt-2">
|
<div className="pt-2 border-t border-purple-100 mt-2">
|
||||||
<p className="text-xs text-gray-500">By {dmsDetails.createdByName}</p>
|
<p className="text-[10px] text-gray-500">By {dmsDetails.createdByName}</p>
|
||||||
<p className="text-xs text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
|
<p className="text-[10px] text-gray-500">{formatDate(dmsDetails.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -241,10 +278,10 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{estimatedBudgetBreakdown.map((item, index) => (
|
{estimatedBudgetBreakdown.map((item, index) => (
|
||||||
<div key={index} className="flex justify-between items-center text-xs">
|
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
|
||||||
<span className="text-gray-700">{item.description}</span>
|
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900 whitespace-nowrap">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -269,10 +306,10 @@ export function ProcessDetailsCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5 pt-1">
|
<div className="space-y-1.5 pt-1">
|
||||||
{closedExpensesBreakdown.map((item, index) => (
|
{closedExpensesBreakdown.map((item, index) => (
|
||||||
<div key={index} className="flex justify-between items-center text-xs">
|
<div key={index} className="flex justify-between items-center text-[10px] sm:text-xs">
|
||||||
<span className="text-gray-700">{item.description}</span>
|
<div className="text-gray-700 truncate mr-2" title={item.description}>{item.description}</div>
|
||||||
<span className="font-medium text-gray-900">
|
<span className="font-medium text-gray-900 whitespace-nowrap">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.totalAmt ?? (item.amount + (item.gstAmt ?? 0)))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -11,11 +11,19 @@ import { format } from 'date-fns';
|
|||||||
interface ProposalCostItem {
|
interface ProposalCostItem {
|
||||||
description: string;
|
description: string;
|
||||||
amount?: number | null;
|
amount?: number | null;
|
||||||
|
gstRate?: number;
|
||||||
|
gstAmt?: number;
|
||||||
|
cgstAmt?: number;
|
||||||
|
sgstAmt?: number;
|
||||||
|
igstAmt?: number;
|
||||||
|
quantity?: number;
|
||||||
|
totalAmt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProposalDetails {
|
interface ProposalDetails {
|
||||||
costBreakup: ProposalCostItem[];
|
costBreakup: ProposalCostItem[];
|
||||||
estimatedBudgetTotal?: number | null;
|
estimatedBudgetTotal?: number | null;
|
||||||
|
totalEstimatedBudget?: number | null;
|
||||||
timelineForClosure?: string | null;
|
timelineForClosure?: string | null;
|
||||||
dealerComments?: string | null;
|
dealerComments?: string | null;
|
||||||
submittedOn?: string | null;
|
submittedOn?: string | null;
|
||||||
@ -29,19 +37,22 @@ interface ProposalDetailsCardProps {
|
|||||||
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
|
export function ProposalDetailsCard({ proposalDetails, className }: ProposalDetailsCardProps) {
|
||||||
// Calculate estimated total from costBreakup if not provided
|
// Calculate estimated total from costBreakup if not provided
|
||||||
const calculateEstimatedTotal = () => {
|
const calculateEstimatedTotal = () => {
|
||||||
if (proposalDetails.estimatedBudgetTotal !== undefined && proposalDetails.estimatedBudgetTotal !== null) {
|
const total = proposalDetails.totalEstimatedBudget ?? proposalDetails.estimatedBudgetTotal;
|
||||||
return proposalDetails.estimatedBudgetTotal;
|
if (total !== undefined && total !== null) {
|
||||||
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate sum from costBreakup items
|
// Calculate sum from costBreakup items
|
||||||
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
|
if (proposalDetails.costBreakup && proposalDetails.costBreakup.length > 0) {
|
||||||
const total = proposalDetails.costBreakup.reduce((sum, item) => {
|
const total = proposalDetails.costBreakup.reduce((sum, item) => {
|
||||||
const amount = item.amount || 0;
|
const amount = item.amount || 0;
|
||||||
return sum + (Number.isNaN(amount) ? 0 : amount);
|
const gst = item.gstAmt || 0;
|
||||||
|
const lineTotal = item.totalAmt || (Number(amount) + Number(gst));
|
||||||
|
return sum + (Number.isNaN(lineTotal) ? 0 : lineTotal);
|
||||||
}, 0);
|
}, 0);
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -99,7 +110,13 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
|
|||||||
Item Description
|
Item Description
|
||||||
</th>
|
</th>
|
||||||
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
Amount
|
Base Amount
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
GST
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-right text-xs font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
Total
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -107,16 +124,27 @@ export function ProposalDetailsCard({ proposalDetails, className }: ProposalDeta
|
|||||||
{(proposalDetails.costBreakup || []).map((item, index) => (
|
{(proposalDetails.costBreakup || []).map((item, index) => (
|
||||||
<tr key={index} className="hover:bg-gray-50">
|
<tr key={index} className="hover:bg-gray-50">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
<td className="px-4 py-3 text-sm text-gray-900">
|
||||||
{item.description}
|
<div>{item.description}</div>
|
||||||
|
{item.gstRate ? (
|
||||||
|
<div className="text-[10px] text-gray-400">
|
||||||
|
{item.cgstAmt ? `CGST: ${item.gstRate / 2}%, SGST: ${item.gstRate / 2}%` : `IGST: ${item.gstRate}%`}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 text-right">
|
||||||
|
{formatCurrency(item.amount)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-900 text-right">
|
||||||
|
{formatCurrency(item.gstAmt)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
|
<td className="px-4 py-3 text-sm text-gray-900 text-right font-medium">
|
||||||
{formatCurrency(item.amount)}
|
{formatCurrency(item.totalAmt || (Number(item.amount || 0) + Number(item.gstAmt || 0)))}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="bg-green-50 font-semibold">
|
<tr className="bg-green-50 font-semibold">
|
||||||
<td className="px-4 py-3 text-sm text-gray-900">
|
<td colSpan={3} className="px-4 py-3 text-sm text-gray-900">
|
||||||
Estimated Budget (Total)
|
Estimated Budget (Total Inclusive of GST)
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm text-green-700 text-right">
|
<td className="px-4 py-3 text-sm text-green-700 text-right">
|
||||||
{formatCurrency(estimatedTotal)}
|
{formatCurrency(estimatedTotal)}
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -40,6 +40,7 @@ interface CreditNoteSAPModalProps {
|
|||||||
requestNumber?: string;
|
requestNumber?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
|
taxationType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreditNoteSAPModal({
|
export function CreditNoteSAPModal({
|
||||||
@ -53,13 +54,16 @@ export function CreditNoteSAPModal({
|
|||||||
requestNumber,
|
requestNumber,
|
||||||
requestId: _requestId,
|
requestId: _requestId,
|
||||||
dueDate,
|
dueDate,
|
||||||
|
taxationType,
|
||||||
}: CreditNoteSAPModalProps) {
|
}: CreditNoteSAPModalProps) {
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
|
const isNonGst = taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||||
|
|
||||||
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
|
const hasCreditNote = creditNoteData?.creditNoteNumber && creditNoteData?.creditNoteNumber !== '';
|
||||||
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
|
const creditNoteNumber = creditNoteData?.creditNoteNumber || '';
|
||||||
const creditNoteDate = creditNoteData?.creditNoteDate
|
const creditNoteDate = creditNoteData?.creditNoteDate
|
||||||
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
|
? formatDateTime(creditNoteData.creditNoteDate, { includeTime: false, format: 'short' })
|
||||||
: '';
|
: '';
|
||||||
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
|
const creditNoteAmount = creditNoteData?.creditNoteAmount || 0;
|
||||||
@ -69,7 +73,7 @@ export function CreditNoteSAPModal({
|
|||||||
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
|
const dealerCode = dealerInfo?.dealerCode || 'RE-JP-009';
|
||||||
const activity = activityName || 'Activity';
|
const activity = activityName || 'Activity';
|
||||||
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
|
const requestIdDisplay = requestNumber || 'RE-REQ-2024-CM-101';
|
||||||
const dueDateDisplay = dueDate
|
const dueDateDisplay = dueDate
|
||||||
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
|
? formatDateTime(dueDate, { includeTime: false, format: 'short' })
|
||||||
: 'Jan 4, 2026';
|
: 'Jan 4, 2026';
|
||||||
|
|
||||||
@ -116,11 +120,18 @@ export function CreditNoteSAPModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-lg max-w-4xl max-h-[90vh] overflow-y-auto">
|
<DialogContent className="sm:max-w-lg lg:max-w-[1000px] max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl">
|
<DialogTitle className="font-semibold flex items-center gap-2 text-2xl flex-wrap">
|
||||||
<Receipt className="w-6 h-6 text-[--re-green]" />
|
<div className="flex items-center gap-2">
|
||||||
Credit Note from SAP
|
<Receipt className="w-6 h-6 text-[--re-green]" />
|
||||||
|
Credit Note from SAP
|
||||||
|
</div>
|
||||||
|
{taxationType && (
|
||||||
|
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
||||||
|
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-base">
|
<DialogDescription className="text-base">
|
||||||
Review and send credit note to dealer
|
Review and send credit note to dealer
|
||||||
@ -139,7 +150,7 @@ export function CreditNoteSAPModal({
|
|||||||
</div>
|
</div>
|
||||||
<Badge className="bg-green-600 text-white px-4 py-2 text-base">
|
<Badge className="bg-green-600 text-white px-4 py-2 text-base">
|
||||||
<CircleCheckBig className="w-4 h-4 mr-2" />
|
<CircleCheckBig className="w-4 h-4 mr-2" />
|
||||||
{status === 'APPROVED' || status === 'CONFIRMED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'}
|
{status === 'APPROVED' ? 'Approved' : status === 'ISSUED' ? 'Issued' : status === 'SENT' ? 'Sent' : 'Pending'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
.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%;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,67 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,67 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,39 @@
|
|||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -21,13 +21,13 @@ import { Textarea } from '@/components/ui/textarea';
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
|
import { CircleCheckBig, CircleX, Receipt, TriangleAlert } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import './DeptLeadIOApprovalModal.css';
|
||||||
|
|
||||||
interface DeptLeadIOApprovalModalProps {
|
interface DeptLeadIOApprovalModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onApprove: (data: {
|
onApprove: (data: {
|
||||||
ioNumber: string;
|
ioNumber: string;
|
||||||
ioRemark: string;
|
|
||||||
comments: string;
|
comments: string;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
onReject: (comments: string) => Promise<void>;
|
onReject: (comments: string) => Promise<void>;
|
||||||
@ -35,9 +35,9 @@ interface DeptLeadIOApprovalModalProps {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
// Pre-filled IO data from IO table
|
// Pre-filled IO data from IO table
|
||||||
preFilledIONumber?: string;
|
preFilledIONumber?: string;
|
||||||
preFilledIORemark?: string;
|
|
||||||
preFilledBlockedAmount?: number;
|
preFilledBlockedAmount?: number;
|
||||||
preFilledRemainingBalance?: number;
|
preFilledRemainingBalance?: number;
|
||||||
|
taxationType?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeptLeadIOApprovalModal({
|
export function DeptLeadIOApprovalModal({
|
||||||
@ -48,31 +48,30 @@ export function DeptLeadIOApprovalModal({
|
|||||||
requestTitle,
|
requestTitle,
|
||||||
requestId: _requestId,
|
requestId: _requestId,
|
||||||
preFilledIONumber,
|
preFilledIONumber,
|
||||||
preFilledIORemark,
|
|
||||||
preFilledBlockedAmount,
|
preFilledBlockedAmount,
|
||||||
preFilledRemainingBalance,
|
preFilledRemainingBalance,
|
||||||
|
taxationType,
|
||||||
}: DeptLeadIOApprovalModalProps) {
|
}: DeptLeadIOApprovalModalProps) {
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
const [actionType, setActionType] = useState<'approve' | 'reject'>('approve');
|
||||||
const [ioRemark, setIoRemark] = useState('');
|
|
||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const isNonGst = useMemo(() => {
|
||||||
|
return taxationType === 'Non GST' || taxationType === 'Non-GST';
|
||||||
|
}, [taxationType]);
|
||||||
|
|
||||||
// Get IO number from props (read-only, from IO table)
|
// Get IO number from props (read-only, from IO table)
|
||||||
const ioNumber = preFilledIONumber || '';
|
const ioNumber = preFilledIONumber || '';
|
||||||
|
|
||||||
// Reset form when modal opens/closes
|
// Reset form when modal opens/closes
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
// Prefill IO remark from props if available
|
|
||||||
setIoRemark(preFilledIORemark || '');
|
|
||||||
setComments('');
|
setComments('');
|
||||||
setActionType('approve');
|
setActionType('approve');
|
||||||
}
|
}
|
||||||
}, [isOpen, preFilledIORemark]);
|
}, [isOpen]);
|
||||||
|
|
||||||
const ioRemarkChars = ioRemark.length;
|
|
||||||
const commentsChars = comments.length;
|
const commentsChars = comments.length;
|
||||||
const maxIoRemarkChars = 300;
|
|
||||||
const maxCommentsChars = 500;
|
const maxCommentsChars = 500;
|
||||||
|
|
||||||
// Validate form
|
// Validate form
|
||||||
@ -80,13 +79,12 @@ export function DeptLeadIOApprovalModal({
|
|||||||
if (actionType === 'reject') {
|
if (actionType === 'reject') {
|
||||||
return comments.trim().length > 0;
|
return comments.trim().length > 0;
|
||||||
}
|
}
|
||||||
// For approve, need IO number (from table), IO remark, and comments
|
// For approve, need IO number (from table) and comments
|
||||||
return (
|
return (
|
||||||
ioNumber.trim().length > 0 && // IO number must exist from IO table
|
ioNumber.trim().length > 0 && // IO number must exist from IO table
|
||||||
ioRemark.trim().length > 0 &&
|
|
||||||
comments.trim().length > 0
|
comments.trim().length > 0
|
||||||
);
|
);
|
||||||
}, [actionType, ioNumber, ioRemark, comments]);
|
}, [actionType, ioNumber, comments]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!isFormValid) {
|
if (!isFormValid) {
|
||||||
@ -95,10 +93,6 @@ export function DeptLeadIOApprovalModal({
|
|||||||
toast.error('IO number is required. Please block amount from IO tab first.');
|
toast.error('IO number is required. Please block amount from IO tab first.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ioRemark.trim()) {
|
|
||||||
toast.error('Please enter IO remark');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!comments.trim()) {
|
if (!comments.trim()) {
|
||||||
toast.error('Please provide comments');
|
toast.error('Please provide comments');
|
||||||
@ -109,17 +103,16 @@ export function DeptLeadIOApprovalModal({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
if (actionType === 'approve') {
|
if (actionType === 'approve') {
|
||||||
await onApprove({
|
await onApprove({
|
||||||
ioNumber: ioNumber.trim(),
|
ioNumber: ioNumber.trim(),
|
||||||
ioRemark: ioRemark.trim(),
|
|
||||||
comments: comments.trim(),
|
comments: comments.trim(),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await onReject(comments.trim());
|
await onReject(comments.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
handleReset();
|
handleReset();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -132,7 +125,6 @@ export function DeptLeadIOApprovalModal({
|
|||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setActionType('approve');
|
setActionType('approve');
|
||||||
setIoRemark('');
|
|
||||||
setComments('');
|
setComments('');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -145,35 +137,40 @@ export function DeptLeadIOApprovalModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="dept-lead-io-modal overflow-hidden flex flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader className="flex-shrink-0 px-6 pt-6 pb-3">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-2 lg:gap-3 mb-2">
|
||||||
<div className="p-2 rounded-lg bg-green-100">
|
<div className="p-1.5 lg:p-2 rounded-lg bg-green-100">
|
||||||
<CircleCheckBig className="w-6 h-6 text-green-600" />
|
<CircleCheckBig className="w-5 h-5 lg:w-6 lg:h-6 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<DialogTitle className="font-semibold text-xl">
|
<DialogTitle className="font-semibold text-lg lg:text-xl flex items-center gap-2 flex-wrap">
|
||||||
Approve and Organise IO
|
Review and Approve
|
||||||
|
{taxationType && (
|
||||||
|
<Badge className={`ml-2 border-none shadow-sm ${!isNonGst ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-indigo-600 text-white hover:bg-indigo-700'}`}>
|
||||||
|
{!isNonGst ? 'GST Claim' : 'Non-GST Claim'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-sm mt-1">
|
<DialogDescription className="text-xs lg:text-sm mt-1">
|
||||||
Review IO details and provide your approval comments
|
Review IO details and provide your approval comments
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Request Info Card */}
|
{/* Request Info Card */}
|
||||||
<div className="space-y-3 p-4 bg-gray-50 rounded-lg border">
|
<div className="space-y-2 lg:space-y-3 p-3 lg:p-4 bg-gray-50 rounded-lg border">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="font-medium text-gray-900">Workflow Step:</span>
|
<span className="font-medium text-sm lg:text-base text-gray-900">Workflow Step:</span>
|
||||||
<Badge variant="outline" className="font-mono">Step 3</Badge>
|
<Badge variant="outline" className="font-mono text-xs">Step 3</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium text-gray-900">Title:</span>
|
<span className="font-medium text-sm lg:text-base text-gray-900">Title:</span>
|
||||||
<p className="text-gray-700 mt-1">{requestTitle || '—'}</p>
|
<p className="text-xs lg:text-sm text-gray-700 mt-1">{requestTitle || '—'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-gray-900">Action:</span>
|
<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">
|
<Badge className="bg-green-100 text-green-800 border-green-200 text-xs">
|
||||||
<CircleCheckBig className="w-3 h-3 mr-1" />
|
<CircleCheckBig className="w-3 h-3 mr-1" />
|
||||||
APPROVE
|
APPROVE
|
||||||
</Badge>
|
</Badge>
|
||||||
@ -181,176 +178,150 @@ export function DeptLeadIOApprovalModal({
|
|||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="flex-1 overflow-y-auto overflow-x-hidden py-3 lg:py-4 px-6">
|
||||||
{/* Action Toggle Buttons */}
|
<div className="space-y-3 lg:space-y-4">
|
||||||
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
{/* Action Toggle Buttons */}
|
||||||
<Button
|
<div className="flex gap-2 p-1 bg-gray-100 rounded-lg">
|
||||||
type="button"
|
<Button
|
||||||
onClick={() => setActionType('approve')}
|
type="button"
|
||||||
className={`flex-1 ${
|
onClick={() => setActionType('approve')}
|
||||||
actionType === 'approve'
|
className={`flex-1 text-sm lg:text-base ${actionType === 'approve'
|
||||||
? 'bg-green-600 text-white shadow-sm'
|
? 'bg-green-600 text-white shadow-sm'
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
: 'text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
variant={actionType === 'approve' ? 'default' : 'ghost'}
|
variant={actionType === 'approve' ? 'default' : 'ghost'}
|
||||||
>
|
>
|
||||||
<CircleCheckBig className="w-4 h-4 mr-1" />
|
<CircleCheckBig className="w-4 h-4 mr-1" />
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActionType('reject')}
|
onClick={() => setActionType('reject')}
|
||||||
className={`flex-1 ${
|
className={`flex-1 text-sm lg:text-base ${actionType === 'reject'
|
||||||
actionType === 'reject'
|
|
||||||
? 'bg-red-600 text-white shadow-sm'
|
? 'bg-red-600 text-white shadow-sm'
|
||||||
: 'text-gray-700 hover:bg-gray-200'
|
: 'text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
|
variant={actionType === 'reject' ? 'destructive' : 'ghost'}
|
||||||
>
|
>
|
||||||
<CircleX className="w-4 h-4 mr-1" />
|
<CircleX className="w-4 h-4 mr-1" />
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IO Organisation Details - Only shown when approving */}
|
{/* Main Content Area - Two Column Layout on Large Screens */}
|
||||||
{actionType === 'approve' && (
|
<div className="space-y-3 lg:space-y-0 lg:grid lg:grid-cols-2 lg:gap-6">
|
||||||
<div className="p-3 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
|
{/* Left Column - IO Organisation Details (Only shown when approving) */}
|
||||||
<div className="flex items-center gap-2">
|
{actionType === 'approve' && (
|
||||||
<Receipt className="w-4 h-4 text-blue-600" />
|
<div className="p-3 lg:p-4 bg-blue-50 border border-blue-200 rounded-lg space-y-3">
|
||||||
<h4 className="font-semibold text-blue-900">IO Organisation Details</h4>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<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 Number - Read-only from IO table */}
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="ioNumber" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
IO Number <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="ioNumber"
|
|
||||||
value={ioNumber || '—'}
|
|
||||||
disabled
|
|
||||||
readOnly
|
|
||||||
className="bg-gray-100 h-8 cursor-not-allowed"
|
|
||||||
/>
|
|
||||||
{!ioNumber && (
|
|
||||||
<p className="text-xs text-red-600 mt-1">
|
|
||||||
⚠️ IO number not found. Please block amount from IO tab first.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{ioNumber && (
|
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
|
||||||
✓ Loaded from IO table
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IO Balance Information - Read-only */}
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{/* Blocked Amount Display */}
|
|
||||||
{preFilledBlockedAmount !== undefined && preFilledBlockedAmount > 0 && (
|
|
||||||
<div className="p-2 bg-green-50 border border-green-200 rounded">
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs font-semibold text-gray-700">Blocked Amount:</span>
|
|
||||||
<span className="text-sm font-bold text-green-700 mt-1">
|
|
||||||
₹{preFilledBlockedAmount.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Remaining Balance Display */}
|
{/* IO Number - Read-only from IO table */}
|
||||||
{preFilledRemainingBalance !== undefined && preFilledRemainingBalance !== null && (
|
<div className="space-y-1">
|
||||||
<div className="p-2 bg-blue-50 border border-blue-200 rounded">
|
<Label htmlFor="ioNumber" className="text-xs lg:text-sm font-semibold text-gray-900 flex items-center gap-2">
|
||||||
<div className="flex flex-col">
|
IO Number <span className="text-red-500">*</span>
|
||||||
<span className="text-xs font-semibold text-gray-700">Remaining Balance:</span>
|
</Label>
|
||||||
<span className="text-sm font-bold text-blue-700 mt-1">
|
<Input
|
||||||
₹{preFilledRemainingBalance.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
id="ioNumber"
|
||||||
</span>
|
value={ioNumber || '—'}
|
||||||
</div>
|
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>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* IO Remark - Editable field (prefilled from IO tab, but can be modified) */}
|
{/* IO Balance Information - Read-only */}
|
||||||
<div className="space-y-1">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Label htmlFor="ioRemark" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
{/* Blocked Amount Display */}
|
||||||
IO Remark <span className="text-red-500">*</span>
|
{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>
|
||||||
</Label>
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="ioRemark"
|
id="comment"
|
||||||
placeholder="Enter remarks about IO organization"
|
placeholder={
|
||||||
value={ioRemark}
|
actionType === 'approve'
|
||||||
|
? 'Enter your approval comments and any conditions or notes...'
|
||||||
|
: 'Enter detailed reasons for rejection...'
|
||||||
|
}
|
||||||
|
value={comments}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
if (value.length <= maxIoRemarkChars) {
|
if (value.length <= maxCommentsChars) {
|
||||||
setIoRemark(value);
|
setComments(value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
rows={3}
|
rows={4}
|
||||||
className="bg-white text-sm min-h-[80px] resize-none"
|
className="text-xs lg:text-sm min-h-[80px] lg:min-h-[100px] resize-none"
|
||||||
disabled={false}
|
|
||||||
readOnly={false}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||||
{preFilledIORemark && (
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-blue-600">
|
<TriangleAlert className="w-3 h-3" />
|
||||||
✓ Prefilled from IO tab (editable)
|
Required and visible to all
|
||||||
</span>
|
</div>
|
||||||
)}
|
<span>{commentsChars}/{maxCommentsChars}</span>
|
||||||
<span className="text-gray-600">{ioRemarkChars}/{maxIoRemarkChars}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments & Remarks */}
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="comment" className="text-sm font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
Comments & Remarks <span className="text-red-500">*</span>
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id="comment"
|
|
||||||
placeholder={
|
|
||||||
actionType === 'approve'
|
|
||||||
? 'Enter your approval comments and any conditions or notes...'
|
|
||||||
: 'Enter detailed reasons for rejection...'
|
|
||||||
}
|
|
||||||
value={comments}
|
|
||||||
onChange={(e) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
if (value.length <= maxCommentsChars) {
|
|
||||||
setComments(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
rows={4}
|
|
||||||
className="text-sm min-h-[80px] resize-none"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<TriangleAlert className="w-3 h-3" />
|
|
||||||
Required and visible to all
|
|
||||||
</div>
|
|
||||||
<span>{commentsChars}/{maxCommentsChars}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
<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">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
|
className="text-sm lg:text-base"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isFormValid || submitting}
|
disabled={!isFormValid || submitting}
|
||||||
className={`${
|
className={`text-sm lg:text-base ${actionType === 'approve'
|
||||||
actionType === 'approve'
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
? 'bg-green-600 hover:bg-green-700'
|
: 'bg-red-600 hover:bg-red-700'
|
||||||
: 'bg-red-600 hover:bg-red-700'
|
} text-white`}
|
||||||
} text-white`}
|
|
||||||
>
|
>
|
||||||
{submitting ? (
|
{submitting ? (
|
||||||
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
|
`${actionType === 'approve' ? 'Approving' : 'Rejecting'}...`
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export function EditClaimAmountModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
<DialogContent className="sm:max-w-[500px] lg:max-w-[800px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<DollarSign className="w-5 h-5 text-green-600" />
|
<DollarSign className="w-5 h-5 text-green-600" />
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export function EmailNotificationTemplateModal({
|
|||||||
stepNumber,
|
stepNumber,
|
||||||
stepName,
|
stepName,
|
||||||
requestNumber = 'RE-REQ-2024-CM-101',
|
requestNumber = 'RE-REQ-2024-CM-101',
|
||||||
recipientEmail = 'system@royalenfield.com',
|
recipientEmail = `system@${import.meta.env.VITE_EMAIL_DOMAIN}`,
|
||||||
subject,
|
subject,
|
||||||
emailBody,
|
emailBody,
|
||||||
}: EmailNotificationTemplateModalProps) {
|
}: EmailNotificationTemplateModalProps) {
|
||||||
@ -53,7 +53,7 @@ This is an automated message.`;
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
<DialogContent className="sm:max-w-2xl max-w-2xl">
|
<DialogContent className="sm:max-w-2xl lg:max-w-[1000px] max-w-2xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,307 @@
|
|||||||
|
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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@
|
|||||||
* Located in: src/dealer-claim/components/request-detail/modals/
|
* Located in: src/dealer-claim/components/request-detail/modals/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export { AdditionalApproverReviewModal } from './AdditionalApproverReviewModal';
|
||||||
export { CreditNoteSAPModal } from './CreditNoteSAPModal';
|
export { CreditNoteSAPModal } from './CreditNoteSAPModal';
|
||||||
export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal';
|
export { DealerCompletionDocumentsModal } from './DealerCompletionDocumentsModal';
|
||||||
export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal';
|
export { DealerProposalSubmissionModal } from './DealerProposalSubmissionModal';
|
||||||
@ -12,4 +13,6 @@ export { DeptLeadIOApprovalModal } from './DeptLeadIOApprovalModal';
|
|||||||
export { DMSPushModal } from './DMSPushModal';
|
export { DMSPushModal } from './DMSPushModal';
|
||||||
export { EditClaimAmountModal } from './EditClaimAmountModal';
|
export { EditClaimAmountModal } from './EditClaimAmountModal';
|
||||||
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
|
export { EmailNotificationTemplateModal } from './EmailNotificationTemplateModal';
|
||||||
|
export { InitiatorActionModal } from './InitiatorActionModal';
|
||||||
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
|
export { InitiatorProposalApprovalModal } from './InitiatorProposalApprovalModal';
|
||||||
|
export { SnapshotDetailsModal } from './SnapshotDetailsModal';
|
||||||
|
|||||||
@ -30,5 +30,13 @@ export { ClaimManagementWizard } from './components/request-creation/ClaimManage
|
|||||||
// Request Detail Screen (Complete standalone screen)
|
// Request Detail Screen (Complete standalone screen)
|
||||||
export { DealerClaimRequestDetail } from './pages/RequestDetail';
|
export { DealerClaimRequestDetail } from './pages/RequestDetail';
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
export { DealerDashboard } from './pages/Dashboard';
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
export { DealerRequestsFilters } from './components/DealerRequestsFilters';
|
||||||
|
export { DealerClosedRequestsFilters } from './components/DealerClosedRequestsFilters';
|
||||||
|
export { DealerUserAllRequestsFilters } from './components/DealerUserAllRequestsFilters';
|
||||||
|
|
||||||
// Re-export types
|
// Re-export types
|
||||||
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
export type { RequestDetailProps } from '@/pages/RequestDetail/types/requestDetail.types';
|
||||||
|
|||||||
671
src/dealer-claim/pages/Dashboard.tsx
Normal file
671
src/dealer-claim/pages/Dashboard.tsx
Normal file
@ -0,0 +1,671 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -38,6 +38,10 @@ import { useDocumentUpload } from '@/hooks/useDocumentUpload';
|
|||||||
import { useModalManager } from '@/hooks/useModalManager';
|
import { useModalManager } from '@/hooks/useModalManager';
|
||||||
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
|
||||||
import { downloadDocument } from '@/services/workflowApi';
|
import { downloadDocument } from '@/services/workflowApi';
|
||||||
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||||
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||||
|
|
||||||
|
|
||||||
// Dealer Claim Components (import from index to get properly aliased exports)
|
// Dealer Claim Components (import from index to get properly aliased exports)
|
||||||
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
|
import { DealerClaimOverviewTab, DealerClaimWorkflowTab, IOTab } from '../index';
|
||||||
@ -113,6 +117,24 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
const [showResumeModal, setShowResumeModal] = useState(false);
|
||||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||||
|
const [systemPolicy, setSystemPolicy] = useState<{
|
||||||
|
maxApprovalLevels: number;
|
||||||
|
maxParticipants: number;
|
||||||
|
allowSpectators: boolean;
|
||||||
|
maxSpectators: number;
|
||||||
|
}>({
|
||||||
|
maxApprovalLevels: 10,
|
||||||
|
maxParticipants: 50,
|
||||||
|
allowSpectators: true,
|
||||||
|
maxSpectators: 20
|
||||||
|
});
|
||||||
|
const [policyViolationModal, setPolicyViolationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
violations: []
|
||||||
|
});
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
@ -131,34 +153,14 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
|
|
||||||
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
|
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
|
||||||
const currentUserId = (user as any)?.userId || '';
|
const currentUserId = (user as any)?.userId || '';
|
||||||
const currentUserEmail = (user as any)?.email?.toLowerCase() || '';
|
|
||||||
// Use approvalFlow (transformed) or approvals (raw) - both have step/levelNumber
|
|
||||||
const approvalFlow = apiRequest?.approvalFlow || [];
|
|
||||||
const approvals = apiRequest?.approvals || [];
|
|
||||||
|
|
||||||
// Find Department Lead step dynamically by levelName (handles step shifts when approvers are added)
|
|
||||||
const deptLeadLevel = approvalFlow.find((level: any) => {
|
|
||||||
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
|
||||||
return levelName.includes('department lead');
|
|
||||||
}) || approvals.find((level: any) => {
|
|
||||||
const levelName = (level.levelName || level.level_name || '').toLowerCase();
|
|
||||||
return levelName.includes('department lead');
|
|
||||||
}) || approvalFlow.find((level: any) =>
|
|
||||||
(level.step || level.levelNumber || level.level_number) === 3
|
|
||||||
) || approvals.find((level: any) =>
|
|
||||||
(level.levelNumber || level.level_number) === 3
|
|
||||||
); // Fallback to step 3 for backwards compatibility
|
|
||||||
|
|
||||||
const deptLeadUserId = deptLeadLevel?.approverId || deptLeadLevel?.approver_id || deptLeadLevel?.approver?.userId;
|
|
||||||
const deptLeadEmail = (deptLeadLevel?.approverEmail || deptLeadLevel?.approver_email || deptLeadLevel?.approver?.email || '').toLowerCase().trim();
|
|
||||||
|
|
||||||
// User is department lead if they match the Department Lead approver (regardless of status or step number)
|
|
||||||
const isDeptLead = (deptLeadUserId && deptLeadUserId === currentUserId) ||
|
|
||||||
(deptLeadEmail && currentUserEmail && deptLeadEmail === currentUserEmail);
|
|
||||||
|
|
||||||
// IO tab visibility for dealer claims
|
// IO tab visibility for dealer claims
|
||||||
// Show IO tab only for department lead (found dynamically, not hardcoded to step 3)
|
// Restricted: Hide from Dealers, show for internal roles (Initiator, Dept Lead, Finance, Admin)
|
||||||
const showIOTab = isDeptLead;
|
const isDealer = (user as any)?.jobTitle === 'Dealer' || (user as any)?.designation === 'Dealer';
|
||||||
|
const isClaimManagement = request?.workflowType === 'CLAIM_MANAGEMENT' ||
|
||||||
|
apiRequest?.workflowType === 'CLAIM_MANAGEMENT' ||
|
||||||
|
request?.templateType === 'claim-management';
|
||||||
|
const showIOTab = isClaimManagement && !isDealer;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mergedMessages,
|
mergedMessages,
|
||||||
@ -177,6 +179,12 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setDocumentError,
|
setDocumentError,
|
||||||
} = useDocumentUpload(apiRequest, refreshDetails);
|
} = useDocumentUpload(apiRequest, refreshDetails);
|
||||||
|
|
||||||
|
// State to temporarily store approval level for modal (used for additional approvers)
|
||||||
|
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
|
||||||
|
|
||||||
|
// Use temporary level if set, otherwise use currentApprovalLevel
|
||||||
|
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
showApproveModal,
|
showApproveModal,
|
||||||
setShowApproveModal,
|
setShowApproveModal,
|
||||||
@ -194,26 +202,30 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setSkipApproverData,
|
setSkipApproverData,
|
||||||
actionStatus,
|
actionStatus,
|
||||||
setActionStatus,
|
setActionStatus,
|
||||||
handleApproveConfirm,
|
handleApproveConfirm: originalHandleApproveConfirm,
|
||||||
handleRejectConfirm,
|
handleRejectConfirm: originalHandleRejectConfirm,
|
||||||
handleAddApprover,
|
handleAddApprover,
|
||||||
handleSkipApprover,
|
handleSkipApprover,
|
||||||
handleAddSpectator,
|
handleAddSpectator,
|
||||||
} = useModalManager(requestIdentifier, currentApprovalLevel, refreshDetails);
|
} = 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);
|
||||||
|
};
|
||||||
|
|
||||||
// Closure functionality - only for initiator when request is approved/rejected
|
// Closure functionality - only for initiator when request is approved/rejected
|
||||||
// Check both lowercase and uppercase status values
|
// Check both lowercase and uppercase status values
|
||||||
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
||||||
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
|
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
|
||||||
|
|
||||||
// Debug logging
|
// Closure check completed
|
||||||
console.debug('[DealerClaimRequestDetail] Closure check:', {
|
|
||||||
requestStatus,
|
|
||||||
requestStatusRaw: request?.status,
|
|
||||||
apiRequestStatusRaw: apiRequest?.status,
|
|
||||||
isInitiator,
|
|
||||||
needsClosure,
|
|
||||||
});
|
|
||||||
const {
|
const {
|
||||||
conclusionRemark,
|
conclusionRemark,
|
||||||
setConclusionRemark,
|
setConclusionRemark,
|
||||||
@ -222,6 +234,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
aiGenerated,
|
aiGenerated,
|
||||||
handleGenerateConclusion,
|
handleGenerateConclusion,
|
||||||
handleFinalizeConclusion,
|
handleFinalizeConclusion,
|
||||||
|
generationAttempts,
|
||||||
|
generationFailed,
|
||||||
|
maxAttemptsReached,
|
||||||
} = useConclusionRemark(
|
} = useConclusionRemark(
|
||||||
request,
|
request,
|
||||||
requestIdentifier,
|
requestIdentifier,
|
||||||
@ -232,6 +247,32 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setShowActionStatusModal
|
setShowActionStatusModal
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load system policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSystemPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSystemPolicy({
|
||||||
|
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
|
||||||
|
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
|
||||||
|
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
|
||||||
|
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load system policy:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSystemPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-switch tab when URL query parameter changes
|
// Auto-switch tab when URL query parameter changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
@ -284,7 +325,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setShowShareSummaryModal(true);
|
setShowShareSummaryModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
|
const isClosed = request?.status === 'closed';
|
||||||
|
|
||||||
// Fetch summary details if request is closed
|
// Fetch summary details if request is closed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -298,7 +339,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
try {
|
try {
|
||||||
setLoadingSummary(true);
|
setLoadingSummary(true);
|
||||||
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
||||||
|
|
||||||
if (summary?.summaryId) {
|
if (summary?.summaryId) {
|
||||||
setSummaryId(summary.summaryId);
|
setSummaryId(summary.summaryId);
|
||||||
try {
|
try {
|
||||||
@ -324,6 +365,37 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
fetchSummaryDetails();
|
fetchSummaryDetails();
|
||||||
}, [isClosed, apiRequest?.requestId]);
|
}, [isClosed, apiRequest?.requestId]);
|
||||||
|
|
||||||
|
// Listen for credit note notifications and trigger silent refresh
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentUserId || !apiRequest?.requestId) return;
|
||||||
|
|
||||||
|
const socket = getSocket();
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
joinUserRoom(socket, currentUserId);
|
||||||
|
|
||||||
|
const handleNewNotification = (data: { notification: any }) => {
|
||||||
|
const notif = data?.notification;
|
||||||
|
if (!notif) return;
|
||||||
|
|
||||||
|
const notifRequestId = notif.requestId || notif.request_id;
|
||||||
|
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
|
||||||
|
if (notifRequestId !== apiRequest.requestId &&
|
||||||
|
notifRequestNumber !== requestIdentifier &&
|
||||||
|
notifRequestNumber !== apiRequest.requestNumber) return;
|
||||||
|
|
||||||
|
// Check for credit note metadata
|
||||||
|
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
|
||||||
|
refreshDetails();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('notification:new', handleNewNotification);
|
||||||
|
return () => {
|
||||||
|
socket.off('notification:new', handleNewNotification);
|
||||||
|
};
|
||||||
|
}, [currentUserId, apiRequest?.requestId, requestIdentifier, refreshDetails]);
|
||||||
|
|
||||||
// Get current levels for WorkNotesTab
|
// Get current levels for WorkNotesTab
|
||||||
const currentLevels = (request?.approvalFlow || [])
|
const currentLevels = (request?.approvalFlow || [])
|
||||||
.filter((flow: any) => flow && typeof flow.step === 'number')
|
.filter((flow: any) => flow && typeof flow.step === 'number')
|
||||||
@ -359,15 +431,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
{accessDenied.message}
|
{accessDenied.message}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBack || (() => window.history.back())}
|
onClick={onBack || (() => window.history.back())}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
@ -392,15 +464,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
The dealer claim request you're looking for doesn't exist or may have been deleted.
|
The dealer claim request you're looking for doesn't exist or may have been deleted.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-3 justify-center">
|
<div className="flex gap-3 justify-center">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={onBack || (() => window.history.back())}
|
onClick={onBack || (() => window.history.back())}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-4 h-4" />
|
<ArrowLeft className="w-4 h-4" />
|
||||||
Go Back
|
Go Back
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => window.location.href = '/dashboard'}
|
onClick={() => window.location.href = '/dashboard'}
|
||||||
className="bg-blue-600 hover:bg-blue-700"
|
className="bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
@ -522,13 +594,16 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
aiGenerated={aiGenerated}
|
aiGenerated={aiGenerated}
|
||||||
handleGenerateConclusion={handleGenerateConclusion}
|
handleGenerateConclusion={handleGenerateConclusion}
|
||||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||||
|
generationAttempts={generationAttempts}
|
||||||
|
generationFailed={generationFailed}
|
||||||
|
maxAttemptsReached={maxAttemptsReached}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{isClosed && (
|
{isClosed && (
|
||||||
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
||||||
<SummaryTab
|
<SummaryTab
|
||||||
summary={summaryDetails}
|
summary={summaryDetails}
|
||||||
loading={loadingSummary}
|
loading={loadingSummary}
|
||||||
onShare={handleShareSummary}
|
onShare={handleShareSummary}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
@ -550,6 +625,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
setShowSkipApproverModal(true);
|
setShowSkipApproverModal(true);
|
||||||
}}
|
}}
|
||||||
onRefresh={refreshDetails}
|
onRefresh={refreshDetails}
|
||||||
|
documentPolicy={documentPolicy}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -589,6 +665,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
isSpectator={isSpectator}
|
isSpectator={isSpectator}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
onAddApprover={handleAddApprover}
|
onAddApprover={handleAddApprover}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</div>
|
</div>
|
||||||
@ -678,6 +756,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
actionStatus={actionStatus}
|
actionStatus={actionStatus}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
|
maxApprovalLevels={systemPolicy.maxApprovalLevels}
|
||||||
|
onPolicyViolation={(violations) => setPolicyViolationModal({ open: true, violations })}
|
||||||
setShowApproveModal={setShowApproveModal}
|
setShowApproveModal={setShowApproveModal}
|
||||||
setShowRejectModal={setShowRejectModal}
|
setShowRejectModal={setShowRejectModal}
|
||||||
setShowAddApproverModal={setShowAddApproverModal}
|
setShowAddApproverModal={setShowAddApproverModal}
|
||||||
@ -696,6 +776,19 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
|||||||
downloadDocument={downloadDocument}
|
downloadDocument={downloadDocument}
|
||||||
documentPolicy={documentPolicy}
|
documentPolicy={documentPolicy}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Policy Violation Modal */}
|
||||||
|
<PolicyViolationModal
|
||||||
|
open={policyViolationModal.open}
|
||||||
|
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
|
||||||
|
violations={policyViolationModal.violations}
|
||||||
|
policyDetails={{
|
||||||
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
||||||
|
maxParticipants: systemPolicy.maxParticipants,
|
||||||
|
allowSpectators: systemPolicy.allowSpectators,
|
||||||
|
maxSpectators: systemPolicy.maxSpectators,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
74
src/flows.ts
74
src/flows.ts
@ -13,6 +13,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RequestFlowType } from '@/utils/requestTypeUtils';
|
import { RequestFlowType } from '@/utils/requestTypeUtils';
|
||||||
|
import { UserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
|
||||||
// Import flow modules from src/ level
|
// Import flow modules from src/ level
|
||||||
import * as CustomFlow from './custom';
|
import * as CustomFlow from './custom';
|
||||||
@ -88,6 +89,79 @@ export function getRequestDetailScreen(flowType: RequestFlowType) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Requests Filters component for a user filter type
|
||||||
|
* Each user type can have its own filter component
|
||||||
|
*
|
||||||
|
* This allows for plug-and-play filter components:
|
||||||
|
* - DEALER: Simplified filters (search + sort only)
|
||||||
|
* - STANDARD: Full filters (search + status + priority + template + sort)
|
||||||
|
*
|
||||||
|
* To add a new user filter type:
|
||||||
|
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
||||||
|
* 2. Create a filter component in the appropriate flow folder
|
||||||
|
* 3. Export it from the flow's index.ts
|
||||||
|
* 4. Add a case here to return it
|
||||||
|
*/
|
||||||
|
export function getRequestsFilters(userFilterType: UserFilterType) {
|
||||||
|
switch (userFilterType) {
|
||||||
|
case 'DEALER':
|
||||||
|
return DealerClaimFlow.DealerRequestsFilters;
|
||||||
|
case 'STANDARD':
|
||||||
|
default:
|
||||||
|
return CustomFlow.StandardRequestsFilters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Closed Requests Filters component for a user filter type
|
||||||
|
* Each user type can have its own filter component for closed requests
|
||||||
|
*
|
||||||
|
* This allows for plug-and-play filter components:
|
||||||
|
* - DEALER: Simplified filters (search + status + sort only, no priority or template)
|
||||||
|
* - STANDARD: Full filters (search + priority + status + template + sort)
|
||||||
|
*
|
||||||
|
* To add a new user filter type:
|
||||||
|
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
||||||
|
* 2. Create a closed requests filter component in the appropriate flow folder
|
||||||
|
* 3. Export it from the flow's index.ts
|
||||||
|
* 4. Add a case here to return it
|
||||||
|
*/
|
||||||
|
export function getClosedRequestsFilters(userFilterType: UserFilterType) {
|
||||||
|
switch (userFilterType) {
|
||||||
|
case 'DEALER':
|
||||||
|
return DealerClaimFlow.DealerClosedRequestsFilters;
|
||||||
|
case 'STANDARD':
|
||||||
|
default:
|
||||||
|
return CustomFlow.StandardClosedRequestsFilters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get User All Requests Filters component for a user filter type
|
||||||
|
* Each user type can have its own filter component for user all requests
|
||||||
|
*
|
||||||
|
* This allows for plug-and-play filter components:
|
||||||
|
* - DEALER: Simplified filters (search + status + initiator + approver + date range, no priority/template/department/sla)
|
||||||
|
* - STANDARD: Full filters (all filters including priority, template, department, and SLA compliance)
|
||||||
|
*
|
||||||
|
* To add a new user filter type:
|
||||||
|
* 1. Add the user filter type to UserFilterType in userFilterUtils.ts
|
||||||
|
* 2. Create a user all requests filter component in the appropriate flow folder
|
||||||
|
* 3. Export it from the flow's index.ts
|
||||||
|
* 4. Add a case here to return it
|
||||||
|
*/
|
||||||
|
export function getUserAllRequestsFilters(userFilterType: UserFilterType) {
|
||||||
|
switch (userFilterType) {
|
||||||
|
case 'DEALER':
|
||||||
|
return DealerClaimFlow.DealerUserAllRequestsFilters;
|
||||||
|
case 'STANDARD':
|
||||||
|
default:
|
||||||
|
return CustomFlow.StandardUserAllRequestsFilters;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export flow modules for direct access
|
// Re-export flow modules for direct access
|
||||||
export { CustomFlow, DealerClaimFlow, SharedComponents };
|
export { CustomFlow, DealerClaimFlow, SharedComponents };
|
||||||
export type { RequestFlowType } from '@/utils/requestTypeUtils';
|
export type { RequestFlowType } from '@/utils/requestTypeUtils';
|
||||||
|
export type { UserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
|||||||
@ -42,6 +42,18 @@ export function useConclusionRemark(
|
|||||||
// State: Tracks if current conclusion was AI-generated (shows badge in UI)
|
// State: Tracks if current conclusion was AI-generated (shows badge in UI)
|
||||||
const [aiGenerated, setAiGenerated] = useState(false);
|
const [aiGenerated, setAiGenerated] = useState(false);
|
||||||
|
|
||||||
|
// State: Tracks number of AI generation attempts
|
||||||
|
const [generationAttempts, setGenerationAttempts] = useState(0);
|
||||||
|
|
||||||
|
// State: Tracks if AI generation failed (unable to generate)
|
||||||
|
const [generationFailed, setGenerationFailed] = useState(false);
|
||||||
|
|
||||||
|
// State: Tracks if max attempts (3 for success, 1 for fail) reached
|
||||||
|
const [maxAttemptsReached, setMaxAttemptsReached] = useState(false);
|
||||||
|
|
||||||
|
// State: Tracks number of AI generation failures
|
||||||
|
const [failureAttempts, setFailureAttempts] = useState(0);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function: fetchExistingConclusion
|
* Function: fetchExistingConclusion
|
||||||
*
|
*
|
||||||
@ -50,26 +62,46 @@ export function useConclusionRemark(
|
|||||||
* Use Case: When request is approved, final approver generates conclusion.
|
* Use Case: When request is approved, final approver generates conclusion.
|
||||||
* Initiator needs to review and finalize it before closing request.
|
* Initiator needs to review and finalize it before closing request.
|
||||||
*
|
*
|
||||||
|
* Optimization: Check request object first before making API call
|
||||||
* Process:
|
* Process:
|
||||||
* 1. Dynamically import conclusion API service
|
* 1. Check if conclusion data is already in request object
|
||||||
* 2. Fetch conclusion by request ID
|
* 2. If not available, fetch from API
|
||||||
* 3. Load into state if exists
|
* 3. Load into state if exists
|
||||||
* 4. Mark as AI-generated if applicable
|
* 4. Mark as AI-generated if applicable
|
||||||
*/
|
*/
|
||||||
const fetchExistingConclusion = async () => {
|
const fetchExistingConclusion = async () => {
|
||||||
|
// Optimization: Check if conclusion data is already in request object
|
||||||
|
// Request detail response includes conclusionRemark and aiGeneratedConclusion fields
|
||||||
|
const existingConclusion = request?.conclusionRemark || request?.conclusion_remark;
|
||||||
|
const existingAiConclusion = request?.aiGeneratedConclusion || request?.ai_generated_conclusion;
|
||||||
|
|
||||||
|
if (existingConclusion || existingAiConclusion) {
|
||||||
|
// Use data from request object - no API call needed
|
||||||
|
setConclusionRemark(existingConclusion || existingAiConclusion);
|
||||||
|
setAiGenerated(!!existingAiConclusion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only fetch from API if not available in request object
|
||||||
|
// This handles cases where request object might not have been refreshed yet
|
||||||
try {
|
try {
|
||||||
// Lazy load: Import conclusion API only when needed
|
// Lazy load: Import conclusion API only when needed
|
||||||
const { getConclusion } = await import('@/services/conclusionApi');
|
const { getConclusion } = await import('@/services/conclusionApi');
|
||||||
|
|
||||||
// API Call: Fetch existing conclusion
|
// API Call: Fetch existing conclusion (returns null if not found)
|
||||||
const result = await getConclusion(request.requestId || requestIdentifier);
|
const result = await getConclusion(request.requestId || requestIdentifier);
|
||||||
|
|
||||||
if (result && result.aiGeneratedRemark) {
|
if (result && (result.aiGeneratedRemark || result.finalRemark)) {
|
||||||
// Load: Set the AI-generated or final remark
|
// Load: Set the AI-generated or final remark
|
||||||
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark);
|
// Handle null values by providing empty string fallback
|
||||||
|
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark || '');
|
||||||
setAiGenerated(!!result.aiGeneratedRemark);
|
setAiGenerated(!!result.aiGeneratedRemark);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Only log non-404 errors (404 is handled gracefully in API)
|
||||||
|
if ((err as any)?.response?.status !== 404) {
|
||||||
|
console.error('[useConclusionRemark] Error fetching conclusion:', err);
|
||||||
|
}
|
||||||
// No conclusion yet - this is expected for newly approved requests
|
// No conclusion yet - this is expected for newly approved requests
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -93,8 +125,12 @@ export function useConclusionRemark(
|
|||||||
* 5. Handle errors silently (user can type manually)
|
* 5. Handle errors silently (user can type manually)
|
||||||
*/
|
*/
|
||||||
const handleGenerateConclusion = async () => {
|
const handleGenerateConclusion = async () => {
|
||||||
|
// Safety check: Prevent generation if max attempts already reached
|
||||||
|
if (maxAttemptsReached) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setConclusionLoading(true);
|
setConclusionLoading(true);
|
||||||
|
setGenerationFailed(false);
|
||||||
|
|
||||||
// Lazy load: Import conclusion API
|
// Lazy load: Import conclusion API
|
||||||
const { generateConclusion } = await import('@/services/conclusionApi');
|
const { generateConclusion } = await import('@/services/conclusionApi');
|
||||||
@ -102,14 +138,74 @@ export function useConclusionRemark(
|
|||||||
// API Call: Generate AI conclusion based on request data
|
// API Call: Generate AI conclusion based on request data
|
||||||
const result = await generateConclusion(request.requestId || requestIdentifier);
|
const result = await generateConclusion(request.requestId || requestIdentifier);
|
||||||
|
|
||||||
|
const newAttempts = generationAttempts + 1;
|
||||||
|
setGenerationAttempts(newAttempts);
|
||||||
|
|
||||||
|
// Check for "unable to generate" or similar keywords in proper response
|
||||||
|
const isUnableToGenerate = !result?.aiGeneratedRemark ||
|
||||||
|
result.aiGeneratedRemark.toLowerCase().includes('unable to generate') ||
|
||||||
|
result.aiGeneratedRemark.toLowerCase().includes('sorry');
|
||||||
|
|
||||||
|
if (isUnableToGenerate) {
|
||||||
|
const newFailures = failureAttempts + 1;
|
||||||
|
setFailureAttempts(newFailures);
|
||||||
|
|
||||||
|
if (newFailures >= 2) {
|
||||||
|
setMaxAttemptsReached(true);
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'AI Generation Limit Reached',
|
||||||
|
message: "We're unable to process a conclusion remark at this time after 2 attempts. Please proceed with a manual approach using the editor below."
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'System Note',
|
||||||
|
message: "We're unable to process a conclusion remark at the moment. You have one more attempt remaining, or you can proceed manually."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
setConclusionRemark(result?.aiGeneratedRemark || '');
|
||||||
|
setAiGenerated(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Success: Load AI-generated remark
|
// Success: Load AI-generated remark
|
||||||
setConclusionRemark(result.aiGeneratedRemark);
|
setConclusionRemark(result.aiGeneratedRemark);
|
||||||
setAiGenerated(true);
|
setAiGenerated(true);
|
||||||
|
setFailureAttempts(0); // Reset failures on success
|
||||||
|
|
||||||
|
// Limit to 2 successful attempts
|
||||||
|
if (newAttempts >= 2) {
|
||||||
|
setMaxAttemptsReached(true);
|
||||||
|
setActionStatus?.({
|
||||||
|
success: true,
|
||||||
|
title: 'Maximum Attempts Reached',
|
||||||
|
message: "You've reached the maximum of 2 regeneration attempts. Feel free to manually edit the current suggestion to fit your specific needs."
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Fail silently: User can write conclusion manually
|
|
||||||
console.error('[useConclusionRemark] AI generation failed:', err);
|
console.error('[useConclusionRemark] AI generation failed:', err);
|
||||||
setConclusionRemark('');
|
const newFailures = failureAttempts + 1;
|
||||||
|
setFailureAttempts(newFailures);
|
||||||
setAiGenerated(false);
|
setAiGenerated(false);
|
||||||
|
|
||||||
|
if (newFailures >= 2) {
|
||||||
|
setMaxAttemptsReached(true);
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'System Note',
|
||||||
|
message: "We're unable to process your request at the moment. Since the maximum of 2 attempts is reached, please proceed with a manual approach."
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'System Note',
|
||||||
|
message: "We're unable to process your request at the moment. You have one more attempt remaining, or you can proceed manually."
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
} finally {
|
} finally {
|
||||||
setConclusionLoading(false);
|
setConclusionLoading(false);
|
||||||
}
|
}
|
||||||
@ -218,16 +314,36 @@ export function useConclusionRemark(
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Effect: Auto-fetch existing conclusion when request becomes approved or rejected
|
* Effect: Auto-load existing conclusion when request becomes approved, rejected, or closed
|
||||||
*
|
*
|
||||||
* Trigger: When request status changes to "approved" or "rejected" and user is initiator
|
* Trigger: When request status changes to "approved", "rejected", or "closed" and user is initiator
|
||||||
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
|
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
|
||||||
|
*
|
||||||
|
* Optimization:
|
||||||
|
* 1. First check if conclusion data is already in request object (no API call needed)
|
||||||
|
* 2. Only fetch from API if not available in request object
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) {
|
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
|
||||||
fetchExistingConclusion();
|
fetchExistingConclusion();
|
||||||
}
|
}
|
||||||
}, [request?.status, isInitiator]);
|
}, [request?.status, request?.conclusionRemark, request?.aiGeneratedConclusion, isInitiator, conclusionRemark]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
conclusionRemark,
|
conclusionRemark,
|
||||||
@ -236,7 +352,10 @@ export function useConclusionRemark(
|
|||||||
conclusionSubmitting,
|
conclusionSubmitting,
|
||||||
aiGenerated,
|
aiGenerated,
|
||||||
handleGenerateConclusion,
|
handleGenerateConclusion,
|
||||||
handleFinalizeConclusion
|
handleFinalizeConclusion,
|
||||||
|
generationAttempts,
|
||||||
|
generationFailed,
|
||||||
|
maxAttemptsReached
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export interface RequestTemplate {
|
|||||||
icon: React.ComponentType<any>;
|
icon: React.ComponentType<any>;
|
||||||
estimatedTime: string;
|
estimatedTime: string;
|
||||||
commonApprovers: string[];
|
commonApprovers: string[];
|
||||||
|
workflowApprovers?: any[]; // Full approver objects for Admin Templates
|
||||||
suggestedSLA: number;
|
suggestedSLA: number;
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
fields: {
|
fields: {
|
||||||
@ -162,9 +163,9 @@ export function useCreateRequestForm(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Load system policy
|
// Load system policy
|
||||||
const workflowConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
const systemSettingsConfigs = await getPublicConfigurations('SYSTEM_SETTINGS');
|
||||||
const tatConfigs = await getPublicConfigurations('TAT_SETTINGS');
|
const workflowSharingConfigs = await getPublicConfigurations('WORKFLOW_SHARING');
|
||||||
const allConfigs = [...workflowConfigs, ...tatConfigs];
|
const allConfigs = [...systemSettingsConfigs, ...workflowSharingConfigs];
|
||||||
const configMap: Record<string, string> = {};
|
const configMap: Record<string, string> = {};
|
||||||
allConfigs.forEach((c: AdminConfiguration) => {
|
allConfigs.forEach((c: AdminConfiguration) => {
|
||||||
configMap[c.configKey] = c.configValue;
|
configMap[c.configKey] = c.configValue;
|
||||||
@ -199,7 +200,7 @@ export function useCreateRequestForm(
|
|||||||
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
||||||
const participants = Array.isArray(details.participants) ? details.participants : [];
|
const participants = Array.isArray(details.participants) ? details.participants : [];
|
||||||
const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : [];
|
const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : [];
|
||||||
|
|
||||||
// Store existing documents for tracking
|
// Store existing documents for tracking
|
||||||
setExistingDocuments(documents);
|
setExistingDocuments(documents);
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
|||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
import { getPublicConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { handleSecurityError } from '@/utils/securityToast';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Hook: useDocumentUpload
|
* Custom Hook: useDocumentUpload
|
||||||
@ -26,7 +27,7 @@ export function useDocumentUpload(
|
|||||||
) {
|
) {
|
||||||
// State: Indicates if document is currently being uploaded
|
// State: Indicates if document is currently being uploaded
|
||||||
const [uploadingDocument, setUploadingDocument] = useState(false);
|
const [uploadingDocument, setUploadingDocument] = useState(false);
|
||||||
|
|
||||||
// State: Stores document for preview modal
|
// State: Stores document for preview modal
|
||||||
const [previewDocument, setPreviewDocument] = useState<{
|
const [previewDocument, setPreviewDocument] = useState<{
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@ -101,7 +102,7 @@ export function useDocumentUpload(
|
|||||||
// Check file extension
|
// Check file extension
|
||||||
const fileName = file.name.toLowerCase();
|
const fileName = file.name.toLowerCase();
|
||||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
@ -130,12 +131,12 @@ export function useDocumentUpload(
|
|||||||
*/
|
*/
|
||||||
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = event.target.files;
|
const files = event.target.files;
|
||||||
|
|
||||||
// Validate: Check if file is selected
|
// Validate: Check if file is selected
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
const fileArray = Array.from(files);
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
// Validate all files against document policy
|
// Validate all files against document policy
|
||||||
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
const validFiles: File[] = [];
|
const validFiles: File[] = [];
|
||||||
@ -169,11 +170,11 @@ export function useDocumentUpload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
setUploadingDocument(true);
|
setUploadingDocument(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Upload only the first valid file (backend currently supports single file)
|
// Upload only the first valid file (backend currently supports single file)
|
||||||
const file = validFiles[0];
|
const file = validFiles[0];
|
||||||
|
|
||||||
// Validate: Ensure request ID is available
|
// Validate: Ensure request ID is available
|
||||||
// Note: Backend requires UUID, not request number
|
// Note: Backend requires UUID, not request number
|
||||||
const requestId = apiRequest?.requestId;
|
const requestId = apiRequest?.requestId;
|
||||||
@ -181,17 +182,17 @@ export function useDocumentUpload(
|
|||||||
toast.error('Request ID not found');
|
toast.error('Request ID not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// API Call: Upload document to backend
|
// API Call: Upload document to backend
|
||||||
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
|
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
|
||||||
if (file) {
|
if (file) {
|
||||||
await uploadDocument(file, requestId, 'SUPPORTING');
|
await uploadDocument(file, requestId, 'SUPPORTING');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh: Reload request details to show newly uploaded document
|
// Refresh: Reload request details to show newly uploaded document
|
||||||
// This also updates the activity timeline
|
// This also updates the activity timeline
|
||||||
await refreshDetails();
|
await refreshDetails();
|
||||||
|
|
||||||
// Success feedback
|
// Success feedback
|
||||||
if (validFiles.length < fileArray.length) {
|
if (validFiles.length < fileArray.length) {
|
||||||
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
||||||
@ -200,12 +201,14 @@ export function useDocumentUpload(
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[useDocumentUpload] Upload error:', error);
|
console.error('[useDocumentUpload] Upload error:', error);
|
||||||
|
|
||||||
// Error feedback with backend error message if available
|
// Show security-specific red toast for scan errors, or generic error toast
|
||||||
toast.error(error?.response?.data?.error || 'Failed to upload document');
|
if (!handleSecurityError(error)) {
|
||||||
|
toast.error(error?.response?.data?.message || 'Failed to upload document');
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDocument(false);
|
setUploadingDocument(false);
|
||||||
|
|
||||||
// Cleanup: Clear the file input to allow re-uploading same file
|
// Cleanup: Clear the file input to allow re-uploading same file
|
||||||
if (event.target) {
|
if (event.target) {
|
||||||
event.target.value = '';
|
event.target.value = '';
|
||||||
|
|||||||
@ -85,6 +85,10 @@ export function useModalManager(
|
|||||||
// API Call: Submit approval
|
// API Call: Submit approval
|
||||||
await approveLevel(requestIdentifier, levelId, description || '');
|
await approveLevel(requestIdentifier, levelId, description || '');
|
||||||
|
|
||||||
|
// Small delay to ensure backend has fully processed the approval and updated the status
|
||||||
|
// This is especially important for additional approvers where the workflow moves to the next step
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|
||||||
// Refresh: Update UI with new approval status
|
// Refresh: Update UI with new approval status
|
||||||
await refreshDetails();
|
await refreshDetails();
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
import workflowApi, { getPauseDetails } from '@/services/workflowApi';
|
||||||
import apiClient from '@/services/authApi';
|
import apiClient from '@/services/authApi';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
|
||||||
import { getSocket } from '@/utils/socket';
|
import { getSocket } from '@/utils/socket';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,19 +28,19 @@ export function useRequestDetails(
|
|||||||
) {
|
) {
|
||||||
// State: Stores the fetched and transformed request data
|
// State: Stores the fetched and transformed request data
|
||||||
const [apiRequest, setApiRequest] = useState<any | null>(null);
|
const [apiRequest, setApiRequest] = useState<any | null>(null);
|
||||||
|
|
||||||
// State: Indicates if data is currently being fetched
|
// State: Indicates if data is currently being fetched
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
// State: Loading state for initial fetch
|
// State: Loading state for initial fetch
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// State: Access denied information
|
// State: Access denied information
|
||||||
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
|
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
|
||||||
|
|
||||||
// State: Stores the current approval level for the logged-in user
|
// State: Stores the current approval level for the logged-in user
|
||||||
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
|
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
|
||||||
|
|
||||||
// State: Indicates if the current user is a spectator (view-only access)
|
// State: Indicates if the current user is a spectator (view-only access)
|
||||||
const [isSpectator, setIsSpectator] = useState(false);
|
const [isSpectator, setIsSpectator] = useState(false);
|
||||||
|
|
||||||
@ -103,14 +101,14 @@ export function useRequestDetails(
|
|||||||
const documents = Array.isArray(details.documents) ? details.documents : [];
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
// Debug: Log TAT alerts for monitoring
|
// Debug: Log TAT alerts for monitoring
|
||||||
if (tatAlerts.length > 0) {
|
if (tatAlerts.length > 0) {
|
||||||
// TAT alerts loaded - logging removed
|
// TAT alerts loaded - logging removed
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform: Map approval levels to UI format with TAT alerts
|
* Transform: Map approval levels to UI format with TAT alerts
|
||||||
* Each approval level includes:
|
* Each approval level includes:
|
||||||
@ -123,10 +121,10 @@ export function useRequestDetails(
|
|||||||
const levelNumber = a.levelNumber || 0;
|
const levelNumber = a.levelNumber || 0;
|
||||||
const levelStatus = (a.status || '').toString().toUpperCase();
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
const levelId = a.levelId || a.level_id;
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
// Determine display status based on workflow progress
|
// Determine display status based on workflow progress
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
// Future levels that haven't been reached yet show as "waiting"
|
// Future levels that haven't been reached yet show as "waiting"
|
||||||
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
||||||
displayStatus = 'waiting';
|
displayStatus = 'waiting';
|
||||||
@ -135,10 +133,10 @@ export function useRequestDetails(
|
|||||||
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
||||||
displayStatus = 'pending';
|
displayStatus = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter: Get TAT alerts that belong to this specific approval level
|
// Filter: Get TAT alerts that belong to this specific approval level
|
||||||
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: levelNumber,
|
step: levelNumber,
|
||||||
levelId,
|
levelId,
|
||||||
@ -152,8 +150,8 @@ export function useRequestDetails(
|
|||||||
remainingHours: Number(a.remainingHours || 0),
|
remainingHours: Number(a.remainingHours || 0),
|
||||||
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
// Calculate actual hours taken if level is completed
|
// Calculate actual hours taken if level is completed
|
||||||
actualHours: a.levelEndTime && a.levelStartTime
|
actualHours: a.levelEndTime && a.levelStartTime
|
||||||
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
|
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
|
||||||
: undefined,
|
: undefined,
|
||||||
comment: a.comments || undefined,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
@ -211,23 +209,26 @@ export function useRequestDetails(
|
|||||||
* Filter: Remove TAT breach activities from audit trail
|
* Filter: Remove TAT breach activities from audit trail
|
||||||
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
|
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
|
||||||
*/
|
*/
|
||||||
const filteredActivities = Array.isArray(details.activities)
|
const filteredActivities = Array.isArray(details.activities)
|
||||||
? details.activities.filter((activity: any) => {
|
? details.activities.filter((activity: any) => {
|
||||||
const activityType = (activity.type || '').toLowerCase();
|
const activityType = (activity.type || '').toLowerCase();
|
||||||
return activityType !== 'sla_warning';
|
return activityType !== 'sla_warning';
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch: Get pause details if request is paused
|
* Fetch: Get pause details only if request is actually paused
|
||||||
* This is needed to show resume/retrigger buttons correctly
|
* Use request-level isPaused field from workflow response
|
||||||
*/
|
*/
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
try {
|
const isPaused = (wf as any).isPaused || false;
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
|
||||||
} catch (error) {
|
if (isPaused) {
|
||||||
// Pause info not available or request not paused - ignore
|
try {
|
||||||
console.debug('Pause details not available:', error);
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
|
} catch (error) {
|
||||||
|
// Pause info not available - ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -237,39 +238,26 @@ export function useRequestDetails(
|
|||||||
let proposalDetails = null;
|
let proposalDetails = null;
|
||||||
let completionDetails = null;
|
let completionDetails = null;
|
||||||
let internalOrder = null;
|
let internalOrder = null;
|
||||||
|
let internalOrders = [];
|
||||||
|
|
||||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
||||||
try {
|
try {
|
||||||
console.debug('[useRequestDetails] Fetching claim details for requestId:', wf.requestId);
|
|
||||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
||||||
console.debug('[useRequestDetails] Claim API response:', {
|
|
||||||
status: claimResponse.status,
|
|
||||||
hasData: !!claimResponse.data,
|
|
||||||
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
|
|
||||||
fullResponse: claimResponse.data,
|
|
||||||
});
|
|
||||||
|
|
||||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
const claimData = claimResponse.data?.data || claimResponse.data;
|
||||||
console.debug('[useRequestDetails] Extracted claimData:', {
|
|
||||||
hasClaimData: !!claimData,
|
|
||||||
claimDataKeys: claimData ? Object.keys(claimData) : [],
|
|
||||||
hasClaimDetails: !!(claimData?.claimDetails || claimData?.claim_details),
|
|
||||||
hasProposalDetails: !!(claimData?.proposalDetails || claimData?.proposal_details),
|
|
||||||
hasCompletionDetails: !!(claimData?.completionDetails || claimData?.completion_details),
|
|
||||||
hasInternalOrder: !!(claimData?.internalOrder || claimData?.internal_order),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (claimData) {
|
if (claimData) {
|
||||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
claimDetails = claimData.claimDetails || claimData.claim_details;
|
||||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
||||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
completionDetails = claimData.completionDetails || claimData.completion_details;
|
||||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
||||||
|
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
|
||||||
// New normalized tables
|
// New normalized tables
|
||||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
||||||
const invoice = claimData.invoice || null;
|
const invoice = claimData.invoice || null;
|
||||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
||||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
||||||
|
|
||||||
// Store new fields in claimDetails for backward compatibility and easy access
|
// Store new fields in claimDetails for backward compatibility and easy access
|
||||||
if (claimDetails) {
|
if (claimDetails) {
|
||||||
(claimDetails as any).budgetTracking = budgetTracking;
|
(claimDetails as any).budgetTracking = budgetTracking;
|
||||||
@ -277,25 +265,8 @@ export function useRequestDetails(
|
|||||||
(claimDetails as any).creditNote = creditNote;
|
(claimDetails as any).creditNote = creditNote;
|
||||||
(claimDetails as any).completionExpenses = completionExpenses;
|
(claimDetails as any).completionExpenses = completionExpenses;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('[useRequestDetails] Extracted details:', {
|
// Extracted details processed
|
||||||
claimDetails: claimDetails ? {
|
|
||||||
hasActivityName: !!(claimDetails.activityName || claimDetails.activity_name),
|
|
||||||
hasActivityType: !!(claimDetails.activityType || claimDetails.activity_type),
|
|
||||||
hasLocation: !!(claimDetails.location),
|
|
||||||
activityName: claimDetails.activityName || claimDetails.activity_name,
|
|
||||||
activityType: claimDetails.activityType || claimDetails.activity_type,
|
|
||||||
location: claimDetails.location,
|
|
||||||
allKeys: Object.keys(claimDetails),
|
|
||||||
} : null,
|
|
||||||
hasProposalDetails: !!proposalDetails,
|
|
||||||
hasCompletionDetails: !!completionDetails,
|
|
||||||
hasInternalOrder: !!internalOrder,
|
|
||||||
hasBudgetTracking: !!budgetTracking,
|
|
||||||
hasInvoice: !!invoice,
|
|
||||||
hasCreditNote: !!creditNote,
|
|
||||||
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
console.warn('[useRequestDetails] No claimData found in response');
|
console.warn('[useRequestDetails] No claimData found in response');
|
||||||
}
|
}
|
||||||
@ -357,13 +328,14 @@ export function useRequestDetails(
|
|||||||
proposalDetails: proposalDetails || null,
|
proposalDetails: proposalDetails || null,
|
||||||
completionDetails: completionDetails || null,
|
completionDetails: completionDetails || null,
|
||||||
internalOrder: internalOrder || null,
|
internalOrder: internalOrder || null,
|
||||||
|
internalOrders: internalOrders || [],
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||||
invoice: (claimDetails as any)?.invoice || null,
|
invoice: (claimDetails as any)?.invoice || null,
|
||||||
creditNote: (claimDetails as any)?.creditNote || null,
|
creditNote: (claimDetails as any)?.creditNote || null,
|
||||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(updatedRequest);
|
setApiRequest(updatedRequest);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -381,8 +353,8 @@ export function useRequestDetails(
|
|||||||
const approvalLevelNumber = a.levelNumber || 0;
|
const approvalLevelNumber = a.levelNumber || 0;
|
||||||
// Only show buttons if user is assigned to the CURRENT active level
|
// Only show buttons if user is assigned to the CURRENT active level
|
||||||
// Include PAUSED status - paused level is still the current level
|
// Include PAUSED status - paused level is still the current level
|
||||||
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
|
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
|
||||||
&& approverEmail === userEmail
|
&& approverEmail === userEmail
|
||||||
&& approvalLevelNumber === currentLevel;
|
&& approvalLevelNumber === currentLevel;
|
||||||
});
|
});
|
||||||
setCurrentApprovalLevel(newCurrentLevel || null);
|
setCurrentApprovalLevel(newCurrentLevel || null);
|
||||||
@ -393,8 +365,8 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
const viewerId = (user as any)?.userId;
|
const viewerId = (user as any)?.userId;
|
||||||
if (viewerId) {
|
if (viewerId) {
|
||||||
const isSpec = participants.some((p: any) =>
|
const isSpec = participants.some((p: any) =>
|
||||||
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
|
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
|
||||||
(p.userId || p.user_id) === viewerId
|
(p.userId || p.user_id) === viewerId
|
||||||
);
|
);
|
||||||
setIsSpectator(isSpec);
|
setIsSpectator(isSpec);
|
||||||
@ -418,11 +390,11 @@ export function useRequestDetails(
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setAccessDenied(null);
|
setAccessDenied(null);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
@ -430,7 +402,7 @@ export function useRequestDetails(
|
|||||||
if (mounted) setLoading(false);
|
if (mounted) setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the same transformation logic as refreshDetails
|
// Use the same transformation logic as refreshDetails
|
||||||
const wf = details.workflow || {};
|
const wf = details.workflow || {};
|
||||||
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
||||||
@ -438,7 +410,7 @@ export function useRequestDetails(
|
|||||||
const documents = Array.isArray(details.documents) ? details.documents : [];
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
// TAT alerts received - logging removed
|
// TAT alerts received - logging removed
|
||||||
|
|
||||||
const priority = (wf.priority || '').toString().toLowerCase();
|
const priority = (wf.priority || '').toString().toLowerCase();
|
||||||
@ -449,9 +421,9 @@ export function useRequestDetails(
|
|||||||
const levelNumber = a.levelNumber || 0;
|
const levelNumber = a.levelNumber || 0;
|
||||||
const levelStatus = (a.status || '').toString().toUpperCase();
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
const levelId = a.levelId || a.level_id;
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
// If paused, show paused status (don't change it)
|
// If paused, show paused status (don't change it)
|
||||||
if (levelStatus === 'PAUSED') {
|
if (levelStatus === 'PAUSED') {
|
||||||
displayStatus = 'paused';
|
displayStatus = 'paused';
|
||||||
@ -460,9 +432,9 @@ export function useRequestDetails(
|
|||||||
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
|
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
|
||||||
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
|
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: levelNumber,
|
step: levelNumber,
|
||||||
levelId,
|
levelId,
|
||||||
@ -477,8 +449,8 @@ export function useRequestDetails(
|
|||||||
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
// Use backend-calculated elapsedHours (working hours) for completed approvals
|
// Use backend-calculated elapsedHours (working hours) for completed approvals
|
||||||
// Backend already calculates this correctly using calculateElapsedWorkingHours
|
// Backend already calculates this correctly using calculateElapsedWorkingHours
|
||||||
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
|
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
|
||||||
? Number(a.elapsedHours)
|
? Number(a.elapsedHours)
|
||||||
: undefined,
|
: undefined,
|
||||||
comment: a.comments || undefined,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
@ -486,7 +458,7 @@ export function useRequestDetails(
|
|||||||
tatAlerts: levelAlerts,
|
tatAlerts: levelAlerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map spectators
|
// Map spectators
|
||||||
const spectators = participants
|
const spectators = participants
|
||||||
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
|
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
|
||||||
@ -521,20 +493,24 @@ export function useRequestDetails(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Filter out TAT warnings from activities
|
// Filter out TAT warnings from activities
|
||||||
const filteredActivities = Array.isArray(details.activities)
|
const filteredActivities = Array.isArray(details.activities)
|
||||||
? details.activities.filter((activity: any) => {
|
? details.activities.filter((activity: any) => {
|
||||||
const activityType = (activity.type || '').toLowerCase();
|
const activityType = (activity.type || '').toLowerCase();
|
||||||
return activityType !== 'sla_warning';
|
return activityType !== 'sla_warning';
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Fetch pause details
|
// Fetch pause details only if request is actually paused
|
||||||
|
// Use request-level isPaused field from workflow response
|
||||||
let pauseInfo = null;
|
let pauseInfo = null;
|
||||||
try {
|
const isPaused = (wf as any).isPaused || false;
|
||||||
pauseInfo = await getPauseDetails(wf.requestId);
|
|
||||||
} catch (error) {
|
if (isPaused) {
|
||||||
// Pause info not available or request not paused - ignore
|
try {
|
||||||
console.debug('Pause details not available:', error);
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
|
} catch (error) {
|
||||||
|
// Pause info not available - ignore
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -544,29 +520,25 @@ export function useRequestDetails(
|
|||||||
let proposalDetails = null;
|
let proposalDetails = null;
|
||||||
let completionDetails = null;
|
let completionDetails = null;
|
||||||
let internalOrder = null;
|
let internalOrder = null;
|
||||||
|
let internalOrders = [];
|
||||||
|
|
||||||
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
|
||||||
try {
|
try {
|
||||||
console.debug('[useRequestDetails] Initial load - Fetching claim details for requestId:', wf.requestId);
|
|
||||||
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
|
||||||
console.debug('[useRequestDetails] Initial load - Claim API response:', {
|
|
||||||
status: claimResponse.status,
|
|
||||||
hasData: !!claimResponse.data,
|
|
||||||
dataKeys: claimResponse.data ? Object.keys(claimResponse.data) : [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const claimData = claimResponse.data?.data || claimResponse.data;
|
const claimData = claimResponse.data?.data || claimResponse.data;
|
||||||
if (claimData) {
|
if (claimData) {
|
||||||
claimDetails = claimData.claimDetails || claimData.claim_details;
|
claimDetails = claimData.claimDetails || claimData.claim_details;
|
||||||
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
|
||||||
completionDetails = claimData.completionDetails || claimData.completion_details;
|
completionDetails = claimData.completionDetails || claimData.completion_details;
|
||||||
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
internalOrder = claimData.internalOrder || claimData.internal_order || null;
|
||||||
|
internalOrders = claimData.internalOrders || claimData.internal_orders || [];
|
||||||
// New normalized tables
|
// New normalized tables
|
||||||
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
const budgetTracking = claimData.budgetTracking || claimData.budget_tracking || null;
|
||||||
const invoice = claimData.invoice || null;
|
const invoice = claimData.invoice || null;
|
||||||
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
const creditNote = claimData.creditNote || claimData.credit_note || null;
|
||||||
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
|
||||||
|
|
||||||
// Store new fields in claimDetails for backward compatibility and easy access
|
// Store new fields in claimDetails for backward compatibility and easy access
|
||||||
if (claimDetails) {
|
if (claimDetails) {
|
||||||
(claimDetails as any).budgetTracking = budgetTracking;
|
(claimDetails as any).budgetTracking = budgetTracking;
|
||||||
@ -574,18 +546,8 @@ export function useRequestDetails(
|
|||||||
(claimDetails as any).creditNote = creditNote;
|
(claimDetails as any).creditNote = creditNote;
|
||||||
(claimDetails as any).completionExpenses = completionExpenses;
|
(claimDetails as any).completionExpenses = completionExpenses;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug('[useRequestDetails] Initial load - Extracted details:', {
|
// Initial load - Extracted details processed
|
||||||
hasClaimDetails: !!claimDetails,
|
|
||||||
claimDetailsKeys: claimDetails ? Object.keys(claimDetails) : [],
|
|
||||||
hasProposalDetails: !!proposalDetails,
|
|
||||||
hasCompletionDetails: !!completionDetails,
|
|
||||||
hasInternalOrder: !!internalOrder,
|
|
||||||
hasBudgetTracking: !!budgetTracking,
|
|
||||||
hasInvoice: !!invoice,
|
|
||||||
hasCreditNote: !!creditNote,
|
|
||||||
hasCompletionExpenses: Array.isArray(completionExpenses) && completionExpenses.length > 0,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// Claim details not available - request might not be fully initialized yet
|
// Claim details not available - request might not be fully initialized yet
|
||||||
@ -634,15 +596,16 @@ export function useRequestDetails(
|
|||||||
proposalDetails: proposalDetails || null,
|
proposalDetails: proposalDetails || null,
|
||||||
completionDetails: completionDetails || null,
|
completionDetails: completionDetails || null,
|
||||||
internalOrder: internalOrder || null,
|
internalOrder: internalOrder || null,
|
||||||
|
internalOrders: internalOrders || [],
|
||||||
// New normalized tables (also available via claimDetails for backward compatibility)
|
// New normalized tables (also available via claimDetails for backward compatibility)
|
||||||
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
budgetTracking: (claimDetails as any)?.budgetTracking || null,
|
||||||
invoice: (claimDetails as any)?.invoice || null,
|
invoice: (claimDetails as any)?.invoice || null,
|
||||||
creditNote: (claimDetails as any)?.creditNote || null,
|
creditNote: (claimDetails as any)?.creditNote || null,
|
||||||
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
completionExpenses: (claimDetails as any)?.completionExpenses || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(mapped);
|
setApiRequest(mapped);
|
||||||
|
|
||||||
// Find current user's approval level
|
// Find current user's approval level
|
||||||
// Only show approve/reject buttons if user is the CURRENT active approver
|
// Only show approve/reject buttons if user is the CURRENT active approver
|
||||||
// Include PAUSED status - when paused, the paused level is still the current level
|
// Include PAUSED status - when paused, the paused level is still the current level
|
||||||
@ -653,8 +616,8 @@ export function useRequestDetails(
|
|||||||
const approvalLevelNumber = a.levelNumber || 0;
|
const approvalLevelNumber = a.levelNumber || 0;
|
||||||
// Only show buttons if user is assigned to the CURRENT active level
|
// Only show buttons if user is assigned to the CURRENT active level
|
||||||
// Include PAUSED status - paused level is still the current level
|
// Include PAUSED status - paused level is still the current level
|
||||||
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
|
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
|
||||||
&& approverEmail === userEmail
|
&& approverEmail === userEmail
|
||||||
&& approvalLevelNumber === currentLevel;
|
&& approvalLevelNumber === currentLevel;
|
||||||
});
|
});
|
||||||
setCurrentApprovalLevel(userCurrentLevel || null);
|
setCurrentApprovalLevel(userCurrentLevel || null);
|
||||||
@ -662,7 +625,7 @@ export function useRequestDetails(
|
|||||||
// Check spectator status
|
// Check spectator status
|
||||||
const viewerId = (user as any)?.userId;
|
const viewerId = (user as any)?.userId;
|
||||||
if (viewerId) {
|
if (viewerId) {
|
||||||
const isSpec = participants.some((p: any) =>
|
const isSpec = participants.some((p: any) =>
|
||||||
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
|
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
|
||||||
);
|
);
|
||||||
setIsSpectator(isSpec);
|
setIsSpectator(isSpec);
|
||||||
@ -674,7 +637,7 @@ export function useRequestDetails(
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Check for 403 Forbidden (Access Denied)
|
// Check for 403 Forbidden (Access Denied)
|
||||||
if (error?.response?.status === 403) {
|
if (error?.response?.status === 403) {
|
||||||
const message = error?.response?.data?.message ||
|
const message = error?.response?.data?.message ||
|
||||||
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
|
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
|
||||||
setAccessDenied({ denied: true, message });
|
setAccessDenied({ denied: true, message });
|
||||||
}
|
}
|
||||||
@ -686,34 +649,26 @@ export function useRequestDetails(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => { mounted = false; };
|
return () => { mounted = false; };
|
||||||
}, [requestIdentifier, user]);
|
}, [requestIdentifier, user]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed: Get final request object with fallback to static databases
|
* Computed: Get final request object with fallback to static databases
|
||||||
* Priority: API data → Custom DB → Claim DB → Dynamic props → null
|
* Priority: API data → Custom Database → Claim Database → Dynamic props → null
|
||||||
*/
|
*/
|
||||||
const request = useMemo(() => {
|
const request = useMemo(() => {
|
||||||
// Primary source: API data
|
// Primary source: API data
|
||||||
if (apiRequest) return apiRequest;
|
if (apiRequest) return apiRequest;
|
||||||
|
|
||||||
// Fallback 1: Static custom request database
|
// Fallback: Dynamic requests passed as props
|
||||||
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
|
const dynamicRequest = dynamicRequests.find((req: any) =>
|
||||||
if (customRequest) return customRequest;
|
req.id === requestIdentifier ||
|
||||||
|
|
||||||
// Fallback 2: Static claim management database
|
|
||||||
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
|
|
||||||
if (claimRequest) return claimRequest;
|
|
||||||
|
|
||||||
// Fallback 3: Dynamic requests passed as props
|
|
||||||
const dynamicRequest = dynamicRequests.find((req: any) =>
|
|
||||||
req.id === requestIdentifier ||
|
|
||||||
req.requestNumber === requestIdentifier ||
|
req.requestNumber === requestIdentifier ||
|
||||||
req.request_number === requestIdentifier
|
req.request_number === requestIdentifier
|
||||||
);
|
);
|
||||||
if (dynamicRequest) return dynamicRequest;
|
if (dynamicRequest) return dynamicRequest;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
||||||
|
|
||||||
@ -734,9 +689,9 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
const existingParticipants = useMemo(() => {
|
const existingParticipants = useMemo(() => {
|
||||||
if (!request) return [];
|
if (!request) return [];
|
||||||
|
|
||||||
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
|
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
|
||||||
|
|
||||||
// Add initiator
|
// Add initiator
|
||||||
if (request.initiator?.email) {
|
if (request.initiator?.email) {
|
||||||
participants.push({
|
participants.push({
|
||||||
@ -745,7 +700,7 @@ export function useRequestDetails(
|
|||||||
name: request.initiator.name
|
name: request.initiator.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add approvers from approval flow
|
// Add approvers from approval flow
|
||||||
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
|
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
|
||||||
request.approvalFlow.forEach((approval: any) => {
|
request.approvalFlow.forEach((approval: any) => {
|
||||||
@ -758,7 +713,7 @@ export function useRequestDetails(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add spectators
|
// Add spectators
|
||||||
if (request.spectators && Array.isArray(request.spectators)) {
|
if (request.spectators && Array.isArray(request.spectators)) {
|
||||||
request.spectators.forEach((spectator: any) => {
|
request.spectators.forEach((spectator: any) => {
|
||||||
@ -771,20 +726,20 @@ export function useRequestDetails(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add from participants array
|
// Add from participants array
|
||||||
if (request.participants && Array.isArray(request.participants)) {
|
if (request.participants && Array.isArray(request.participants)) {
|
||||||
request.participants.forEach((p: any) => {
|
request.participants.forEach((p: any) => {
|
||||||
const email = (p.userEmail || p.email || '').toLowerCase();
|
const email = (p.userEmail || p.email || '').toLowerCase();
|
||||||
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
|
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
|
||||||
const name = p.userName || p.user_name || p.name;
|
const name = p.userName || p.user_name || p.name;
|
||||||
|
|
||||||
if (email && participantType && !participants.find(x => x.email === email)) {
|
if (email && participantType && !participants.find(x => x.email === email)) {
|
||||||
participants.push({ email, participantType, name });
|
participants.push({ email, participantType, name });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return participants;
|
return participants;
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
@ -803,12 +758,12 @@ export function useRequestDetails(
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestIdentifier || !apiRequest) return;
|
if (!requestIdentifier || !apiRequest) return;
|
||||||
|
|
||||||
const socket = getSocket();
|
const socket = getSocket();
|
||||||
if (!socket) {
|
if (!socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler: Request updated by another user
|
* Handler: Request updated by another user
|
||||||
* Silently refresh to show latest changes
|
* Silently refresh to show latest changes
|
||||||
@ -820,10 +775,10 @@ export function useRequestDetails(
|
|||||||
refreshDetails();
|
refreshDetails();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Register listener
|
// Register listener
|
||||||
socket.on('request:updated', handleRequestUpdated);
|
socket.on('request:updated', handleRequestUpdated);
|
||||||
|
|
||||||
// Cleanup on unmount
|
// Cleanup on unmount
|
||||||
return () => {
|
return () => {
|
||||||
socket.off('request:updated', handleRequestUpdated);
|
socket.off('request:updated', handleRequestUpdated);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { AuthProvider } from './contexts/AuthContext';
|
|||||||
import { AuthenticatedApp } from './pages/Auth';
|
import { AuthenticatedApp } from './pages/Auth';
|
||||||
import { store } from './redux/store';
|
import { store } from './redux/store';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
import './styles/base-layout.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
165
src/pages/Admin/Templates/AdminTemplatesList.tsx
Normal file
165
src/pages/Admin/Templates/AdminTemplatesList.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
503
src/pages/Admin/Templates/CreateTemplate.tsx
Normal file
503
src/pages/Admin/Templates/CreateTemplate.tsx
Normal file
@ -0,0 +1,503 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,6 +11,7 @@ import { Pagination } from '@/components/common/Pagination';
|
|||||||
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig, getSLAConfig } from '../utils/configMappers';
|
||||||
import { formatDate, formatDateTime } from '../utils/formatters';
|
import { formatDate, formatDateTime } from '../utils/formatters';
|
||||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||||
|
import { navigateToRequest } from '@/utils/requestNavigation';
|
||||||
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
|
import type { ApproverPerformanceRequest } from '../types/approverPerformance.types';
|
||||||
|
|
||||||
interface ApproverPerformanceRequestListProps {
|
interface ApproverPerformanceRequestListProps {
|
||||||
@ -69,7 +70,6 @@ export function ApproverPerformanceRequestList({
|
|||||||
key={request.requestId}
|
key={request.requestId}
|
||||||
className="hover:shadow-md transition-shadow cursor-pointer"
|
className="hover:shadow-md transition-shadow cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const { navigateToRequest } = require('@/utils/requestNavigation');
|
|
||||||
navigateToRequest({
|
navigateToRequest({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
requestTitle: request.title,
|
requestTitle: request.title,
|
||||||
@ -166,7 +166,6 @@ export function ApproverPerformanceRequestList({
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const { navigateToRequest } = require('@/utils/requestNavigation');
|
|
||||||
navigateToRequest({
|
navigateToRequest({
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
requestTitle: request.title,
|
requestTitle: request.title,
|
||||||
|
|||||||
@ -1,22 +1,39 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn, Shield } from 'lucide-react';
|
||||||
import { ReLogo } from '@/assets';
|
import { ReLogo, LandingPageImage } from '@/assets';
|
||||||
|
import { initiateTanflowLogin } from '@/services/tanflowAuth';
|
||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const { login, isLoading, error } = useAuth();
|
const { login, isLoading, error } = useAuth();
|
||||||
|
const [tanflowLoading, setTanflowLoading] = useState(false);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
|
||||||
const handleSSOLogin = async () => {
|
// 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 () => {
|
||||||
// Clear any existing session data
|
// Clear any existing session data
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login();
|
await login();
|
||||||
} catch (loginError) {
|
} catch (loginError) {
|
||||||
console.error('========================================');
|
console.error('========================================');
|
||||||
console.error('LOGIN ERROR');
|
console.error('OKTA LOGIN ERROR');
|
||||||
console.error('Error details:', loginError);
|
console.error('Error details:', loginError);
|
||||||
console.error('Error message:', (loginError as Error)?.message);
|
console.error('Error message:', (loginError as Error)?.message);
|
||||||
console.error('Error stack:', (loginError as Error)?.stack);
|
console.error('Error stack:', (loginError as Error)?.stack);
|
||||||
@ -24,59 +41,123 @@ export function Auth() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTanflowLogin = () => {
|
||||||
|
// Clear any existing session data
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
|
||||||
|
setTanflowLoading(true);
|
||||||
|
try {
|
||||||
|
initiateTanflowLogin();
|
||||||
|
} catch (loginError) {
|
||||||
|
console.error('========================================');
|
||||||
|
console.error('TANFLOW LOGIN ERROR');
|
||||||
|
console.error('Error details:', loginError);
|
||||||
|
setTanflowLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Auth0 Error in Auth Component:', {
|
console.error('Auth Error in Auth Component:', {
|
||||||
message: error.message,
|
message: error.message,
|
||||||
error: error
|
error: error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
<div
|
||||||
<Card className="w-full max-w-md shadow-xl">
|
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">
|
||||||
<CardHeader className="space-y-1 text-center pb-6">
|
<CardHeader className="space-y-1 text-center pb-6">
|
||||||
<div className="flex flex-col items-center justify-center mb-4">
|
<div className="flex flex-col items-center justify-center mb-4">
|
||||||
<img
|
<img
|
||||||
src={ReLogo}
|
src={ReLogo}
|
||||||
alt="Royal Enfield Logo"
|
alt="Royal Enfield Logo"
|
||||||
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
<p className="text-xs text-gray-300 text-center truncate">Approval Portal</p>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
<div className="bg-red-900/50 border border-red-700 text-red-200 px-4 py-3 rounded-lg">
|
||||||
<p className="text-sm font-medium">Authentication Error</p>
|
<p className="text-sm font-medium">Authentication Error</p>
|
||||||
<p className="text-sm">{error.message}</p>
|
<p className="text-sm">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSSOLogin}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
|
||||||
/>
|
|
||||||
Logging in...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<LogIn className="mr-2 h-5 w-5" />
|
|
||||||
SSO Login
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-500 mt-4">
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleOKTALogin}
|
||||||
|
disabled={isLoading || tanflowLoading}
|
||||||
|
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
|
/>
|
||||||
|
Logging in...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LogIn className="mr-2 h-5 w-5" />
|
||||||
|
RE Employee Login
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<span className="w-full border-t border-gray-700"></span>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
|
<span className="bg-gray-900 px-2 text-gray-400">Or</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={handleTanflowLogin}
|
||||||
|
disabled={isLoading || tanflowLoading}
|
||||||
|
className="w-full h-12 text-base font-semibold bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{tanflowLoading ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||||
|
/>
|
||||||
|
Redirecting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="mr-2 h-5 w-5" />
|
||||||
|
Dealer Login
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center text-sm text-gray-400 mt-4">
|
||||||
<p>Secure Single Sign-On</p>
|
<p>Secure Single Sign-On</p>
|
||||||
<p className="text-xs mt-1">Powered by Auth0</p>
|
<p className="text-xs mt-1 text-gray-500">Choose your authentication provider</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Auth } from './Auth';
|
import { Auth } from './Auth';
|
||||||
import { AuthCallback } from './AuthCallback';
|
import { AuthCallback } from './AuthCallback';
|
||||||
|
import { TanflowCallback } from './TanflowCallback';
|
||||||
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
|
import { AuthDebugInfo } from '@/components/Auth/AuthDebugInfo';
|
||||||
import App from '../../App';
|
import App from '../../App';
|
||||||
|
|
||||||
@ -10,7 +11,8 @@ export function AuthenticatedApp() {
|
|||||||
const [showDebugInfo, setShowDebugInfo] = useState(false);
|
const [showDebugInfo, setShowDebugInfo] = useState(false);
|
||||||
|
|
||||||
// Check if we're on callback route (after all hooks are called)
|
// Check if we're on callback route (after all hooks are called)
|
||||||
const isCallbackRoute = typeof window !== 'undefined' && window.location.pathname === '/login/callback';
|
const isCallbackRoute = typeof window !== 'undefined' &&
|
||||||
|
window.location.pathname === '/login/callback';
|
||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
@ -39,7 +41,35 @@ export function AuthenticatedApp() {
|
|||||||
}, [isAuthenticated, isLoading, error, user]);
|
}, [isAuthenticated, isLoading, error, user]);
|
||||||
|
|
||||||
// Always show callback loader when on callback route (after all hooks)
|
// Always show callback loader when on callback route (after all hooks)
|
||||||
|
// Detect provider from sessionStorage to show appropriate callback component
|
||||||
if (isCallbackRoute) {
|
if (isCallbackRoute) {
|
||||||
|
// Check if this is a logout redirect (no code, no error)
|
||||||
|
const urlParams = typeof window !== 'undefined' ? new URLSearchParams(window.location.search) : null;
|
||||||
|
const hasCode = urlParams?.get('code');
|
||||||
|
const hasError = urlParams?.get('error');
|
||||||
|
|
||||||
|
// If no code and no error, it's a logout redirect - redirect immediately
|
||||||
|
if (!hasCode && !hasError) {
|
||||||
|
console.log('🚪 AuthenticatedApp: Logout redirect detected, redirecting to home');
|
||||||
|
const logoutParams = new URLSearchParams();
|
||||||
|
logoutParams.set('tanflow_logged_out', 'true');
|
||||||
|
logoutParams.set('logout', Date.now().toString());
|
||||||
|
window.location.replace(`/?${logoutParams.toString()}`);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-slate-900 border-t-transparent mx-auto mb-4"></div>
|
||||||
|
<p className="text-gray-600">Redirecting...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authProvider = typeof window !== 'undefined' ? sessionStorage.getItem('auth_provider') : null;
|
||||||
|
if (authProvider === 'tanflow') {
|
||||||
|
return <TanflowCallback />;
|
||||||
|
}
|
||||||
|
// Default to OKTA callback (or if provider not set yet)
|
||||||
return <AuthCallback />;
|
return <AuthCallback />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
301
src/pages/Auth/TanflowCallback.tsx
Normal file
301
src/pages/Auth/TanflowCallback.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { useCallback, useRef, useEffect } from 'react';
|
import { useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
import { ClosedRequestsHeader } from './components/ClosedRequestsHeader';
|
||||||
import { ClosedRequestsFilters as ClosedRequestsFiltersComponent } from './components/ClosedRequestsFilters';
|
|
||||||
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
import { ClosedRequestsList } from './components/ClosedRequestsList';
|
||||||
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
import { ClosedRequestsEmpty } from './components/ClosedRequestsEmpty';
|
||||||
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
import { ClosedRequestsPagination } from './components/ClosedRequestsPagination';
|
||||||
@ -14,6 +13,11 @@ import { useClosedRequestsFilters } from './hooks/useClosedRequestsFilters';
|
|||||||
// Types
|
// Types
|
||||||
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
import type { ClosedRequestsProps } from './types/closedRequests.types';
|
||||||
|
|
||||||
|
// Utils & Factory
|
||||||
|
import { getUserFilterType } from '@/utils/userFilterUtils';
|
||||||
|
import { getClosedRequestsFilters } from '@/flows';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||||
// Data fetching hook
|
// Data fetching hook
|
||||||
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
const closedRequests = useClosedRequests({ itemsPerPage: 10 });
|
||||||
@ -23,10 +27,29 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
fetchRef.current = closedRequests.fetchRequests;
|
fetchRef.current = closedRequests.fetchRequests;
|
||||||
|
|
||||||
const filters = useClosedRequestsFilters();
|
const filters = useClosedRequestsFilters();
|
||||||
|
|
||||||
|
// Get user filter type and corresponding filter component (plug-and-play pattern)
|
||||||
|
const userFilterType = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
return getUserFilterType(userData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClosedRequests] Error getting user filter type:', error);
|
||||||
|
return 'STANDARD' as const;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get the appropriate filter component based on user type
|
||||||
|
const ClosedRequestsFiltersComponent = useMemo(() => {
|
||||||
|
return getClosedRequestsFilters(userFilterType);
|
||||||
|
}, [userFilterType]);
|
||||||
|
|
||||||
|
const isDealer = userFilterType === 'DEALER';
|
||||||
const prevFiltersRef = useRef({
|
const prevFiltersRef = useRef({
|
||||||
searchTerm: filters.searchTerm,
|
searchTerm: filters.searchTerm,
|
||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
priorityFilter: filters.priorityFilter,
|
priorityFilter: filters.priorityFilter,
|
||||||
|
templateTypeFilter: filters.templateTypeFilter,
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
@ -38,13 +61,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
fetchRef.current(storedPage, {
|
fetchRef.current(storedPage, {
|
||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
// Only include priority and templateType filters if user is not a dealer
|
||||||
sortBy: filters.sortBy,
|
priority: !isDealer && filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
sortOrder: filters.sortOrder,
|
templateType: !isDealer && filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
});
|
sortBy: filters.sortBy,
|
||||||
|
sortOrder: filters.sortOrder,
|
||||||
|
});
|
||||||
hasInitialFetchRun.current = true;
|
hasInitialFetchRun.current = true;
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []); // Only on mount
|
}, [isDealer]); // Re-fetch if dealer status changes
|
||||||
|
|
||||||
// Track filter changes and refetch
|
// Track filter changes and refetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -55,6 +80,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
prev.searchTerm !== filters.searchTerm ||
|
prev.searchTerm !== filters.searchTerm ||
|
||||||
prev.statusFilter !== filters.statusFilter ||
|
prev.statusFilter !== filters.statusFilter ||
|
||||||
prev.priorityFilter !== filters.priorityFilter ||
|
prev.priorityFilter !== filters.priorityFilter ||
|
||||||
|
prev.templateTypeFilter !== filters.templateTypeFilter ||
|
||||||
prev.sortBy !== filters.sortBy ||
|
prev.sortBy !== filters.sortBy ||
|
||||||
prev.sortOrder !== filters.sortOrder;
|
prev.sortOrder !== filters.sortOrder;
|
||||||
|
|
||||||
@ -67,15 +93,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update previous values
|
// Update previous values
|
||||||
prevFiltersRef.current = {
|
prevFiltersRef.current = {
|
||||||
searchTerm: filters.searchTerm,
|
searchTerm: filters.searchTerm,
|
||||||
statusFilter: filters.statusFilter,
|
statusFilter: filters.statusFilter,
|
||||||
priorityFilter: filters.priorityFilter,
|
priorityFilter: filters.priorityFilter,
|
||||||
|
templateTypeFilter: filters.templateTypeFilter,
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
};
|
};
|
||||||
@ -83,7 +111,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.sortBy, filters.sortOrder]);
|
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.sortBy, filters.sortOrder, isDealer]);
|
||||||
|
|
||||||
// Page change handler
|
// Page change handler
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
@ -94,6 +122,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
@ -108,6 +137,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
search: filters.searchTerm || undefined,
|
search: filters.searchTerm || undefined,
|
||||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||||
sortBy: filters.sortBy,
|
sortBy: filters.sortBy,
|
||||||
sortOrder: filters.sortOrder,
|
sortOrder: filters.sortOrder,
|
||||||
});
|
});
|
||||||
@ -123,17 +153,25 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters - Plug-and-play pattern */}
|
||||||
<ClosedRequestsFiltersComponent
|
<ClosedRequestsFiltersComponent
|
||||||
searchTerm={filters.searchTerm}
|
searchTerm={filters.searchTerm}
|
||||||
priorityFilter={filters.priorityFilter}
|
priorityFilter={filters.priorityFilter}
|
||||||
statusFilter={filters.statusFilter}
|
statusFilter={filters.statusFilter}
|
||||||
|
templateTypeFilter={filters.templateTypeFilter}
|
||||||
sortBy={filters.sortBy}
|
sortBy={filters.sortBy}
|
||||||
sortOrder={filters.sortOrder}
|
sortOrder={filters.sortOrder}
|
||||||
activeFiltersCount={filters.activeFiltersCount}
|
activeFiltersCount={
|
||||||
|
isDealer
|
||||||
|
? // For dealers: only count search and status (closure type)
|
||||||
|
[filters.searchTerm, filters.statusFilter !== 'all' ? filters.statusFilter : null].filter(Boolean).length
|
||||||
|
: // For standard users: count all filters
|
||||||
|
filters.activeFiltersCount
|
||||||
|
}
|
||||||
onSearchChange={filters.setSearchTerm}
|
onSearchChange={filters.setSearchTerm}
|
||||||
onPriorityChange={filters.setPriorityFilter}
|
onPriorityChange={filters.setPriorityFilter}
|
||||||
onStatusChange={filters.setStatusFilter}
|
onStatusChange={filters.setStatusFilter}
|
||||||
|
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
||||||
onSortByChange={filters.setSortBy}
|
onSortByChange={filters.setSortBy}
|
||||||
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
|
onSortOrderChange={() => filters.setSortOrder(filters.sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
onClearFilters={filters.clearFilters}
|
onClearFilters={filters.clearFilters}
|
||||||
|
|||||||
@ -67,11 +67,11 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
|||||||
const templateTypeUpper = templateType?.toUpperCase() || '';
|
const templateTypeUpper = templateType?.toUpperCase() || '';
|
||||||
|
|
||||||
// Direct mapping from templateType
|
// Direct mapping from templateType
|
||||||
let templateLabel = 'Custom';
|
let templateLabel = 'Non-Templatized';
|
||||||
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
|
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
|
||||||
|
|
||||||
if (templateTypeUpper === 'DEALER CLAIM') {
|
if (templateTypeUpper === 'DEALER CLAIM') {
|
||||||
templateLabel = 'Claim Management';
|
templateLabel = 'Dealer Claim';
|
||||||
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
|
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
|
||||||
} else if (templateTypeUpper === 'TEMPLATE') {
|
} else if (templateTypeUpper === 'TEMPLATE') {
|
||||||
templateLabel = 'Template';
|
templateLabel = 'Template';
|
||||||
|
|||||||
@ -12,12 +12,14 @@ interface ClosedRequestsFiltersProps {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
|
templateTypeFilter: string;
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
activeFiltersCount: number;
|
activeFiltersCount: number;
|
||||||
onSearchChange: (value: string) => void;
|
onSearchChange: (value: string) => void;
|
||||||
onPriorityChange: (value: string) => void;
|
onPriorityChange: (value: string) => void;
|
||||||
onStatusChange: (value: string) => void;
|
onStatusChange: (value: string) => void;
|
||||||
|
onTemplateTypeChange: (value: string) => void;
|
||||||
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
onSortByChange: (value: 'created' | 'due' | 'priority') => void;
|
||||||
onSortOrderChange: () => void;
|
onSortOrderChange: () => void;
|
||||||
onClearFilters: () => void;
|
onClearFilters: () => void;
|
||||||
@ -27,12 +29,14 @@ export function ClosedRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
|
onTemplateTypeChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -82,7 +86,7 @@ export function ClosedRequestsFilters({
|
|||||||
data-testid="closed-requests-search"
|
data-testid="closed-requests-search"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={onPriorityChange}>
|
<Select value={priorityFilter} onValueChange={onPriorityChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-priority-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-priority-filter">
|
||||||
<SelectValue placeholder="All Priorities" />
|
<SelectValue placeholder="All Priorities" />
|
||||||
@ -103,7 +107,7 @@ export function ClosedRequestsFilters({
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-status-filter">
|
||||||
<SelectValue placeholder="Closure Type" />
|
<SelectValue placeholder="Closure Type" />
|
||||||
@ -125,6 +129,17 @@ export function ClosedRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
||||||
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
||||||
|
<SelectValue placeholder="All Templates" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Templates</SelectItem>
|
||||||
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-sort-by">
|
||||||
@ -136,7 +151,7 @@ export function ClosedRequestsFilters({
|
|||||||
<SelectItem value="priority">Priority</SelectItem>
|
<SelectItem value="priority">Priority</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fetchRequests = useCallback(
|
const fetchRequests = useCallback(
|
||||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -51,6 +51,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
search: filters?.search,
|
search: filters?.search,
|
||||||
status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
|
status: filters?.status && filters.status !== 'all' ? filters.status : undefined,
|
||||||
priority: filters?.priority,
|
priority: filters?.priority,
|
||||||
|
templateType: filters?.templateType,
|
||||||
sortBy: filters?.sortBy,
|
sortBy: filters?.sortBy,
|
||||||
sortOrder: filters?.sortOrder
|
sortOrder: filters?.sortOrder
|
||||||
});
|
});
|
||||||
@ -90,7 +91,7 @@ export function useClosedRequests({ itemsPerPage = 10 }: UseClosedRequestsOption
|
|||||||
// Initial fetch removed - component handles initial fetch using Redux stored page
|
// Initial fetch removed - component handles initial fetch using Redux stored page
|
||||||
// This prevents duplicate fetches and allows page persistence
|
// This prevents duplicate fetches and allows page persistence
|
||||||
|
|
||||||
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
const handleRefresh = useCallback((filters?: { search?: string; status?: string; priority?: string; templateType?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(pagination.currentPage, filters);
|
fetchRequests(pagination.currentPage, filters);
|
||||||
}, [fetchRequests, pagination.currentPage]);
|
}, [fetchRequests, pagination.currentPage]);
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
setSearchTerm as setSearchTermAction,
|
setSearchTerm as setSearchTermAction,
|
||||||
setStatusFilter as setStatusFilterAction,
|
setStatusFilter as setStatusFilterAction,
|
||||||
setPriorityFilter as setPriorityFilterAction,
|
setPriorityFilter as setPriorityFilterAction,
|
||||||
|
setTemplateTypeFilter as setTemplateTypeFilterAction,
|
||||||
setSortBy as setSortByAction,
|
setSortBy as setSortByAction,
|
||||||
setSortOrder as setSortOrderAction,
|
setSortOrder as setSortOrderAction,
|
||||||
setCurrentPage as setCurrentPageAction,
|
setCurrentPage as setCurrentPageAction,
|
||||||
@ -26,12 +27,13 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
|
|
||||||
// Get filters from Redux
|
// Get filters from Redux
|
||||||
const { searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
|
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, currentPage } = useAppSelector((state) => state.closedRequests);
|
||||||
|
|
||||||
// Create setters that dispatch Redux actions
|
// Create setters that dispatch Redux actions
|
||||||
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||||
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
|
||||||
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||||
|
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
|
||||||
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
|
const setSortBy = useCallback((value: 'created' | 'due' | 'priority') => dispatch(setSortByAction(value)), [dispatch]);
|
||||||
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
|
const setSortOrder = useCallback((value: 'asc' | 'desc') => dispatch(setSortOrderAction(value)), [dispatch]);
|
||||||
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||||
@ -41,10 +43,11 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
search: searchTerm,
|
search: searchTerm,
|
||||||
status: statusFilter,
|
status: statusFilter,
|
||||||
priority: priorityFilter,
|
priority: priorityFilter,
|
||||||
|
templateType: templateTypeFilter !== 'all' ? templateTypeFilter : undefined,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder]);
|
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder]);
|
||||||
|
|
||||||
// Debounced filter change handler
|
// Debounced filter change handler
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -71,7 +74,7 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
clearTimeout(debounceTimeoutRef.current);
|
clearTimeout(debounceTimeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, sortBy, sortOrder, onFiltersChange, getFilters, debounceMs]);
|
||||||
|
|
||||||
const clearFilters = useCallback(() => {
|
const clearFilters = useCallback(() => {
|
||||||
dispatch(clearFiltersAction());
|
dispatch(clearFiltersAction());
|
||||||
@ -80,19 +83,22 @@ export function useClosedRequestsFilters({ onFiltersChange, debounceMs = 500 }:
|
|||||||
const activeFiltersCount = [
|
const activeFiltersCount = [
|
||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter !== 'all' ? priorityFilter : null,
|
priorityFilter !== 'all' ? priorityFilter : null,
|
||||||
statusFilter !== 'all' ? statusFilter : null
|
statusFilter !== 'all' ? statusFilter : null,
|
||||||
|
templateTypeFilter !== 'all' ? templateTypeFilter : null
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
currentPage,
|
currentPage,
|
||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
|
setTemplateTypeFilter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export interface ClosedRequestsFiltersState {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
statusFilter: string;
|
statusFilter: string;
|
||||||
priorityFilter: string;
|
priorityFilter: string;
|
||||||
|
templateTypeFilter: string;
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
@ -13,6 +14,7 @@ const initialState: ClosedRequestsFiltersState = {
|
|||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
statusFilter: 'all',
|
statusFilter: 'all',
|
||||||
priorityFilter: 'all',
|
priorityFilter: 'all',
|
||||||
|
templateTypeFilter: 'all',
|
||||||
sortBy: 'created',
|
sortBy: 'created',
|
||||||
sortOrder: 'desc',
|
sortOrder: 'desc',
|
||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
@ -31,6 +33,9 @@ const closedRequestsSlice = createSlice({
|
|||||||
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
setPriorityFilter: (state, action: PayloadAction<string>) => {
|
||||||
state.priorityFilter = action.payload;
|
state.priorityFilter = action.payload;
|
||||||
},
|
},
|
||||||
|
setTemplateTypeFilter: (state, action: PayloadAction<string>) => {
|
||||||
|
state.templateTypeFilter = action.payload;
|
||||||
|
},
|
||||||
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
|
setSortBy: (state, action: PayloadAction<'created' | 'due' | 'priority'>) => {
|
||||||
state.sortBy = action.payload;
|
state.sortBy = action.payload;
|
||||||
},
|
},
|
||||||
@ -44,6 +49,7 @@ const closedRequestsSlice = createSlice({
|
|||||||
state.searchTerm = '';
|
state.searchTerm = '';
|
||||||
state.statusFilter = 'all';
|
state.statusFilter = 'all';
|
||||||
state.priorityFilter = 'all';
|
state.priorityFilter = 'all';
|
||||||
|
state.templateTypeFilter = 'all';
|
||||||
state.currentPage = 1;
|
state.currentPage = 1;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -53,6 +59,7 @@ export const {
|
|||||||
setSearchTerm,
|
setSearchTerm,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
setPriorityFilter,
|
setPriorityFilter,
|
||||||
|
setTemplateTypeFilter,
|
||||||
setSortBy,
|
setSortBy,
|
||||||
setSortOrder,
|
setSortOrder,
|
||||||
setCurrentPage,
|
setCurrentPage,
|
||||||
|
|||||||
@ -28,6 +28,7 @@ export interface ClosedRequestsFilters {
|
|||||||
search: string;
|
search: string;
|
||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
|
templateType?: string;
|
||||||
sortBy: 'created' | 'due' | 'priority';
|
sortBy: 'created' | 'due' | 'priority';
|
||||||
sortOrder: 'asc' | 'desc';
|
sortOrder: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|||||||
232
src/pages/CreateAdminRequest/CreateAdminRequest.tsx
Normal file
232
src/pages/CreateAdminRequest/CreateAdminRequest.tsx
Normal file
@ -0,0 +1,232 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -13,7 +13,7 @@
|
|||||||
* - components/ - UI components
|
* - components/ - UI components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
||||||
@ -22,7 +22,7 @@ import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
|||||||
import { downloadDocument } from '@/services/workflowApi';
|
import { downloadDocument } from '@/services/workflowApi';
|
||||||
|
|
||||||
// Custom Hooks
|
// Custom Hooks
|
||||||
import { useCreateRequestForm } from '@/hooks/useCreateRequestForm';
|
import { useCreateRequestForm, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
import { useWizardNavigation } from '@/hooks/useWizardNavigation';
|
import { useWizardNavigation } from '@/hooks/useWizardNavigation';
|
||||||
import { useRequestModals } from './hooks/useRequestModals';
|
import { useRequestModals } from './hooks/useRequestModals';
|
||||||
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
|
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
|
||||||
@ -31,6 +31,10 @@ import { useCreateRequestHandlers } from './hooks/useCreateRequestHandlers';
|
|||||||
// Constants
|
// Constants
|
||||||
import { REQUEST_TEMPLATES } from './constants/requestTemplates';
|
import { REQUEST_TEMPLATES } from './constants/requestTemplates';
|
||||||
|
|
||||||
|
// Services
|
||||||
|
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
|
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
|
||||||
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
|
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
|
||||||
@ -64,6 +68,35 @@ export function CreateRequest({
|
|||||||
const isEditing = isEditMode && !!editRequestId;
|
const isEditing = isEditMode && !!editRequestId;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
const [adminTemplates, setAdminTemplates] = useState<RequestTemplate[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTemplates = async () => {
|
||||||
|
try {
|
||||||
|
const templates = await getTemplates();
|
||||||
|
const mappedTemplates: RequestTemplate[] = templates.map((t: BackendTemplate) => ({
|
||||||
|
id: t.id,
|
||||||
|
name: t.name,
|
||||||
|
description: t.description,
|
||||||
|
category: t.category,
|
||||||
|
icon: FileText,
|
||||||
|
estimatedTime: t.estimatedTime,
|
||||||
|
commonApprovers: t.approvers.map((a: any) => a.name),
|
||||||
|
workflowApprovers: t.approvers,
|
||||||
|
suggestedSLA: t.suggestedSLA,
|
||||||
|
priority: t.priority,
|
||||||
|
fields: t.fields || {}
|
||||||
|
}));
|
||||||
|
setAdminTemplates(mappedTemplates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch admin templates:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allTemplates = useMemo(() => [...REQUEST_TEMPLATES, ...adminTemplates], [adminTemplates]);
|
||||||
|
|
||||||
// Form and state management hooks
|
// Form and state management hooks
|
||||||
const {
|
const {
|
||||||
formData,
|
formData,
|
||||||
@ -75,7 +108,7 @@ export function CreateRequest({
|
|||||||
documentPolicy,
|
documentPolicy,
|
||||||
existingDocuments,
|
existingDocuments,
|
||||||
setExistingDocuments,
|
setExistingDocuments,
|
||||||
} = useCreateRequestForm(isEditing, editRequestId, REQUEST_TEMPLATES);
|
} = useCreateRequestForm(isEditing, editRequestId, allTemplates);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentStep,
|
currentStep,
|
||||||
@ -84,6 +117,7 @@ export function CreateRequest({
|
|||||||
isStepValid,
|
isStepValid,
|
||||||
nextStep: wizardNextStep,
|
nextStep: wizardNextStep,
|
||||||
prevStep: wizardPrevStep,
|
prevStep: wizardPrevStep,
|
||||||
|
goToStep,
|
||||||
} = useWizardNavigation(isEditing, selectedTemplate, formData);
|
} = useWizardNavigation(isEditing, selectedTemplate, formData);
|
||||||
|
|
||||||
// Document management state
|
// Document management state
|
||||||
@ -98,6 +132,7 @@ export function CreateRequest({
|
|||||||
documentErrorModal,
|
documentErrorModal,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
closeValidationModal,
|
closeValidationModal,
|
||||||
|
openPolicyViolationModal,
|
||||||
closePolicyViolationModal,
|
closePolicyViolationModal,
|
||||||
openDocumentErrorModal,
|
openDocumentErrorModal,
|
||||||
closeDocumentErrorModal,
|
closeDocumentErrorModal,
|
||||||
@ -138,23 +173,40 @@ export function CreateRequest({
|
|||||||
wizardPrevStep,
|
wizardPrevStep,
|
||||||
user: user!,
|
user: user!,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
|
systemPolicy,
|
||||||
|
onPolicyViolation: openPolicyViolationModal,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
goToStep,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle back button:
|
// Handle back button:
|
||||||
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
|
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
|
||||||
// - Other steps: Go to previous step in wizard
|
// - Other steps: Go to previous step in wizard
|
||||||
const handleBackButton = useCallback(() => {
|
const handleBackButton = useCallback(() => {
|
||||||
if (currentStep === 1 || currentStep === 3 || currentStep === 4) {
|
// If on the first step (Template Selection), always go back to dashboard
|
||||||
// On steps 1, 3, or 4, navigate back to previous screen using browser history
|
// 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 (onBack) {
|
if (onBack) {
|
||||||
onBack();
|
onBack();
|
||||||
} else {
|
} else {
|
||||||
// Use window.history.back() as fallback for more reliable navigation
|
|
||||||
if (window.history.length > 1) {
|
if (window.history.length > 1) {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
// If no history, navigate to dashboard
|
|
||||||
navigate('/dashboard', { replace: true });
|
navigate('/dashboard', { replace: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,7 +219,7 @@ export function CreateRequest({
|
|||||||
// Sync documents from formData only on initial mount (when loading draft)
|
// Sync documents from formData only on initial mount (when loading draft)
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
const documentsSyncedRef = useRef(false);
|
const documentsSyncedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only sync from formData on initial mount or when loading a draft
|
// Only sync from formData on initial mount or when loading a draft
|
||||||
if (isInitialMount.current && formData.documents && formData.documents.length > 0 && !documentsSyncedRef.current) {
|
if (isInitialMount.current && formData.documents && formData.documents.length > 0 && !documentsSyncedRef.current) {
|
||||||
@ -181,7 +233,7 @@ export function CreateRequest({
|
|||||||
// Use a ref to prevent circular updates
|
// Use a ref to prevent circular updates
|
||||||
const isUpdatingFromFormData = useRef(false);
|
const isUpdatingFromFormData = useRef(false);
|
||||||
const prevDocumentsRef = useRef(documents);
|
const prevDocumentsRef = useRef(documents);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip if we're currently syncing from formData
|
// Skip if we're currently syncing from formData
|
||||||
if (isUpdatingFromFormData.current) {
|
if (isUpdatingFromFormData.current) {
|
||||||
@ -189,7 +241,7 @@ export function CreateRequest({
|
|||||||
prevDocumentsRef.current = documents;
|
prevDocumentsRef.current = documents;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only update if documents actually changed
|
// Only update if documents actually changed
|
||||||
if (prevDocumentsRef.current !== documents) {
|
if (prevDocumentsRef.current !== documents) {
|
||||||
updateFormData('documents', documents);
|
updateFormData('documents', documents);
|
||||||
@ -207,6 +259,7 @@ export function CreateRequest({
|
|||||||
templates={REQUEST_TEMPLATES}
|
templates={REQUEST_TEMPLATES}
|
||||||
selectedTemplate={selectedTemplate}
|
selectedTemplate={selectedTemplate}
|
||||||
onSelectTemplate={selectTemplate}
|
onSelectTemplate={selectTemplate}
|
||||||
|
adminTemplates={adminTemplates}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
@ -222,6 +275,7 @@ export function CreateRequest({
|
|||||||
<ApprovalWorkflowStep
|
<ApprovalWorkflowStep
|
||||||
formData={formData}
|
formData={formData}
|
||||||
updateFormData={updateFormData}
|
updateFormData={updateFormData}
|
||||||
|
systemPolicy={systemPolicy}
|
||||||
onValidationError={(error) =>
|
onValidationError={(error) =>
|
||||||
openValidationModal(
|
openValidationModal(
|
||||||
error.type as 'error' | 'self-assign' | 'not-found',
|
error.type as 'error' | 'self-assign' | 'not-found',
|
||||||
@ -229,6 +283,7 @@ export function CreateRequest({
|
|||||||
error.message
|
error.message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onPolicyViolation={openPolicyViolationModal}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 4:
|
case 4:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { Lightbulb, FileText } from 'lucide-react';
|
|||||||
export const REQUEST_TEMPLATES: RequestTemplate[] = [
|
export const REQUEST_TEMPLATES: RequestTemplate[] = [
|
||||||
{
|
{
|
||||||
id: 'custom',
|
id: 'custom',
|
||||||
name: 'Custom Request',
|
name: 'Non-Templatized',
|
||||||
description:
|
description:
|
||||||
'Create a custom request for unique business needs with full flexibility to define your own workflow and requirements',
|
'Create a custom request for unique business needs with full flexibility to define your own workflow and requirements',
|
||||||
category: 'General',
|
category: 'General',
|
||||||
|
|||||||
@ -8,7 +8,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { RequestTemplate, FormData } from '@/hooks/useCreateRequestForm';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { RequestTemplate, FormData, SystemPolicy } from '@/hooks/useCreateRequestForm';
|
||||||
import { PreviewDocument } from '../types/createRequest.types';
|
import { PreviewDocument } from '../types/createRequest.types';
|
||||||
import { getDocumentPreviewUrl } from '@/services/workflowApi';
|
import { getDocumentPreviewUrl } from '@/services/workflowApi';
|
||||||
import { validateApprovers } from './useApproverValidation';
|
import { validateApprovers } from './useApproverValidation';
|
||||||
@ -28,7 +29,10 @@ interface UseHandlersOptions {
|
|||||||
email: string,
|
email: string,
|
||||||
message: string
|
message: string
|
||||||
) => void;
|
) => void;
|
||||||
|
systemPolicy?: SystemPolicy;
|
||||||
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
onSubmit?: (requestData: any) => void;
|
onSubmit?: (requestData: any) => void;
|
||||||
|
goToStep?: (step: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateRequestHandlers({
|
export function useCreateRequestHandlers({
|
||||||
@ -42,8 +46,12 @@ export function useCreateRequestHandlers({
|
|||||||
wizardPrevStep,
|
wizardPrevStep,
|
||||||
user,
|
user,
|
||||||
openValidationModal,
|
openValidationModal,
|
||||||
|
systemPolicy,
|
||||||
|
onPolicyViolation,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
// goToStep,
|
||||||
}: UseHandlersOptions) {
|
}: UseHandlersOptions) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
const [previewDocument, setPreviewDocument] =
|
const [previewDocument, setPreviewDocument] =
|
||||||
useState<PreviewDocument | null>(null);
|
useState<PreviewDocument | null>(null);
|
||||||
@ -59,13 +67,23 @@ export function useCreateRequestHandlers({
|
|||||||
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
|
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
|
||||||
updateFormData('slaEndDate', suggestedDate);
|
updateFormData('slaEndDate', suggestedDate);
|
||||||
|
|
||||||
if (template.id === 'existing-template') {
|
// Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep)
|
||||||
setShowTemplateModal(true);
|
|
||||||
|
if (template.id !== 'custom' && template.id !== 'existing-template') {
|
||||||
|
// Redirect to dedicated Admin Request flow
|
||||||
|
navigate(`/create-admin-request/${template.id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplateSelection = (templateId: string) => {
|
const handleTemplateSelection = (templateId: string) => {
|
||||||
if (onSubmit) {
|
// 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
|
||||||
onSubmit({ templateType: templateId });
|
onSubmit({ templateType: templateId });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -74,12 +92,32 @@ export function useCreateRequestHandlers({
|
|||||||
const nextStep = async () => {
|
const nextStep = async () => {
|
||||||
if (!isStepValid()) return;
|
if (!isStepValid()) return;
|
||||||
|
|
||||||
|
// On step 1, if "existing-template" is selected, open the template selection modal
|
||||||
|
if (currentStep === 1 && _selectedTemplate?.id === 'existing-template') {
|
||||||
|
setShowTemplateModal(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (window.innerWidth < 640) {
|
if (window.innerWidth < 640) {
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special validation when leaving step 3 (Approval Workflow)
|
// Special validation when leaving step 3 (Approval Workflow)
|
||||||
if (currentStep === 3) {
|
if (currentStep === 3) {
|
||||||
|
// Validate approval level count against system policy
|
||||||
|
if (systemPolicy && onPolicyViolation) {
|
||||||
|
const approverCount = formData.approverCount || 1;
|
||||||
|
if (approverCount > systemPolicy.maxApprovalLevels) {
|
||||||
|
onPolicyViolation([{
|
||||||
|
type: 'Maximum Approval Levels Exceeded',
|
||||||
|
message: `The request has ${approverCount} approval levels, which exceeds the maximum allowed (${systemPolicy.maxApprovalLevels}). Please reduce the number of approvers.`,
|
||||||
|
currentValue: approverCount,
|
||||||
|
maxValue: systemPolicy.maxApprovalLevels
|
||||||
|
}]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
|
const initiatorEmail = (user as any)?.email?.toLowerCase() || '';
|
||||||
const validation = await validateApprovers(
|
const validation = await validateApprovers(
|
||||||
formData.approvers,
|
formData.approvers,
|
||||||
@ -142,6 +180,7 @@ export function useCreateRequestHandlers({
|
|||||||
setPreviewDocument(null);
|
setPreviewDocument(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
showTemplateModal,
|
showTemplateModal,
|
||||||
setShowTemplateModal,
|
setShowTemplateModal,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
import {
|
import {
|
||||||
buildCreatePayload,
|
buildCreatePayload,
|
||||||
@ -10,8 +11,6 @@ import {
|
|||||||
validateApproversForSubmission,
|
validateApproversForSubmission,
|
||||||
} from '../utils/payloadBuilders';
|
} from '../utils/payloadBuilders';
|
||||||
import {
|
import {
|
||||||
createAndSubmitWorkflow,
|
|
||||||
updateAndSubmitWorkflow,
|
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
updateWorkflowRequest,
|
updateWorkflowRequest,
|
||||||
} from '../services/createRequestService';
|
} from '../services/createRequestService';
|
||||||
@ -58,34 +57,48 @@ export function useCreateRequestSubmission({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing && editRequestId) {
|
if (isEditing && editRequestId) {
|
||||||
// Update existing workflow
|
// Update existing workflow with isDraft: false (Submit)
|
||||||
const updatePayload = buildUpdatePayload(
|
const updatePayload = buildUpdatePayload(
|
||||||
formData,
|
formData,
|
||||||
user,
|
user,
|
||||||
documentsToDelete
|
documentsToDelete,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateAndSubmitWorkflow(
|
await updateWorkflowRequest(
|
||||||
editRequestId,
|
editRequestId,
|
||||||
updatePayload,
|
updatePayload,
|
||||||
documents,
|
documents,
|
||||||
documentsToDelete
|
documentsToDelete
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Show toast after backend confirmation
|
||||||
|
toast.success('Request Submitted Successfully!', {
|
||||||
|
description: `Your request "${formData.title}" has been submitted and sent for approval.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
onSubmit?.({
|
onSubmit?.({
|
||||||
...formData,
|
...formData,
|
||||||
backendId: editRequestId,
|
backendId: editRequestId,
|
||||||
template: selectedTemplate,
|
template: selectedTemplate,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new workflow
|
// Create new workflow with isDraft: false (Submit)
|
||||||
const createPayload = buildCreatePayload(
|
const createPayload = buildCreatePayload(
|
||||||
formData,
|
formData,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
user
|
user,
|
||||||
|
false
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await createAndSubmitWorkflow(createPayload, documents);
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
onSubmit?.({
|
onSubmit?.({
|
||||||
...formData,
|
...formData,
|
||||||
@ -93,8 +106,12 @@ export function useCreateRequestSubmission({
|
|||||||
template: selectedTemplate,
|
template: selectedTemplate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Failed to submit workflow:', error);
|
console.error('Failed to submit workflow:', error);
|
||||||
|
toast.error('Failed to Submit Request', {
|
||||||
|
description: error?.response?.data?.message || error?.message || 'An error occurred while submitting the request.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -116,11 +133,12 @@ export function useCreateRequestSubmission({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (isEditing && editRequestId) {
|
if (isEditing && editRequestId) {
|
||||||
// Update existing draft
|
// Update existing draft with isDraft: true
|
||||||
const updatePayload = buildUpdatePayload(
|
const updatePayload = buildUpdatePayload(
|
||||||
formData,
|
formData,
|
||||||
user,
|
user,
|
||||||
documentsToDelete
|
documentsToDelete,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
await updateWorkflowRequest(
|
await updateWorkflowRequest(
|
||||||
@ -130,29 +148,44 @@ export function useCreateRequestSubmission({
|
|||||||
documentsToDelete
|
documentsToDelete
|
||||||
);
|
);
|
||||||
|
|
||||||
|
toast.success('Draft Saved Successfully!', {
|
||||||
|
description: `Your request "${formData.title}" has been saved as draft.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
onSubmit?.({
|
onSubmit?.({
|
||||||
...formData,
|
...formData,
|
||||||
backendId: editRequestId,
|
backendId: editRequestId,
|
||||||
template: selectedTemplate,
|
template: selectedTemplate,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Create new draft
|
// Create new draft with isDraft: true
|
||||||
const createPayload = buildCreatePayload(
|
const createPayload = buildCreatePayload(
|
||||||
formData,
|
formData,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
user
|
user,
|
||||||
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await createWorkflow(createPayload, documents);
|
const result = await createWorkflow(createPayload, documents);
|
||||||
|
|
||||||
|
toast.success('Draft Saved Successfully!', {
|
||||||
|
description: `Your request "${formData.title}" has been saved as draft.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
onSubmit?.({
|
onSubmit?.({
|
||||||
...formData,
|
...formData,
|
||||||
backendId: result.id,
|
backendId: result.id,
|
||||||
template: selectedTemplate,
|
template: selectedTemplate,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Failed to save draft:', error);
|
console.error('Failed to save draft:', error);
|
||||||
|
toast.error('Failed to Save Draft', {
|
||||||
|
description: error?.response?.data?.message || error?.message || 'An error occurred while saving the draft.',
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
setSavingDraft(false);
|
setSavingDraft(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
createWorkflowMultipart,
|
createWorkflowMultipart,
|
||||||
submitWorkflow,
|
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
updateWorkflowMultipart,
|
updateWorkflowMultipart,
|
||||||
} from '@/services/workflowApi';
|
} from '@/services/workflowApi';
|
||||||
@ -14,7 +13,7 @@ import {
|
|||||||
} from '../types/createRequest.types';
|
} from '../types/createRequest.types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new workflow
|
* Create a new workflow (supports both draft and direct submission via isDraft flag)
|
||||||
*/
|
*/
|
||||||
export async function createWorkflow(
|
export async function createWorkflow(
|
||||||
payload: CreateWorkflowPayload,
|
payload: CreateWorkflowPayload,
|
||||||
@ -29,7 +28,7 @@ export async function createWorkflow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing workflow
|
* Update an existing workflow (supports both draft and direct submission via isDraft flag)
|
||||||
*/
|
*/
|
||||||
export async function updateWorkflowRequest(
|
export async function updateWorkflowRequest(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@ -51,36 +50,3 @@ export async function updateWorkflowRequest(
|
|||||||
await updateWorkflow(requestId, payload);
|
await updateWorkflow(requestId, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Submit a workflow
|
|
||||||
*/
|
|
||||||
export async function submitWorkflowRequest(requestId: string): Promise<void> {
|
|
||||||
await submitWorkflow(requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create and submit a workflow in one operation
|
|
||||||
*/
|
|
||||||
export async function createAndSubmitWorkflow(
|
|
||||||
payload: CreateWorkflowPayload,
|
|
||||||
documents: File[]
|
|
||||||
): Promise<{ id: string }> {
|
|
||||||
const result = await createWorkflow(payload, documents);
|
|
||||||
await submitWorkflowRequest(result.id);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update and submit a workflow in one operation
|
|
||||||
*/
|
|
||||||
export async function updateAndSubmitWorkflow(
|
|
||||||
requestId: string,
|
|
||||||
payload: UpdateWorkflowPayload,
|
|
||||||
documents: File[],
|
|
||||||
documentsToDelete: string[]
|
|
||||||
): Promise<void> {
|
|
||||||
await updateWorkflowRequest(requestId, payload, documents, documentsToDelete);
|
|
||||||
await submitWorkflowRequest(requestId);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@ -67,6 +67,7 @@ export interface CreateWorkflowPayload {
|
|||||||
email: string;
|
email: string;
|
||||||
}>;
|
}>;
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
|
isDraft?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateWorkflowPayload {
|
export interface UpdateWorkflowPayload {
|
||||||
@ -76,6 +77,7 @@ export interface UpdateWorkflowPayload {
|
|||||||
approvalLevels: ApprovalLevel[];
|
approvalLevels: ApprovalLevel[];
|
||||||
participants: Participant[];
|
participants: Participant[];
|
||||||
deleteDocumentIds?: string[];
|
deleteDocumentIds?: string[];
|
||||||
|
isDraft?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidationModalState {
|
export interface ValidationModalState {
|
||||||
|
|||||||
@ -17,16 +17,9 @@ import { buildApprovalLevels } from './approvalLevelBuilders';
|
|||||||
export function buildCreatePayload(
|
export function buildCreatePayload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
selectedTemplate: RequestTemplate | null,
|
selectedTemplate: RequestTemplate | null,
|
||||||
_user: any
|
_user: any,
|
||||||
|
isDraft: boolean = false
|
||||||
): CreateWorkflowPayload {
|
): CreateWorkflowPayload {
|
||||||
// Filter out spectators who are also approvers (backend will handle validation)
|
|
||||||
const approverEmails = new Set(
|
|
||||||
(formData.approvers || []).map((a: any) => a?.email?.toLowerCase()).filter(Boolean)
|
|
||||||
);
|
|
||||||
const filteredSpectators = (formData.spectators || []).filter(
|
|
||||||
(s: any) => s?.email && !approverEmails.has(s.email.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
templateId: selectedTemplate?.id || null,
|
templateId: selectedTemplate?.id || null,
|
||||||
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
|
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' : 'TEMPLATE',
|
||||||
@ -38,16 +31,17 @@ export function buildCreatePayload(
|
|||||||
userId: a?.userId || '',
|
userId: a?.userId || '',
|
||||||
email: a?.email || '',
|
email: a?.email || '',
|
||||||
name: a?.name,
|
name: a?.name,
|
||||||
tat: a?.tat || '',
|
tat: a?.tat || 24,
|
||||||
tatType: a?.tatType || 'hours',
|
tatType: a?.tatType || 'hours',
|
||||||
})),
|
})),
|
||||||
spectators: filteredSpectators.map((s: any) => ({
|
spectators: (formData.spectators || []).map((s: any) => ({
|
||||||
userId: s?.userId || '',
|
userId: s?.userId || '',
|
||||||
name: s?.name || '',
|
name: s?.name || '',
|
||||||
email: s?.email || '',
|
email: s?.email || '',
|
||||||
})),
|
})),
|
||||||
ccList: [], // Auto-generated by backend
|
ccList: [], // Auto-generated by backend
|
||||||
participants: [], // Auto-generated by backend from approvers and spectators
|
participants: [], // Auto-generated by backend from approvers and spectators
|
||||||
|
isDraft,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +52,8 @@ export function buildCreatePayload(
|
|||||||
export function buildUpdatePayload(
|
export function buildUpdatePayload(
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
_user: any,
|
_user: any,
|
||||||
documentsToDelete: string[]
|
documentsToDelete: string[],
|
||||||
|
isDraft: boolean = false
|
||||||
): UpdateWorkflowPayload {
|
): UpdateWorkflowPayload {
|
||||||
const approvalLevels = buildApprovalLevels(
|
const approvalLevels = buildApprovalLevels(
|
||||||
formData.approvers || [],
|
formData.approvers || [],
|
||||||
@ -72,6 +67,7 @@ export function buildUpdatePayload(
|
|||||||
approvalLevels,
|
approvalLevels,
|
||||||
participants: [], // Auto-generated by backend from approval levels
|
participants: [], // Auto-generated by backend from approval levels
|
||||||
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
||||||
|
isDraft,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +80,7 @@ export function validateApproversForSubmission(
|
|||||||
approverCount: number
|
approverCount: number
|
||||||
): { valid: boolean; message?: string } {
|
): { valid: boolean; message?: string } {
|
||||||
const approversToCheck = approvers.slice(0, approverCount);
|
const approversToCheck = approvers.slice(0, approverCount);
|
||||||
|
|
||||||
// Check if all approvers have valid emails
|
// Check if all approvers have valid emails
|
||||||
const hasMissingEmails = approversToCheck.some(
|
const hasMissingEmails = approversToCheck.some(
|
||||||
(a: any) => !a?.email || !a.email.trim()
|
(a: any) => !a?.email || !a.email.trim()
|
||||||
@ -112,4 +108,3 @@ export function validateApproversForSubmission(
|
|||||||
|
|
||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { type DateRange } from '@/services/dashboard.service';
|
|||||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||||
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||||
import { setViewAsUser } from './redux/dashboardSlice';
|
import { setViewAsUser } from './redux/dashboardSlice';
|
||||||
|
import { TokenManager } from '@/utils/tokenManager';
|
||||||
|
|
||||||
// Custom Hooks
|
// Custom Hooks
|
||||||
import { useDashboardFilters } from './hooks/useDashboardFilters';
|
import { useDashboardFilters } from './hooks/useDashboardFilters';
|
||||||
@ -161,8 +162,19 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [dateRange, customStartDate, customEndDate, viewAsUser]);
|
}, [dateRange, customStartDate, customEndDate, viewAsUser]);
|
||||||
|
|
||||||
|
// Check if user is a Dealer
|
||||||
|
const isDealer = useMemo(() => {
|
||||||
|
try {
|
||||||
|
const userData = TokenManager.getUserData();
|
||||||
|
return userData?.jobTitle === 'Dealer';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Dashboard] Error checking dealer status:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Quick actions
|
// Quick actions
|
||||||
const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate), [isAdmin, onNewRequest, onNavigate]);
|
const quickActions = useMemo(() => getQuickActions(isAdmin, onNewRequest, onNavigate, isDealer), [isAdmin, onNewRequest, onNavigate, isDealer]);
|
||||||
|
|
||||||
// KPI click handler
|
// KPI click handler
|
||||||
const handleKPIClick = useCallback((filters: {
|
const handleKPIClick = useCallback((filters: {
|
||||||
|
|||||||
@ -9,11 +9,11 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Filter, Calendar as CalendarIcon, RefreshCw } from 'lucide-react';
|
import { Filter, Calendar as CalendarIcon, RefreshCw } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { DateRange } from '@/services/dashboard.service';
|
import { DateRange } from '@/services/dashboard.service';
|
||||||
|
import { CustomDatePicker } from '@/components/ui/date-picker';
|
||||||
|
|
||||||
interface DashboardFiltersBarProps {
|
interface DashboardFiltersBarProps {
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
@ -96,12 +96,10 @@ export function DashboardFiltersBar({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
|
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
|
||||||
<Input
|
<CustomDatePicker
|
||||||
id="start-date"
|
value={customStartDate || null}
|
||||||
type="date"
|
onChange={(dateStr: string | null) => {
|
||||||
value={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : ''}
|
const date = dateStr ? new Date(dateStr) : undefined;
|
||||||
onChange={(e) => {
|
|
||||||
const date = e.target.value ? new Date(e.target.value) : undefined;
|
|
||||||
if (date) {
|
if (date) {
|
||||||
onCustomStartDateChange(date);
|
onCustomStartDateChange(date);
|
||||||
if (customEndDate && date > customEndDate) {
|
if (customEndDate && date > customEndDate) {
|
||||||
@ -111,19 +109,18 @@ export function DashboardFiltersBar({
|
|||||||
onCustomStartDateChange(undefined);
|
onCustomStartDateChange(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
max={format(new Date(), 'yyyy-MM-dd')}
|
maxDate={new Date()}
|
||||||
|
placeholderText="dd/mm/yyyy"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
data-testid="start-date-input"
|
data-testid="start-date-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
|
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
|
||||||
<Input
|
<CustomDatePicker
|
||||||
id="end-date"
|
value={customEndDate || null}
|
||||||
type="date"
|
onChange={(dateStr: string | null) => {
|
||||||
value={customEndDate ? format(customEndDate, 'yyyy-MM-dd') : ''}
|
const date = dateStr ? new Date(dateStr) : undefined;
|
||||||
onChange={(e) => {
|
|
||||||
const date = e.target.value ? new Date(e.target.value) : undefined;
|
|
||||||
if (date) {
|
if (date) {
|
||||||
onCustomEndDateChange(date);
|
onCustomEndDateChange(date);
|
||||||
if (customStartDate && date < customStartDate) {
|
if (customStartDate && date < customStartDate) {
|
||||||
@ -133,8 +130,9 @@ export function DashboardFiltersBar({
|
|||||||
onCustomEndDateChange(undefined);
|
onCustomEndDateChange(undefined);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
min={customStartDate ? format(customStartDate, 'yyyy-MM-dd') : undefined}
|
minDate={customStartDate || undefined}
|
||||||
max={format(new Date(), 'yyyy-MM-dd')}
|
maxDate={new Date()}
|
||||||
|
placeholderText="dd/mm/yyyy"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
data-testid="end-date-input"
|
data-testid="end-date-input"
|
||||||
/>
|
/>
|
||||||
@ -181,6 +179,88 @@ export function DashboardFiltersBar({
|
|||||||
<SelectItem value="custom">Custom Range</SelectItem>
|
<SelectItem value="custom">Custom Range</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{/* Custom Date Range Picker for Normal Users */}
|
||||||
|
{dateRange === 'custom' && (
|
||||||
|
<Popover open={showCustomDatePicker} onOpenChange={onShowCustomDatePickerChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="gap-2" data-testid="custom-date-trigger">
|
||||||
|
<CalendarIcon className="w-4 h-4" />
|
||||||
|
{customStartDate && customEndDate
|
||||||
|
? `${format(customStartDate, 'MMM d, yyyy')} - ${format(customEndDate, 'MMM d, yyyy')}`
|
||||||
|
: 'Select dates'}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-auto p-4" align="start" sideOffset={8} data-testid="custom-date-picker">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="start-date-user" className="text-sm font-medium">Start Date</Label>
|
||||||
|
<CustomDatePicker
|
||||||
|
value={customStartDate || null}
|
||||||
|
onChange={(dateStr: string | null) => {
|
||||||
|
const date = dateStr ? new Date(dateStr) : undefined;
|
||||||
|
if (date) {
|
||||||
|
onCustomStartDateChange(date);
|
||||||
|
if (customEndDate && date > customEndDate) {
|
||||||
|
onCustomEndDateChange(date);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onCustomStartDateChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
maxDate={new Date()}
|
||||||
|
placeholderText="dd/mm/yyyy"
|
||||||
|
className="w-full"
|
||||||
|
data-testid="start-date-input-user"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="end-date-user" className="text-sm font-medium">End Date</Label>
|
||||||
|
<CustomDatePicker
|
||||||
|
value={customEndDate || null}
|
||||||
|
onChange={(dateStr: string | null) => {
|
||||||
|
const date = dateStr ? new Date(dateStr) : undefined;
|
||||||
|
if (date) {
|
||||||
|
onCustomEndDateChange(date);
|
||||||
|
if (customStartDate && date < customStartDate) {
|
||||||
|
onCustomStartDateChange(date);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onCustomEndDateChange(undefined);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
minDate={customStartDate || undefined}
|
||||||
|
maxDate={new Date()}
|
||||||
|
placeholderText="dd/mm/yyyy"
|
||||||
|
className="w-full"
|
||||||
|
data-testid="end-date-input-user"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 pt-2 border-t">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={onApplyCustomDate}
|
||||||
|
disabled={!customStartDate || !customEndDate}
|
||||||
|
className="flex-1 bg-re-green hover:bg-re-green/90"
|
||||||
|
data-testid="apply-custom-date-user"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onResetCustomDates}
|
||||||
|
data-testid="cancel-custom-date-user"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -206,4 +286,3 @@ export function DashboardFiltersBar({
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user