Compare commits

..

7 Commits

23 changed files with 2162 additions and 537 deletions

View File

@ -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';
@ -21,6 +21,9 @@ import { Settings } from '@/pages/Settings';
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 { 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';
@ -40,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)
@ -152,7 +155,7 @@ function AppRoutes({ onLogout }: AppProps) {
const handleViewRequest = (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
navigateToRequest({ navigateToRequest({
requestId, requestId,
@ -180,18 +183,18 @@ function AppRoutes({ onLogout }: AppProps) {
} }
return; return;
} }
// If requestData has backendId, it means it came from the API flow (CreateRequest component) // If requestData has backendId, it means it came from the API flow (CreateRequest component)
// The hook already shows the toast, so we just navigate // The hook already shows the toast, so we just navigate
if (requestData.backendId) { if (requestData.backendId) {
navigate('/my-requests'); navigate('/my-requests');
return; return;
} }
// Regular custom request submission (old flow without API) // Regular custom request submission (old flow without API)
// Generate unique ID for the new custom request // Generate unique ID for the new custom request
const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`; const requestId = `RE-REQ-2024-${String(Object.keys(CUSTOM_REQUEST_DATABASE).length + dynamicRequests.length + 1).padStart(3, '0')}`;
// Create full custom request object // Create full custom request object
const newCustomRequest = { const newCustomRequest = {
id: requestId, id: requestId,
@ -219,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(),
@ -243,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})` : ''}`,
@ -268,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) => {
@ -310,7 +309,7 @@ function AppRoutes({ onLogout }: AppProps) {
duration: 5000, duration: 5000,
}); });
} }
setApprovalAction(null); setApprovalAction(null);
resolve(true); resolve(true);
}, 1000); }, 1000);
@ -343,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');
@ -377,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({
@ -392,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 || [];
@ -405,20 +404,20 @@ 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) // Keep the old code below for backward compatibility (local storage fallback)
// This can be removed once API integration is fully tested // This can be removed once API integration is fully tested
/* /*
// Generate unique ID for the new claim request // Generate unique ID for the new claim request
const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`; const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`;
// Create full request object // Create full request object
const newRequest = { const newRequest = {
id: requestId, id: requestId,
@ -445,21 +444,21 @@ function AppRoutes({ onLogout }: AppProps) {
avatar: 'CU' avatar: 'CU'
}, },
department: 'Marketing', department: 'Marketing',
createdAt: new Date().toLocaleString('en-US', { createdAt: new Date().toLocaleString('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().toLocaleString('en-US', { updatedAt: new Date().toLocaleString('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() + 7 * 24 * 60 * 60 * 1000).toISOString(), dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
conclusionRemark: '', conclusionRemark: '',
@ -579,30 +578,30 @@ function AppRoutes({ onLogout }: AppProps) {
documents: [], documents: [],
spectators: [], spectators: [],
auditTrail: [ auditTrail: [
{ {
type: 'created', type: 'created',
action: 'Request Created', action: 'Request Created',
details: `Claim request for ${claimData.activityName} created`, details: `Claim request for ${claimData.activityName} created`,
user: 'Current User', user: 'Current User',
timestamp: new Date().toLocaleString('en-US', { timestamp: new Date().toLocaleString('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
}) })
} }
], ],
tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')] tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')]
}; };
// Add to dynamic requests // Add to dynamic requests
setDynamicRequests(prev => [...prev, newRequest]); setDynamicRequests(prev => [...prev, newRequest]);
// Also add to REQUEST_DATABASE for immediate viewing // Also add to REQUEST_DATABASE for immediate viewing
(REQUEST_DATABASE as any)[requestId] = newRequest; (REQUEST_DATABASE as any)[requestId] = newRequest;
toast.success('Claim Request Submitted', { toast.success('Claim Request Submitted', {
description: 'Your claim management request has been created successfully.', description: 'Your claim management request has been created successfully.',
}); });
@ -614,134 +613,194 @@ function AppRoutes({ onLogout }: AppProps) {
<div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background"> <div className="min-h-screen h-screen flex flex-col overflow-hidden bg-background">
<Routes> <Routes>
{/* Auth Callback - Unified callback for both OKTA and Tanflow */} {/* Auth Callback - Unified callback for both OKTA and Tanflow */}
<Route <Route
path="/login/callback" path="/login/callback"
element={<AuthCallback />} element={<AuthCallback />}
/> />
{/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */} {/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */}
<Route <Route
path="/" path="/"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </PageLayout>
} }
/> />
<Route <Route
path="/dashboard" path="/dashboard"
element={ element={
<PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="dashboard" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} /> <DashboardRoute onNavigate={handleNavigate} onNewRequest={handleNewRequest} />
</PageLayout> </PageLayout>
} }
/>
{/* Admin Routes Group with Shared Layout */}
<Route
element={
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Outlet />
</PageLayout>
}
>
<Route path="/admin/create-template" element={<CreateTemplate />} />
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
<Route path="/admin/templates" element={<AdminTemplatesList />} />
</Route>
{/* Create Request from Admin Template (Dedicated Flow) */}
<Route
path="/create-admin-request/:templateId"
element={
<CreateAdminRequest />
}
/>
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
{/* Admin Routes Group with Shared Layout */}
<Route
element={
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Outlet />
</PageLayout>
}
>
<Route path="/admin/create-template" element={<CreateTemplate />} />
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
<Route path="/admin/templates" element={<AdminTemplatesList />} />
</Route>
{/* Create Request from Admin Template (Dedicated Flow) */}
<Route
path="/create-admin-request/:templateId"
element={
<CreateAdminRequest />
}
/>
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/> />
{/* Open Requests */} {/* Open Requests */}
<Route <Route
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}
@ -749,72 +808,66 @@ 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>
} }
/> />
{/* 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: {
@ -868,7 +921,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} />

View File

@ -36,7 +36,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
const [unreadCount, setUnreadCount] = useState(0); const [unreadCount, setUnreadCount] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false); const [notificationsOpen, setNotificationsOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
// Check if user is a Dealer // Check if user is a Dealer
const isDealer = useMemo(() => { const isDealer = useMemo(() => {
try { try {
@ -47,7 +47,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
return false; return false;
} }
}, []); }, []);
// Get user initials for avatar // Get user initials for avatar
const getUserInitials = () => { const getUserInitials = () => {
try { try {
@ -67,19 +67,20 @@ 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 },
{ id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
]; ];
// Add remaining menu items (exclude "My Requests" for dealers) // Add remaining menu items (exclude "My Requests" for dealers)
if (!isDealer) { if (!isDealer) {
items.push({ id: 'my-requests', label: 'My Requests', icon: User }); items.push({ id: 'my-requests', label: 'My Requests', icon: User });
} }
items.push( items.push(
{ 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 },
@ -98,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));
@ -111,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);
} }
@ -152,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);
@ -173,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);
}; };
@ -214,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)}
/> />
@ -238,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>
@ -248,28 +249,31 @@ 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 */}
{!isDealer && ( {!isDealer && (
<div className="mt-6 pt-6 border-t border-gray-800 px-3"> <div className="mt-6 pt-6 border-t border-gray-800 px-3">
@ -292,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" />
@ -361,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">
@ -422,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" />

View File

@ -1,14 +1,19 @@
import { motion } from 'framer-motion'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { Check, Clock, Users, 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) => {
@ -20,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 }}
@ -46,101 +78,216 @@ 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 = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder
<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'
: 'bg-gray-100'
}`}
data-testid={`template-card-${template.id}-icon`}
>
<template.icon
className={`w-7 h-7 ${isSelected
? 'text-blue-600'
: isCategoryCard
? 'text-blue-600'
: 'text-gray-600'
}`}
/>
</div>
{isSelected && (
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 500, damping: 15 }}
data-testid={`template-card-${template.id}-selected-indicator`}
>
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
<Check className="w-5 h-5 text-white" />
</div>
</motion.div>
)}
</div>
<div className="text-left">
<div className="flex items-start justify-between gap-2 mb-2">
<CardTitle className="text-xl" data-testid={`template-card-${template.id}-name`}>
{template.name}
</CardTitle>
{isComingSoon && (
<Badge
variant="outline"
className="text-xs bg-yellow-100 text-yellow-700 border-yellow-300 font-semibold"
data-testid={`template-card-${template.id}-coming-soon-badge`}
>
Coming Soon
</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
{template.category}
</Badge>
{getPriorityIcon(template.priority)}
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-4">
<p
className="text-sm text-gray-600 leading-relaxed line-clamp-2"
data-testid={`template-card-${template.id}-description`}
>
{template.description}
</p>
{!isCategoryCard && (
<>
<Separator />
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
<Clock className="w-3.5 h-3.5" />
<span>{template.estimatedTime}</span>
</div>
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
<Users className="w-3.5 h-3.5" />
<span>{template.commonApprovers?.length || 0} approvers</span>
</div>
</div>
</>
)}
{isCategoryCard && (
<div className="pt-2">
<p className="text-xs text-blue-600 font-medium flex items-center gap-1">
Click to browse templates &rarr;
</p>
</div>
)}
</CardContent>
</Card>
</motion.div>
);
})
)}
</div>
{/* Template Details Card */}
<AnimatePresence>
{selectedTemplate && (
<motion.div
initial={{ opacity: 0, y: 20, height: 0 }}
animate={{ opacity: 1, y: 0, height: 'auto' }}
exit={{ opacity: 0, y: -20, height: 0 }}
transition={{ duration: 0.3 }}
className="w-full max-w-6xl"
data-testid="template-details-card"
>
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-blue-900" data-testid="template-details-title">
<Info className="w-5 h-5" />
{selectedTemplate.name} - Template Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-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">
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} hours</p>
</div> </div>
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}> <div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
<Users className="w-3.5 h-3.5" /> <Label className="text-blue-900 font-semibold">Priority Level</Label>
<span>{template.commonApprovers.length} approvers</span> <div className="flex items-center gap-1 mt-1">
{getPriorityIcon(selectedTemplate.priority)}
<span className="text-blue-700 capitalize">{selectedTemplate.priority}</span>
</div>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-duration">
<Label className="text-blue-900 font-semibold">Estimated Duration</Label>
<p className="text-blue-700 mt-1">{selectedTemplate.estimatedTime}</p>
</div>
</div>
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
<Label className="text-blue-900 font-semibold">Approvers</Label>
<div className="flex flex-wrap gap-2 mt-2">
{selectedTemplate.commonApprovers?.length > 0 ? (
selectedTemplate.commonApprovers.map((approver, index) => (
<Badge
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
variant="outline"
className="border-blue-300 text-blue-700 bg-white"
data-testid={`template-details-approver-${index}`}
>
{approver}
</Badge>
))
) : (
<span className="text-sm text-gray-500 italic">No specific approvers defined</span>
)}
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</motion.div> </motion.div>
))} )}
</div> </AnimatePresence>
</motion.div> </motion.div>
); );
} }

View File

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

View File

@ -31,14 +31,14 @@ export function StandardClosedRequestsFilters({
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter, // templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
activeFiltersCount, activeFiltersCount,
onSearchChange, onSearchChange,
onPriorityChange, onPriorityChange,
onStatusChange, onStatusChange,
onTemplateTypeChange, // onTemplateTypeChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -77,7 +77,7 @@ export function StandardClosedRequestsFilters({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6"> <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-5 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative"> <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" /> <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 <Input
@ -88,7 +88,7 @@ export function StandardClosedRequestsFilters({
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" />
@ -109,7 +109,7 @@ export function StandardClosedRequestsFilters({
</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" />
@ -130,7 +130,7 @@ export function StandardClosedRequestsFilters({
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{/*
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
@ -140,7 +140,7 @@ export function StandardClosedRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}> <Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
@ -153,7 +153,7 @@ export function StandardClosedRequestsFilters({
<SelectItem value="priority">Priority</SelectItem> <SelectItem value="priority">Priority</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@ -31,13 +31,13 @@ export function StandardRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, // templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
onSearchChange, onSearchChange,
onStatusFilterChange, onStatusFilterChange,
onPriorityFilterChange, onPriorityFilterChange,
onTemplateTypeFilterChange, // onTemplateTypeFilterChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -77,7 +77,7 @@ export function StandardRequestsFilters({
</CardHeader> </CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6"> <CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */} {/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative"> <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" /> <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 <Input
@ -87,7 +87,7 @@ export function StandardRequestsFilters({
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" 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>
<Select value={priorityFilter} onValueChange={onPriorityFilterChange}> <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"> <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" /> <SelectValue placeholder="All Priorities" />
@ -108,7 +108,7 @@ export function StandardRequestsFilters({
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={statusFilter} onValueChange={onStatusFilterChange}> <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"> <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" /> <SelectValue placeholder="All Statuses" />
@ -120,7 +120,7 @@ export function StandardRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -129,7 +129,7 @@ export function StandardRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}> <Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
@ -143,7 +143,7 @@ export function StandardRequestsFilters({
<SelectItem value="sla">SLA Progress</SelectItem> <SelectItem value="sla">SLA Progress</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@ -34,11 +34,11 @@ interface StandardUserAllRequestsFiltersProps {
customStartDate?: Date; customStartDate?: Date;
customEndDate?: Date; customEndDate?: Date;
showCustomDatePicker: boolean; showCustomDatePicker: boolean;
// Departments // Departments
departments: string[]; departments: string[];
loadingDepartments: boolean; loadingDepartments: boolean;
// State for user search // State for user search
initiatorSearch: { initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null; selectedUser: { userId: string; email: string; displayName?: string } | null;
@ -50,7 +50,7 @@ interface StandardUserAllRequestsFiltersProps {
handleClear: () => void; handleClear: () => void;
setShowResults: (show: boolean) => void; setShowResults: (show: boolean) => void;
}; };
approverSearch: { approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null; selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string; searchQuery: string;
@ -61,7 +61,7 @@ interface StandardUserAllRequestsFiltersProps {
handleClear: () => void; handleClear: () => void;
setShowResults: (show: boolean) => void; setShowResults: (show: boolean) => void;
}; };
// Actions // Actions
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void; onStatusChange: (value: string) => void;
@ -78,7 +78,7 @@ interface StandardUserAllRequestsFiltersProps {
onShowCustomDatePickerChange?: (show: boolean) => void; onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void; onApplyCustomDate?: () => void;
onClearFilters: () => void; onClearFilters: () => void;
// Computed // Computed
hasActiveFilters: boolean; hasActiveFilters: boolean;
} }
@ -87,7 +87,7 @@ export function StandardUserAllRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, // templateTypeFilter,
departmentFilter, departmentFilter,
slaComplianceFilter, slaComplianceFilter,
initiatorFilter: _initiatorFilter, initiatorFilter: _initiatorFilter,
@ -104,7 +104,7 @@ export function StandardUserAllRequestsFilters({
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
onTemplateTypeChange, // onTemplateTypeChange,
onDepartmentChange, onDepartmentChange,
onSlaComplianceChange, onSlaComplianceChange,
onInitiatorChange: _onInitiatorChange, onInitiatorChange: _onInitiatorChange,
@ -143,7 +143,7 @@ export function StandardUserAllRequestsFilters({
<Separator /> <Separator />
{/* Primary Filters */} {/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4"> <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"> <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" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input <Input
@ -180,7 +180,7 @@ export function StandardUserAllRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-10" data-testid="template-type-filter"> <SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -189,7 +189,7 @@ export function StandardUserAllRequestsFilters({
<SelectItem value="CUSTOM">Custom</SelectItem> <SelectItem value="CUSTOM">Custom</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<Select <Select
value={departmentFilter} value={departmentFilter}

View File

@ -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: {
@ -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);

View File

@ -0,0 +1,228 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Pencil, Trash2, Search, FileText, AlertTriangle } 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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { getTemplates, deleteTemplate, WorkflowTemplate, getCachedTemplates } from '@/services/workflowTemplateApi';
import { toast } from 'sonner';
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 [deleteId, setDeleteId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
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 handleDelete = async () => {
if (!deleteId) return;
try {
setDeleting(true);
await deleteTemplate(deleteId);
toast.success('Template deleted successfully');
setTemplates(prev => prev.filter(t => t.id !== deleteId));
} catch (error) {
console.error('Failed to delete template:', error);
toast.error('Failed to delete template');
} finally {
setDeleting(false);
setDeleteId(null);
}
};
const filteredTemplates = templates.filter(template =>
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-2 h-10">
{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>
<Button
variant="outline"
className="flex-1 text-red-600 hover:text-red-700 hover:bg-red-50 border-red-100"
onClick={() => setDeleteId(template.id)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<AlertDialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-red-600" />
Delete Template
</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this template? This action cannot be undone.
Active requests using this template will not be affected.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDelete();
}}
className="bg-red-600 hover:bg-red-700"
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

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

View File

@ -2,13 +2,13 @@ import { useState, useEffect } from 'react';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { LogIn, Shield } from 'lucide-react'; import { LogIn } from 'lucide-react';
import { ReLogo, LandingPageImage } from '@/assets'; import { ReLogo, LandingPageImage } from '@/assets';
import { initiateTanflowLogin } from '@/services/tanflowAuth'; // import { initiateTanflowLogin } from '@/services/tanflowAuth';
export function Auth() { export function Auth() {
const { login, isLoading, error } = useAuth(); const { login, isLoading, error } = useAuth();
const [tanflowLoading, setTanflowLoading] = useState(false); const [tanflowLoading] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
// Preload the background image // Preload the background image
@ -28,7 +28,7 @@ export function Auth() {
// 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) {
@ -41,11 +41,11 @@ export function Auth() {
} }
}; };
const handleTanflowLogin = () => { /* const handleTanflowLogin = () => {
// Clear any existing session data // Clear any existing session data
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
setTanflowLoading(true); setTanflowLoading(true);
try { try {
initiateTanflowLogin(); initiateTanflowLogin();
@ -55,7 +55,7 @@ export function Auth() {
console.error('Error details:', loginError); console.error('Error details:', loginError);
setTanflowLoading(false); setTanflowLoading(false);
} }
}; }; */
if (error) { if (error) {
console.error('Auth Error in Auth Component:', { console.error('Auth Error in Auth Component:', {
@ -65,7 +65,7 @@ export function Auth() {
} }
return ( return (
<div <div
className="min-h-screen flex items-center justify-center p-4 relative" className="min-h-screen flex items-center justify-center p-4 relative"
style={{ style={{
backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none', backgroundImage: imageLoaded ? `url(${LandingPageImage})` : 'none',
@ -81,19 +81,19 @@ export function Auth() {
)} )}
{/* Overlay for better readability */} {/* Overlay for better readability */}
<div className="absolute inset-0 bg-black/40"></div> <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"> <Card className="w-full max-w-md shadow-xl relative z-10 bg-black backdrop-blur-sm border-gray-800">
<CardHeader className="space-y-1 text-center pb-6"> <CardHeader className="space-y-1 text-center pb-6">
<div className="flex flex-col items-center justify-center mb-4"> <div className="flex flex-col items-center justify-center mb-4">
<img <img
src={ReLogo} src={ReLogo}
alt="Royal Enfield Logo" alt="Royal Enfield Logo"
className="h-10 w-auto max-w-[168px] object-contain mb-2" className="h-10 w-auto max-w-[168px] object-contain mb-2"
/> />
<p className="text-xs text-gray-300 text-center truncate">Approval Portal</p> <p className="text-xs text-gray-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-900/50 border border-red-700 text-red-200 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">
@ -101,7 +101,7 @@ export function Auth() {
<p className="text-sm">{error.message}</p> <p className="text-sm">{error.message}</p>
</div> </div>
)} )}
<div className="space-y-3"> <div className="space-y-3">
<Button <Button
onClick={handleOKTALogin} onClick={handleOKTALogin}
@ -111,8 +111,8 @@ export function Auth() {
> >
{isLoading ? ( {isLoading ? (
<> <>
<div <div
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
/> />
Logging in... Logging in...
</> </>
@ -123,7 +123,7 @@ export function Auth() {
</> </>
)} )}
</Button> </Button>
{/*
<div className="relative"> <div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-700"></span> <span className="w-full border-t border-gray-700"></span>
@ -152,7 +152,7 @@ export function Auth() {
Dealer Login Dealer Login
</> </>
)} )}
</Button> </Button> */}
</div> </div>
<div className="text-center text-sm text-gray-400 mt-4"> <div className="text-center text-sm text-gray-400 mt-4">

View File

@ -29,14 +29,14 @@ export function ClosedRequestsFilters({
searchTerm, searchTerm,
priorityFilter, priorityFilter,
statusFilter, statusFilter,
templateTypeFilter, // templateTypeFilter,
sortBy, sortBy,
sortOrder, sortOrder,
activeFiltersCount, activeFiltersCount,
onSearchChange, onSearchChange,
onPriorityChange, onPriorityChange,
onStatusChange, onStatusChange,
onTemplateTypeChange, // onTemplateTypeChange,
onSortByChange, onSortByChange,
onSortOrderChange, onSortOrderChange,
onClearFilters, onClearFilters,
@ -75,7 +75,7 @@ export function ClosedRequestsFilters({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6"> <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-5 gap-3 sm:gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
<div className="relative"> <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" /> <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 <Input
@ -86,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" />
@ -107,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" />
@ -129,7 +129,7 @@ export function ClosedRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -138,7 +138,7 @@ export function ClosedRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}> <Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
@ -151,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"

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

View File

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

View File

@ -0,0 +1,135 @@
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';
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: formData.description }}
/>
</div>
{documents.length > 0 && (
<div>
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider block mb-2">Attachments ({documents.length})</span>
<div className="flex flex-wrap gap-2">
{documents.map((doc, i) => (
<Badge key={i} variant="secondary" className="pl-1 pr-2 py-1 flex items-center gap-1.5 h-auto">
<FileText className="w-3 h-3 text-gray-500" />
<span className="truncate max-w-[150px]">{doc.name}</span>
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Approval Workflow</CardTitle>
</CardHeader>
<CardContent>
<div className="relative pl-6 border-l-2 border-gray-100 space-y-8 py-2">
{approvers.map((approver: any, index: number) => (
<div key={index} className="relative">
{/* Timeline dot */}
<div className="absolute -left-[31px] top-1 w-4 h-4 rounded-full bg-white border-2 border-blue-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
</div>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
<div className="flex justify-between items-start mb-1">
<div>
<h5 className="font-semibold text-gray-800 text-sm">{approver.name || approver.email}</h5>
<p className="text-xs text-gray-500">Level {approver.level} Approver</p>
</div>
<Badge variant="outline" className="bg-white text-xs">
{approver.tat || 24} Hours TAT
</Badge>
</div>
<p className="text-xs text-gray-400">{approver.email}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm uppercase text-gray-500">Properties</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Template</span>
<span className="text-sm font-medium text-right">{template.name}</span>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Priority</span>
<Badge className={
template.priority === 'high' ? 'bg-red-100 text-red-700 hover:bg-red-100' :
template.priority === 'medium' ? 'bg-orange-100 text-orange-700 hover:bg-orange-100' :
'bg-green-100 text-green-700 hover:bg-green-100'
}>
{template.priority.toUpperCase()}
</Badge>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-sm text-gray-600">Est. Time</span>
<span className="text-sm text-gray-900">{template.estimatedTime}</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -13,7 +13,7 @@
* - components/ - UI components * - components/ - UI components
*/ */
import { useState, useRef, useEffect, useCallback } 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
@ -142,22 +176,37 @@ export function CreateRequest({
systemPolicy, systemPolicy,
onPolicyViolation: openPolicyViolationModal, 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 });
} }
} }
@ -170,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) {
@ -184,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) {
@ -192,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);
@ -210,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:

View File

@ -32,6 +32,7 @@ interface UseHandlersOptions {
systemPolicy?: SystemPolicy; systemPolicy?: SystemPolicy;
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void; 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({
@ -48,6 +49,7 @@ export function useCreateRequestHandlers({
systemPolicy, systemPolicy,
onPolicyViolation, onPolicyViolation,
onSubmit, onSubmit,
// goToStep,
}: UseHandlersOptions) { }: UseHandlersOptions) {
const navigate = useNavigate(); const navigate = useNavigate();
const [showTemplateModal, setShowTemplateModal] = useState(false); const [showTemplateModal, setShowTemplateModal] = useState(false);
@ -64,8 +66,13 @@ export function useCreateRequestHandlers({
const suggestedDate = new Date(); const suggestedDate = new Date();
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA); suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
updateFormData('slaEndDate', suggestedDate); updateFormData('slaEndDate', suggestedDate);
// Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep) // Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep)
if (template.id !== 'custom' && template.id !== 'existing-template') {
// Redirect to dedicated Admin Request flow
navigate(`/create-admin-request/${template.id}`);
}
}; };
const handleTemplateSelection = (templateId: string) => { const handleTemplateSelection = (templateId: string) => {

View File

@ -22,11 +22,11 @@ export function MyRequestsFilters({
searchTerm, searchTerm,
statusFilter, statusFilter,
priorityFilter, priorityFilter,
templateTypeFilter, // templateTypeFilter,
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
onTemplateTypeChange, // onTemplateTypeChange,
}: MyRequestsFiltersProps) { }: MyRequestsFiltersProps) {
return ( return (
<Card className="border-gray-200" data-testid="my-requests-filters"> <Card className="border-gray-200" data-testid="my-requests-filters">
@ -76,7 +76,7 @@ export function MyRequestsFilters({
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}> {/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
<SelectTrigger <SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10" className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="template-type-filter" data-testid="template-type-filter"
@ -88,7 +88,7 @@ export function MyRequestsFilters({
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -18,9 +18,9 @@ interface Request {
status: 'pending' | 'approved' | 'rejected' | 'closed' | 'paused'; status: 'pending' | 'approved' | 'rejected' | 'closed' | 'paused';
priority: 'express' | 'standard'; priority: 'express' | 'standard';
initiator: { name: string; avatar: string }; initiator: { name: string; avatar: string };
currentApprover?: { currentApprover?: {
name: string; name: string;
avatar: string; avatar: string;
sla?: any; // Backend-calculated SLA data sla?: any; // Backend-calculated SLA data
}; };
createdAt: string; createdAt: string;
@ -106,13 +106,13 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const [items, setItems] = useState<Request[]>([]); const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// Pagination states (currentPage now in Redux) // Pagination states (currentPage now in Redux)
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
const fetchRequestsRef = useRef<any>(null); const fetchRequestsRef = useRef<any>(null);
// Use Redux for filters with callback (persists during navigation) // Use Redux for filters with callback (persists during navigation)
const filters = useOpenRequestsFilters(); const filters = useOpenRequestsFilters();
@ -163,12 +163,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
setLoading(true); setLoading(true);
setItems([]); setItems([]);
} }
// Always use user-scoped endpoint (not organization-wide) // Always use user-scoped endpoint (not organization-wide)
// Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user) // Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user)
// For organization-wide requests, use the "All Requests" screen (/requests) // For organization-wide requests, use the "All Requests" screen (/requests)
const result = await workflowApi.listOpenForMe({ const result = await workflowApi.listOpenForMe({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
search: filterParams?.search, search: filterParams?.search,
status: filterParams?.status, status: filterParams?.status,
@ -177,12 +177,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
sortBy: filterParams?.sortBy, sortBy: filterParams?.sortBy,
sortOrder: filterParams?.sortOrder sortOrder: filterParams?.sortOrder
}); });
// Extract data - workflowApi now returns { data: [], pagination: {} } // Extract data - workflowApi now returns { data: [], pagination: {} }
const data = Array.isArray((result as any)?.data) const data = Array.isArray((result as any)?.data)
? (result as any).data ? (result as any).data
: []; : [];
// Set pagination data // Set pagination data
const pagination = (result as any)?.pagination; const pagination = (result as any)?.pagination;
if (pagination) { if (pagination) {
@ -190,10 +190,10 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
setTotalPages(pagination.totalPages || 1); setTotalPages(pagination.totalPages || 1);
setTotalRecords(pagination.total || 0); setTotalRecords(pagination.total || 0);
} }
const mapped: Request[] = data.map((r: any) => { const mapped: Request[] = data.map((r: any) => {
const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at; const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at;
return { return {
id: r.requestNumber || r.request_number || r.requestId, id: r.requestNumber || r.request_number || r.requestId,
requestId: r.requestId, requestId: r.requestId,
@ -202,13 +202,13 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
description: r.description, description: r.description,
status: (r.status || '').toString().toLowerCase().replace('_', '-'), status: (r.status || '').toString().toLowerCase().replace('_', '-'),
priority: (r.priority || '').toString().toLowerCase(), priority: (r.priority || '').toString().toLowerCase(),
initiator: { initiator: {
name: (r.initiator?.displayName || r.initiator?.email || '—'), name: (r.initiator?.displayName || r.initiator?.email || '—'),
avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase())
}, },
currentApprover: r.currentApprover ? { currentApprover: r.currentApprover ? {
name: (r.currentApprover.name || r.currentApprover.email || '—'), name: (r.currentApprover.name || r.currentApprover.email || '—'),
avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()), avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase()),
sla: r.currentApprover.sla // ← Backend-calculated SLA sla: r.currentApprover.sla // ← Backend-calculated SLA
} : undefined, } : undefined,
createdAt: createdAt || '—', createdAt: createdAt || '—',
@ -224,7 +224,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
setRefreshing(false); setRefreshing(false);
} }
}, [itemsPerPage, filters]); }, [itemsPerPage, filters]);
fetchRequestsRef.current = fetchRequests; fetchRequestsRef.current = fetchRequests;
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
@ -244,21 +244,21 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const maxPagesToShow = 5; const maxPagesToShow = 5;
let startPage = Math.max(1, filters.currentPage - Math.floor(maxPagesToShow / 2)); let startPage = Math.max(1, filters.currentPage - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1); let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) { if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1); startPage = Math.max(1, endPage - maxPagesToShow + 1);
} }
for (let i = startPage; i <= endPage; i++) { for (let i = startPage; i <= endPage; i++) {
pages.push(i); pages.push(i);
} }
return pages; return pages;
}; };
// Track if this is initial mount // Track if this is initial mount
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
// Initial fetch on mount - use stored page from Redux // Initial fetch on mount - use stored page from Redux
useEffect(() => { useEffect(() => {
if (!hasInitialFetchRun.current) { if (!hasInitialFetchRun.current) {
@ -268,7 +268,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount }, []); // Only on mount
// Track previous filter values to detect changes // Track previous filter values to detect changes
const prevFiltersRef = useRef({ const prevFiltersRef = useRef({
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
@ -284,11 +284,11 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
useEffect(() => { useEffect(() => {
// Skip until initial fetch has completed // Skip until initial fetch has completed
if (!hasInitialFetchRun.current) return; if (!hasInitialFetchRun.current) return;
const prev = prevFiltersRef.current; const prev = prevFiltersRef.current;
// Check if any filter actually changed // Check if any filter actually changed
const hasChanged = const hasChanged =
prev.searchTerm !== filters.searchTerm || prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
@ -303,7 +303,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); // Reset to page 1 when filters change (but not on initial mount or back navigation) filters.setCurrentPage(1); // Reset to page 1 when filters change (but not on initial mount or back navigation)
fetchRequests(1, getFilterParams(true)); fetchRequests(1, getFilterParams(true));
// Update previous filters ref // Update previous filters ref
prevFiltersRef.current = { prevFiltersRef.current = {
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
@ -325,7 +325,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const filteredAndSortedRequests = items; const filteredAndSortedRequests = items;
return ( return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto"> <div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto">
{/* Enhanced Header */} {/* Enhanced Header */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6">
@ -340,15 +340,15 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<Badge variant="secondary" className="text-xs sm:text-sm md:text-base px-2 sm:px-3 md:px-4 py-1 sm:py-1.5 md:py-2 bg-slate-100 text-slate-800 font-semibold"> <Badge variant="secondary" className="text-xs sm:text-sm md:text-base px-2 sm:px-3 md:px-4 py-1 sm:py-1.5 md:py-2 bg-slate-100 text-slate-800 font-semibold">
{loading ? 'Loading…' : `${totalRecords || items.length} open`} {loading ? 'Loading…' : `${totalRecords || items.length} open`}
<span className="hidden sm:inline ml-1">requests</span> <span className="hidden sm:inline ml-1">requests</span>
</Badge> </Badge>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="gap-1 sm:gap-2 h-8 sm:h-9" className="gap-1 sm:gap-2 h-8 sm:h-9"
onClick={handleRefresh} onClick={handleRefresh}
disabled={refreshing} disabled={refreshing}
@ -382,10 +382,10 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
{filteredAndSortedRequests.map((request) => { {filteredAndSortedRequests.map((request) => {
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status); const statusConfig = getStatusConfig(request.status);
return ( return (
<Card <Card
key={request.id} key={request.id}
className="group hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200 hover:border-blue-400 hover:scale-[1.002]" className="group hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200 hover:border-blue-400 hover:scale-[1.002]"
onClick={() => onViewRequest?.(request.id, request.title)} onClick={() => onViewRequest?.(request.id, request.title)}
> >
@ -405,8 +405,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors"> <h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
{(request as any).displayId || request.id} {(request as any).displayId || request.id}
</h3> </h3>
<Badge <Badge
variant="outline" variant="outline"
className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`} className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
> >
<statusConfig.icon className="w-3.5 h-3.5 mr-1" /> <statusConfig.icon className="w-3.5 h-3.5 mr-1" />
@ -417,8 +417,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
{request.department} {request.department}
</Badge> </Badge>
)} )}
<Badge <Badge
variant="outline" variant="outline"
className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`} className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`}
> >
{request.priority} {request.priority}
@ -427,18 +427,18 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
{(() => { {(() => {
const templateType = (request as any)?.templateType || (request as any)?.template_type || ''; const templateType = (request as any)?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; 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 = 'Dealer Claim'; 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';
} }
return ( return (
<Badge <Badge
variant="outline" variant="outline"
@ -460,15 +460,15 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
{request.currentLevelSLA && (() => { {request.currentLevelSLA && (() => {
// Check pause status from isPaused field, pauseInfo, OR status field // Check pause status from isPaused field, pauseInfo, OR status field
const isPaused = Boolean( const isPaused = Boolean(
request.isPaused || request.isPaused ||
request.pauseInfo?.isPaused || request.pauseInfo?.isPaused ||
request.status === 'paused' request.status === 'paused'
); );
// Use percentage-based colors to match approver SLA tracker // Use percentage-based colors to match approver SLA tracker
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached) // Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
// Grey: When paused (frozen state) // Grey: When paused (frozen state)
const percentUsed = request.currentLevelSLA.percentageUsed || 0; const percentUsed = request.currentLevelSLA.percentageUsed || 0;
const getSLAColors = () => { const getSLAColors = () => {
// If paused, always use grey colors (frozen state) // If paused, always use grey colors (frozen state)
if (isPaused) { if (isPaused) {
@ -479,7 +479,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
icon: 'text-gray-600' icon: 'text-gray-600'
}; };
} }
if (percentUsed >= 100) { if (percentUsed >= 100) {
return { return {
bg: 'bg-red-50 border border-red-200', bg: 'bg-red-50 border border-red-200',
@ -510,9 +510,9 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
}; };
} }
}; };
const colors = getSLAColors(); const colors = getSLAColors();
return ( return (
<div className={`p-2 rounded-md ${colors.bg}`}> <div className={`p-2 rounded-md ${colors.bg}`}>
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
@ -533,8 +533,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</span> </span>
</div> </div>
</div> </div>
<Progress <Progress
value={percentUsed} value={percentUsed}
className="h-1.5" className="h-1.5"
indicatorClassName={colors.progress} indicatorClassName={colors.progress}
/> />
@ -552,7 +552,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</Avatar> </Avatar>
<span className="font-medium text-gray-900">{request.initiator.name}</span> <span className="font-medium text-gray-900">{request.initiator.name}</span>
</div> </div>
{request.currentApprover && ( {request.currentApprover && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Avatar className="h-6 w-6 ring-2 ring-yellow-200 shadow-sm"> <Avatar className="h-6 w-6 ring-2 ring-yellow-200 shadow-sm">
@ -563,14 +563,14 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<span className="font-medium text-gray-900">{request.currentApprover.name}</span> <span className="font-medium text-gray-900">{request.currentApprover.name}</span>
</div> </div>
)} )}
{request.approvalStep && ( {request.approvalStep && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 text-blue-500" /> <AlertCircle className="w-3.5 h-3.5 text-blue-500" />
<span className="font-medium">{request.approvalStep}</span> <span className="font-medium">{request.approvalStep}</span>
</div> </div>
)} )}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" /> <Calendar className="w-3.5 h-3.5" />
<span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt) : '—'}</span> <span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt) : '—'}</span>
@ -604,8 +604,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
} }
</p> </p>
{filters.activeFiltersCount > 0 && ( {filters.activeFiltersCount > 0 && (
<Button <Button
variant="outline" variant="outline"
className="mt-4" className="mt-4"
onClick={filters.clearFilters} onClick={filters.clearFilters}
> >
@ -624,7 +624,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<div className="text-xs sm:text-sm text-muted-foreground"> <div className="text-xs sm:text-sm text-muted-foreground">
Showing {((filters.currentPage - 1) * itemsPerPage) + 1} to {Math.min(filters.currentPage * itemsPerPage, totalRecords)} of {totalRecords} open requests Showing {((filters.currentPage - 1) * itemsPerPage) + 1} to {Math.min(filters.currentPage * itemsPerPage, totalRecords)} of {totalRecords} open requests
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
@ -635,14 +635,14 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
> >
<ArrowRight className="h-4 w-4 rotate-180" /> <ArrowRight className="h-4 w-4 rotate-180" />
</Button> </Button>
{filters.currentPage > 3 && totalPages > 5 && ( {filters.currentPage > 3 && totalPages > 5 && (
<> <>
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} className="h-8 w-8 p-0">1</Button> <Button variant="outline" size="sm" onClick={() => handlePageChange(1)} className="h-8 w-8 p-0">1</Button>
<span className="text-muted-foreground">...</span> <span className="text-muted-foreground">...</span>
</> </>
)} )}
{getPageNumbers().map((pageNum) => ( {getPageNumbers().map((pageNum) => (
<Button <Button
key={pageNum} key={pageNum}
@ -654,14 +654,14 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
{pageNum} {pageNum}
</Button> </Button>
))} ))}
{filters.currentPage < totalPages - 2 && totalPages > 5 && ( {filters.currentPage < totalPages - 2 && totalPages > 5 && (
<> <>
<span className="text-muted-foreground">...</span> <span className="text-muted-foreground">...</span>
<Button variant="outline" size="sm" onClick={() => handlePageChange(totalPages)} className="h-8 w-8 p-0">{totalPages}</Button> <Button variant="outline" size="sm" onClick={() => handlePageChange(totalPages)} className="h-8 w-8 p-0">{totalPages}</Button>
</> </>
)} )}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"

View File

@ -49,10 +49,10 @@ import { CustomDatePicker } from '@/components/ui/date-picker';
export function Requests({ onViewRequest }: RequestsProps) { export function Requests({ onViewRequest }: RequestsProps) {
const { user } = useAuth(); const { user } = useAuth();
// Get viewAsUser from Redux store (synced with Dashboard toggle) // Get viewAsUser from Redux store (synced with Dashboard toggle)
const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser); const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser);
// Determine if viewing at organization level: // Determine if viewing at organization level:
// - If user is admin/management AND not in "Personal" mode (viewAsUser=false) → show all requests // - If user is admin/management AND not in "Personal" mode (viewAsUser=false) → show all requests
// - If user is admin/management AND in "Personal" mode (viewAsUser=true) → show only their requests // - If user is admin/management AND in "Personal" mode (viewAsUser=true) → show only their requests
@ -95,15 +95,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Status filter should not affect stats - stats should always show all status counts // Status filter should not affect stats - stats should always show all status counts
// For user-level (Personal mode), stats will only include requests where user is involved // For user-level (Personal mode), stats will only include requests where user is involved
const fetchBackendStats = useCallback(async ( const fetchBackendStats = useCallback(async (
statsDateRange?: DateRange, statsDateRange?: DateRange,
statsStartDate?: Date, statsStartDate?: Date,
statsEndDate?: Date, statsEndDate?: Date,
filtersWithoutStatus?: { filtersWithoutStatus?: {
priority?: string; priority?: string;
department?: string; department?: string;
initiator?: string; initiator?: string;
approver?: string; approver?: string;
approverType?: 'current' | 'any'; approverType?: 'current' | 'any';
search?: string; search?: string;
slaCompliance?: string; slaCompliance?: string;
} }
@ -112,9 +112,9 @@ export function Requests({ onViewRequest }: RequestsProps) {
// For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich // For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich
// because these are calculated dynamically, not stored in DB // because these are calculated dynamically, not stored in DB
const slaCompliance = filtersWithoutStatus?.slaCompliance; const slaCompliance = filtersWithoutStatus?.slaCompliance;
const isDynamicSlaStatus = slaCompliance && const isDynamicSlaStatus = slaCompliance &&
slaCompliance !== 'all' && slaCompliance !== 'all' &&
slaCompliance !== 'breached' && slaCompliance !== 'breached' &&
slaCompliance !== 'compliant'; slaCompliance !== 'compliant';
if (isDynamicSlaStatus) { if (isDynamicSlaStatus) {
@ -141,12 +141,12 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Fetch up to 1000 requests (backend will enrich and filter by SLA) // Fetch up to 1000 requests (backend will enrich and filter by SLA)
// Use appropriate API based on org/personal mode // Use appropriate API based on org/personal mode
const result = isOrgLevel const result = isOrgLevel
? await workflowApi.listWorkflows({ page: 1, limit: 1000, ...backendFilters }) ? await workflowApi.listWorkflows({ page: 1, limit: 1000, ...backendFilters })
: await workflowApi.listParticipantRequests({ page: 1, limit: 1000, ...backendFilters }); : await workflowApi.listParticipantRequests({ page: 1, limit: 1000, ...backendFilters });
const filteredData = Array.isArray(result?.data) ? result.data : []; const filteredData = Array.isArray(result?.data) ? result.data : [];
// Calculate stats from filtered data // Calculate stats from filtered data
const total = filteredData.length; const total = filteredData.length;
const pending = filteredData.filter((r: any) => { const pending = filteredData.filter((r: any) => {
@ -244,7 +244,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
const fetchBackendStatsRef = useRef(fetchBackendStats); const fetchBackendStatsRef = useRef(fetchBackendStats);
// Update refs on each render // Update refs on each render
useEffect(() => { useEffect(() => {
filtersRef.current = filters; filtersRef.current = filters;
@ -254,7 +254,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Parse URL search params // Parse URL search params
useEffect(() => { useEffect(() => {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
// Approver Filter (from Dashboard TAT Breach Report link) // Approver Filter (from Dashboard TAT Breach Report link)
const approver = params.get('approver'); const approver = params.get('approver');
const approverType = params.get('approverType'); const approverType = params.get('approverType');
@ -341,35 +341,35 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Total changes when other filters are applied, but stays stable when only status changes // Total changes when other filters are applied, but stays stable when only status changes
// Stats are fetched for both org-level AND user-level (Personal mode) views // Stats are fetched for both org-level AND user-level (Personal mode) views
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
const filtersWithoutStatus = { const filtersWithoutStatus = {
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined, templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined, department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined, initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined, approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
search: filters.searchTerm || undefined, search: filters.searchTerm || undefined,
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
}; };
// All Requests (admin/normal user) should always have a date range // All Requests (admin/normal user) should always have a date range
// Default to 'all' if no date range is selected // Default to 'all' if no date range is selected
const statsDateRange = filters.dateRange || 'all'; const statsDateRange = filters.dateRange || 'all';
fetchBackendStatsRef.current(
statsDateRange,
filters.customStartDate,
filters.customEndDate,
filtersWithoutStatus
);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId); fetchBackendStatsRef.current(
statsDateRange,
filters.customStartDate,
filters.customEndDate,
filtersWithoutStatus
);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
isOrgLevel, isOrgLevel,
filters.dateRange, filters.dateRange,
filters.customStartDate, filters.customStartDate,
filters.customEndDate, filters.customEndDate,
filters.priorityFilter, filters.priorityFilter,
filters.templateTypeFilter, filters.templateTypeFilter,
@ -399,7 +399,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
isOrgLevel, isOrgLevel,
}); });
const hasInitialFetchRun = useRef(false); const hasInitialFetchRun = useRef(false);
// Initial fetch on mount - use stored page from Redux // Initial fetch on mount - use stored page from Redux
useEffect(() => { useEffect(() => {
const storedPage = filters.currentPage || 1; const storedPage = filters.currentPage || 1;
@ -407,13 +407,13 @@ export function Requests({ onViewRequest }: RequestsProps) {
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 }, []); // Only on mount
// Fetch when filters change or isOrgLevel changes // Fetch when filters change or isOrgLevel changes
useEffect(() => { useEffect(() => {
if (!hasInitialFetchRun.current) return; if (!hasInitialFetchRun.current) return;
const prev = prevFiltersRef.current; const prev = prevFiltersRef.current;
const hasChanged = const hasChanged =
prev.searchTerm !== filters.searchTerm || prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter || prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter || prev.priorityFilter !== filters.priorityFilter ||
@ -427,13 +427,13 @@ export function Requests({ onViewRequest }: RequestsProps) {
prev.customStartDate !== filters.customStartDate || prev.customStartDate !== filters.customStartDate ||
prev.customEndDate !== filters.customEndDate || prev.customEndDate !== filters.customEndDate ||
prev.isOrgLevel !== isOrgLevel; prev.isOrgLevel !== isOrgLevel;
if (!hasChanged) return; if (!hasChanged) return;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); filters.setCurrentPage(1);
fetchRequests(1); fetchRequests(1);
prevFiltersRef.current = { prevFiltersRef.current = {
searchTerm: filters.searchTerm, searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter, statusFilter: filters.statusFilter,
@ -495,7 +495,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
closed: backendStats.closed || 0 closed: backendStats.closed || 0
}; };
} }
// Fallback: Calculate from paginated data (less accurate, but better than nothing) // Fallback: Calculate from paginated data (less accurate, but better than nothing)
return calculateStatsFromFilteredData( return calculateStatsFromFilteredData(
[], // Empty - we'll use backendStats or fallback [], // Empty - we'll use backendStats or fallback
@ -521,8 +521,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
/> />
{/* Stats */} {/* Stats */}
<RequestsStats <RequestsStats
stats={stats} stats={stats}
onStatusFilter={(status) => { onStatusFilter={(status) => {
filters.setStatusFilter(status); filters.setStatusFilter(status);
}} }}
@ -553,7 +553,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
<Separator /> <Separator />
{/* Primary Filters */} {/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4"> <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"> <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" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input <Input
@ -590,7 +590,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}> {/* <Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
<SelectTrigger className="h-10" data-testid="template-type-filter"> <SelectTrigger className="h-10" data-testid="template-type-filter">
<SelectValue placeholder="All Templates" /> <SelectValue placeholder="All Templates" />
</SelectTrigger> </SelectTrigger>
@ -599,7 +599,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
<SelectItem value="CUSTOM">Non-Templatized</SelectItem> <SelectItem value="CUSTOM">Non-Templatized</SelectItem>
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem> <SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select> */}
<Select <Select
value={filters.departmentFilter} value={filters.departmentFilter}
@ -792,23 +792,23 @@ export function Requests({ onViewRequest }: RequestsProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label> <Label htmlFor="start-date">Start Date</Label>
<CustomDatePicker <CustomDatePicker
value={filters.customStartDate || null} value={filters.customStartDate || null}
onChange={(dateStr: string | null) => { onChange={(dateStr: string | null) => {
const date = dateStr ? new Date(dateStr) : undefined; const date = dateStr ? new Date(dateStr) : undefined;
if (date) { if (date) {
filters.setCustomStartDate(date); filters.setCustomStartDate(date);
if (filters.customEndDate && date > filters.customEndDate) { if (filters.customEndDate && date > filters.customEndDate) {
filters.setCustomEndDate(date); filters.setCustomEndDate(date);
}
} else {
filters.setCustomStartDate(undefined);
} }
}} } else {
maxDate={new Date()} filters.setCustomStartDate(undefined);
placeholderText="dd/mm/yyyy" }
className="w-full" }}
/> maxDate={new Date()}
placeholderText="dd/mm/yyyy"
className="w-full"
/>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="end-date">End Date</Label> <Label htmlFor="end-date">End Date</Label>

View File

@ -15,23 +15,23 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
*/ */
const stripHtmlTags = (html: string): string => { const stripHtmlTags = (html: string): string => {
if (!html) return ''; if (!html) return '';
// Check if we're in a browser environment // Check if we're in a browser environment
if (typeof document === 'undefined') { if (typeof document === 'undefined') {
// Fallback for SSR: use regex to strip HTML tags // Fallback for SSR: use regex to strip HTML tags
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
} }
// Create a temporary div to parse HTML // Create a temporary div to parse HTML
const tempDiv = document.createElement('div'); const tempDiv = document.createElement('div');
tempDiv.innerHTML = html; tempDiv.innerHTML = html;
// Get text content (automatically strips HTML tags) // Get text content (automatically strips HTML tags)
let text = tempDiv.textContent || tempDiv.innerText || ''; let text = tempDiv.textContent || tempDiv.innerText || '';
// Clean up extra whitespace // Clean up extra whitespace
text = text.replace(/\s+/g, ' ').trim(); text = text.replace(/\s+/g, ' ').trim();
return text; return text;
}; };
@ -100,18 +100,18 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
{(() => { {(() => {
const templateType = request?.templateType || (request as any)?.template_type || ''; const templateType = request?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; 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 = 'Dealer Claim'; 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';
} }
return ( return (
<Badge <Badge
variant="outline" variant="outline"

View File

@ -1,9 +1,9 @@
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Settings as SettingsIcon, Settings as SettingsIcon,
Bell, Bell,
Shield, Shield,
Palette, Palette,
Lock, Lock,
@ -54,7 +54,7 @@ export function Settings() {
const handleEnableNotifications = async () => { const handleEnableNotifications = async () => {
setIsEnablingNotifications(true); setIsEnablingNotifications(true);
setShowNotificationModal(false); setShowNotificationModal(false);
try { try {
// Check if notifications are supported // Check if notifications are supported
if (!('Notification' in window)) { if (!('Notification' in window)) {
@ -67,7 +67,7 @@ export function Settings() {
// Check current permission status BEFORE attempting to enable // Check current permission status BEFORE attempting to enable
let permission = Notification.permission; let permission = Notification.permission;
// If permission was previously denied, show user-friendly instructions // If permission was previously denied, show user-friendly instructions
if (permission === 'denied') { if (permission === 'denied') {
setNotificationSuccess(false); setNotificationSuccess(false);
@ -87,7 +87,7 @@ export function Settings() {
// If permission is 'default', request it first // If permission is 'default', request it first
if (permission === 'default') { if (permission === 'default') {
permission = await Notification.requestPermission(); permission = await Notification.requestPermission();
// If user denied the permission request // If user denied the permission request
if (permission === 'denied') { if (permission === 'denied') {
setNotificationSuccess(false); setNotificationSuccess(false);
@ -126,13 +126,13 @@ export function Settings() {
// Provide more specific error messages // Provide more specific error messages
const errorMessage = error?.message || 'Unknown error occurred'; const errorMessage = error?.message || 'Unknown error occurred';
setNotificationMessage( setNotificationMessage(
errorMessage.includes('permission') errorMessage.includes('permission')
? 'Notification permission was denied. Please enable notifications in your browser settings and try again.' ? 'Notification permission was denied. Please enable notifications in your browser settings and try again.'
: errorMessage.includes('Service worker') : errorMessage.includes('Service worker')
? 'Service worker registration failed. Please refresh the page and try again.' ? 'Service worker registration failed. Please refresh the page and try again.'
: errorMessage.includes('token') : errorMessage.includes('token')
? 'Authentication required. Please log in again and try enabling notifications.' ? 'Authentication required. Please log in again and try enabling notifications.'
: `Unable to enable push notifications: ${errorMessage}` : `Unable to enable push notifications: ${errorMessage}`
); );
setShowNotificationModal(true); setShowNotificationModal(true);
} finally { } finally {
@ -147,7 +147,7 @@ export function Settings() {
<Card className="relative overflow-hidden shadow-2xl border-0 rounded-2xl"> <Card className="relative overflow-hidden shadow-2xl border-0 rounded-2xl">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div> <div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-yellow-400/20 via-transparent to-transparent"></div> <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top_right,_var(--tw-gradient-stops))] from-yellow-400/20 via-transparent to-transparent"></div>
<CardContent className="relative z-10 p-6 sm:p-8 lg:p-12"> <CardContent className="relative z-10 p-6 sm:p-8 lg:p-12">
<div className="flex items-center gap-4 sm:gap-6"> <div className="flex items-center gap-4 sm:gap-6">
<div className="w-14 h-14 sm:w-16 sm:h-16 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-2xl flex items-center justify-center shadow-xl transform hover:scale-105 transition-transform"> <div className="w-14 h-14 sm:w-16 sm:h-16 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-2xl flex items-center justify-center shadow-xl transform hover:scale-105 transition-transform">
@ -164,47 +164,47 @@ export function Settings() {
{/* Tabs for Admin, Cards for Non-Admin */} {/* Tabs for Admin, Cards for Non-Admin */}
{isAdmin ? ( {isAdmin ? (
<Tabs defaultValue="user" className="w-full"> <Tabs defaultValue="user" className="w-full">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5 mb-8 bg-slate-100 p-1 rounded-xl h-auto gap-1"> <TabsList className="grid w-full grid-cols-2 lg:grid-cols-4 mb-8 bg-slate-100 p-1 rounded-xl h-auto gap-1">
<TabsTrigger <TabsTrigger
value="user" value="user"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<SettingsIcon className="w-4 h-4" /> <SettingsIcon className="w-4 h-4" />
<span className="hidden sm:inline">User Settings</span> <span className="hidden sm:inline">User Settings</span>
<span className="sm:hidden">User</span> <span className="sm:hidden">User</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="roles" value="roles"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<Shield className="w-4 h-4" /> <Shield className="w-4 h-4" />
<span className="hidden sm:inline">User Roles</span> <span className="hidden sm:inline">User Roles</span>
<span className="sm:hidden">Roles</span> <span className="sm:hidden">Roles</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="system" value="system"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<Sliders className="w-4 h-4" /> <Sliders className="w-4 h-4" />
<span className="hidden sm:inline">Configuration</span> <span className="hidden sm:inline">Configuration</span>
<span className="sm:hidden">Config</span> <span className="sm:hidden">Config</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="holidays" value="holidays"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
<span className="hidden sm:inline">Holidays</span> <span className="hidden sm:inline">Holidays</span>
<span className="sm:hidden">Holidays</span> <span className="sm:hidden">Holidays</span>
</TabsTrigger> </TabsTrigger>
<TabsTrigger {/* <TabsTrigger
value="templates" value="templates"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all" className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
> >
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
<span className="hidden sm:inline">Templates</span> <span className="hidden sm:inline">Templates</span>
<span className="sm:hidden">Templates</span> <span className="sm:hidden">Templates</span>
</TabsTrigger> </TabsTrigger> */}
</TabsList> </TabsList>
{/* Fixed width container to prevent layout shifts */} {/* Fixed width container to prevent layout shifts */}
@ -240,12 +240,12 @@ export function Settings() {
) : ( ) : (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md"> <div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-xs text-blue-800"> <p className="text-xs text-blue-800">
Click below to register this browser for receiving push notifications. Click below to register this browser for receiving push notifications.
This needs to be done once per browser/device. This needs to be done once per browser/device.
</p> </p>
</div> </div>
)} )}
<Button <Button
onClick={handleEnableNotifications} onClick={handleEnableNotifications}
disabled={isEnablingNotifications || hasSubscription} disabled={isEnablingNotifications || hasSubscription}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@ -315,7 +315,7 @@ export function Settings() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button <Button
onClick={() => setShowPreferencesModal(true)} onClick={() => setShowPreferencesModal(true)}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all" className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
> >
@ -362,7 +362,7 @@ export function Settings() {
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="space-y-4"> <div className="space-y-4">
{/* Dealer Claim Activity Settings Card */} {/* Dealer Claim Activity Settings Card */}
<Card <Card
className="shadow-md hover:shadow-lg transition-all duration-300 border border-slate-200 rounded-lg cursor-pointer group" className="shadow-md hover:shadow-lg transition-all duration-300 border border-slate-200 rounded-lg cursor-pointer group"
onClick={() => setShowActivityTypeManager(true)} onClick={() => setShowActivityTypeManager(true)}
> >
@ -460,12 +460,12 @@ export function Settings() {
) : ( ) : (
<div className="p-3 bg-blue-50 border border-blue-200 rounded-md"> <div className="p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-xs text-blue-800"> <p className="text-xs text-blue-800">
Click below to register this browser for receiving push notifications. Click below to register this browser for receiving push notifications.
This needs to be done once per browser/device. This needs to be done once per browser/device.
</p> </p>
</div> </div>
)} )}
<Button <Button
onClick={handleEnableNotifications} onClick={handleEnableNotifications}
disabled={isEnablingNotifications || hasSubscription} disabled={isEnablingNotifications || hasSubscription}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@ -535,7 +535,7 @@ export function Settings() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Button <Button
onClick={() => setShowPreferencesModal(true)} onClick={() => setShowPreferencesModal(true)}
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all" className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all"
> >
@ -548,14 +548,14 @@ export function Settings() {
</> </>
)} )}
</div> </div>
<NotificationStatusModal <NotificationStatusModal
open={showNotificationModal} open={showNotificationModal}
onClose={() => setShowNotificationModal(false)} onClose={() => setShowNotificationModal(false)}
success={notificationSuccess} success={notificationSuccess}
message={notificationMessage} message={notificationMessage}
/> />
<NotificationPreferencesModal <NotificationPreferencesModal
open={showPreferencesModal} open={showPreferencesModal}
onClose={() => setShowPreferencesModal(false)} onClose={() => setShowPreferencesModal(false)}

View File

@ -0,0 +1,95 @@
import apiClient from './authApi';
export interface WorkflowTemplate {
id: string;
name: string;
description: string;
category: string;
priority: 'low' | 'medium' | 'high';
estimatedTime: string;
approvers: any[];
suggestedSLA: number;
isActive: boolean;
fields?: any;
}
// Simple in-memory cache
let templatesCache: WorkflowTemplate[] | null = null;
export const getCachedTemplates = () => templatesCache;
export const createTemplate = async (templateData: Partial<WorkflowTemplate>): Promise<WorkflowTemplate> => {
const response = await apiClient.post('/templates', templateData);
const t = response.data.data;
// Map backend response
const mappedTemplate = {
id: t.templateId || t.id,
name: t.templateName || t.name,
description: t.templateDescription || t.description,
category: t.templateCategory || t.category,
priority: t.priority || 'medium',
estimatedTime: t.estimatedTime || 'Variable',
approvers: t.approvalLevelsConfig || t.approvers || [],
suggestedSLA: t.defaultTatHours || t.suggestedSLA || 24,
isActive: t.isActive,
fields: t.userFieldMappings || t.fields
};
// Invalidate cache or add to it
if (templatesCache) templatesCache.push(mappedTemplate as WorkflowTemplate);
return mappedTemplate as WorkflowTemplate;
};
export const getTemplates = async (): Promise<WorkflowTemplate[]> => {
const response = await apiClient.get('/templates');
const data = response.data?.data || [];
// Map backend response to frontend interface
const mappedData = data.map((t: any) => ({
id: t.templateId || t.id,
name: t.templateName || t.name,
description: t.templateDescription || t.description,
category: t.templateCategory || t.category,
priority: t.priority || 'medium', // Default if missing
estimatedTime: t.estimatedTime || 'Variable',
approvers: t.approvalLevelsConfig || t.approvers || [],
suggestedSLA: t.defaultTatHours || t.suggestedSLA || 24,
isActive: t.isActive,
fields: t.userFieldMappings || t.fields
}));
templatesCache = mappedData;
return mappedData;
};
export const deleteTemplate = async (id: string): Promise<void> => {
await apiClient.delete(`/templates/${id}`);
if (templatesCache) {
templatesCache = templatesCache.filter(t => t.id !== id);
}
};
export const updateTemplate = async (id: string, templateData: Partial<WorkflowTemplate>): Promise<WorkflowTemplate> => {
const response = await apiClient.put(`/templates/${id}`, templateData);
const t = response.data.data;
// Map backend response
const mappedTemplate = {
id: t.templateId || t.id,
name: t.templateName || t.name,
description: t.templateDescription || t.description,
category: t.templateCategory || t.category,
priority: t.priority || 'medium',
estimatedTime: t.estimatedTime || 'Variable',
approvers: t.approvalLevelsConfig || t.approvers || [],
suggestedSLA: t.defaultTatHours || t.suggestedSLA || 24,
isActive: t.isActive,
fields: t.userFieldMappings || t.fields
};
if (templatesCache) {
templatesCache = templatesCache.map(cacheItem => cacheItem.id === id ? (mappedTemplate as WorkflowTemplate) : cacheItem);
}
return mappedTemplate as WorkflowTemplate;
};