diff --git a/src/App.tsx b/src/App.tsx index 62ea014..17216cd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ 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 { Dashboard } from '@/pages/Dashboard'; import { OpenRequests } from '@/pages/OpenRequests'; @@ -21,6 +21,9 @@ import { Settings } from '@/pages/Settings'; import { Notifications } from '@/pages/Notifications'; import { DetailedReports } from '@/pages/DetailedReports'; import { Admin } from '@/pages/Admin'; +import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList'; +import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate'; +import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; @@ -40,7 +43,7 @@ interface AppProps { function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) { const { user } = useAuth(); const isAdmin = hasManagementAccess(user); - + // Render separate screens based on user role // Admin/Management users see all organization requests // Regular users see only their participant requests (approver/spectator, NOT initiator) @@ -152,7 +155,7 @@ function AppRoutes({ onLogout }: AppProps) { const handleViewRequest = (requestId: string, requestTitle?: string, status?: string, request?: any) => { setSelectedRequestId(requestId); setSelectedRequestTitle(requestTitle || 'Unknown Request'); - + // Use global navigation utility for consistent routing navigateToRequest({ requestId, @@ -180,18 +183,18 @@ function AppRoutes({ onLogout }: AppProps) { } return; } - + // If requestData has backendId, it means it came from the API flow (CreateRequest component) // The hook already shows the toast, so we just navigate if (requestData.backendId) { navigate('/my-requests'); return; } - + // Regular custom request submission (old flow without API) // 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')}`; - + // Create full custom request object const newCustomRequest = { id: requestId, @@ -219,21 +222,21 @@ function AppRoutes({ onLogout }: AppProps) { avatar: 'CU' }, department: requestData.department || 'General', - createdAt: new Date().toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', + createdAt: new Date().toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', minute: 'numeric', - hour12: true + hour12: true }), - updatedAt: new Date().toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', + updatedAt: new Date().toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', minute: 'numeric', - hour12: true + hour12: true }), dueDate: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), submittedDate: new Date().toISOString(), @@ -243,7 +246,7 @@ function AppRoutes({ onLogout }: AppProps) { // Extract name from email if name is not available const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`; const approverEmail = approver?.email || ''; - + return { step: index + 1, approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`, @@ -268,32 +271,28 @@ function AppRoutes({ onLogout }: AppProps) { }; }), auditTrail: [ - { - type: 'created', - action: 'Request Created', - details: `Custom request "${requestData.title}" created`, - user: 'Current User', + { + type: 'created', + action: 'Request Created', + details: `Custom request "${requestData.title}" created`, + user: 'Current User', timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }) }, - { - type: 'assignment', - action: 'Assigned to Approver', - details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`, - user: 'System', + { + type: 'assignment', + action: 'Assigned to Approver', + details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`, + user: 'System', timestamp: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: 'numeric', hour12: true }) } ], tags: requestData.tags || ['custom-request'] }; - + // Add to dynamic requests setDynamicRequests([...dynamicRequests, newCustomRequest]); - + navigate('/my-requests'); - toast.success('Request Submitted Successfully!', { - description: `Your request "${requestData.title}" (${requestId}) has been created and sent for approval.`, - duration: 5000, - }); }; const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => { @@ -310,7 +309,7 @@ function AppRoutes({ onLogout }: AppProps) { duration: 5000, }); } - + setApprovalAction(null); resolve(true); }, 1000); @@ -343,7 +342,7 @@ function AppRoutes({ onLogout }: AppProps) { // Call API to create claim request const response = await createClaimRequest(payload); - + // Validate response - ensure request was actually created successfully if (!response || !response.request) { throw new Error('Invalid response from server: Request object not found'); @@ -377,11 +376,11 @@ function AppRoutes({ onLogout }: AppProps) { } } catch (error: any) { console.error('[App] Error creating claim request:', error); - + // Check for manager-related errors const errorData = error?.response?.data; const errorCode = errorData?.code || errorData?.error?.code; - + if (errorCode === 'NO_MANAGER_FOUND') { // Show modal for no manager found setManagerModalData({ @@ -392,7 +391,7 @@ function AppRoutes({ onLogout }: AppProps) { setManagerModalOpen(true); return; } - + if (errorCode === 'MULTIPLE_MANAGERS_FOUND') { // Show modal with manager list for selection const managers = errorData?.managers || errorData?.error?.managers || []; @@ -405,20 +404,20 @@ function AppRoutes({ onLogout }: AppProps) { setManagerModalOpen(true); return; } - + // Other errors - show toast const errorMessage = error?.response?.data?.message || error?.message || 'Failed to create claim request'; toast.error('Failed to Submit Claim Request', { description: errorMessage, }); } - + // Keep the old code below for backward compatibility (local storage fallback) // This can be removed once API integration is fully tested /* // Generate unique ID for the new claim request const requestId = `RE-REQ-2024-CM-${String(dynamicRequests.length + 2).padStart(3, '0')}`; - + // Create full request object const newRequest = { id: requestId, @@ -445,21 +444,21 @@ function AppRoutes({ onLogout }: AppProps) { avatar: 'CU' }, department: 'Marketing', - createdAt: new Date().toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', + createdAt: new Date().toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', minute: 'numeric', - hour12: true + hour12: true }), - updatedAt: new Date().toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', + updatedAt: new Date().toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', minute: 'numeric', - hour12: true + hour12: true }), dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), conclusionRemark: '', @@ -579,30 +578,30 @@ function AppRoutes({ onLogout }: AppProps) { documents: [], spectators: [], auditTrail: [ - { - type: 'created', - action: 'Request Created', - details: `Claim request for ${claimData.activityName} created`, - user: 'Current User', - timestamp: new Date().toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', + { + type: 'created', + action: 'Request Created', + details: `Claim request for ${claimData.activityName} created`, + user: 'Current User', + timestamp: new Date().toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', minute: 'numeric', - hour12: true + hour12: true }) } ], tags: ['claim-management', 'new-request', claimData.activityType?.toLowerCase().replace(/\s+/g, '-')] }; - + // Add to dynamic requests setDynamicRequests(prev => [...prev, newRequest]); - + // Also add to REQUEST_DATABASE for immediate viewing (REQUEST_DATABASE as any)[requestId] = newRequest; - + toast.success('Claim Request Submitted', { description: 'Your claim management request has been created successfully.', }); @@ -614,134 +613,194 @@ function AppRoutes({ onLogout }: AppProps) {
{/* Auth Callback - Unified callback for both OKTA and Tanflow */} - } + } /> - + {/* Dashboard - Conditionally renders DealerDashboard or regular Dashboard */} - - } + } /> - - - } + } + /> + + {/* Admin Routes Group with Shared Layout */} + + + + } + > + } /> + } /> + } /> + + + {/* Create Request from Admin Template (Dedicated Flow) */} + + } + /> + + + + + } + /> + + {/* Admin Routes Group with Shared Layout */} + + + + } + > + } /> + } /> + } /> + + + {/* Create Request from Admin Template (Dedicated Flow) */} + + } + /> + + + + + } /> {/* Open Requests */} - - } + } /> {/* Closed Requests */} - - } + } /> {/* Shared Summaries */} - - } + } /> {/* Shared Summary Detail */} - - } + } /> {/* My Requests */} - - } + } /> {/* Requests - Separate screens for Admin and Regular Users */} - - } + } /> {/* Approver Performance - Detailed Performance Analysis */} - - } + } /> {/* Request Detail - requestId will be read from URL params */} - - - } + } /> {/* Work Notes - Dedicated Full-Screen Page */} - } + } /> {/* New Request (Custom) */} - - } + } /> {/* Edit Draft Request */} - - } + } /> {/* Claim Management Wizard */} - - } + } /> {/* Profile */} - - } + } /> {/* Settings */} - - } + } /> {/* Notifications */} - - } + } /> {/* Detailed Reports */} - - } + } /> - {/* Admin Control Panel */} - - - - } - /> + + + - diff --git a/src/components/layout/PageLayout/PageLayout.tsx b/src/components/layout/PageLayout/PageLayout.tsx index 994481d..5726388 100644 --- a/src/components/layout/PageLayout/PageLayout.tsx +++ b/src/components/layout/PageLayout/PageLayout.tsx @@ -36,7 +36,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on const [unreadCount, setUnreadCount] = useState(0); const [notificationsOpen, setNotificationsOpen] = useState(false); const { user } = useAuth(); - + // Check if user is a Dealer const isDealer = useMemo(() => { try { @@ -47,7 +47,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on return false; } }, []); - + // Get user initials for avatar const getUserInitials = () => { try { @@ -67,19 +67,20 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on return 'U'; } }; - + const menuItems = useMemo(() => { const items = [ { id: 'dashboard', label: 'Dashboard', icon: Home }, // Add "All Requests" for all users (admin sees org-level, regular users see their participant requests) { id: 'requests', label: 'All Requests', icon: List }, + { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true }, ]; // Add remaining menu items (exclude "My Requests" for dealers) if (!isDealer) { items.push({ id: 'my-requests', label: 'My Requests', icon: User }); } - + items.push( { id: 'open-requests', label: 'Open Requests', icon: FileText }, { id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle }, @@ -98,7 +99,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on // Mark as read if (!notification.isRead) { await notificationApi.markAsRead(notification.notificationId); - setNotifications(prev => + setNotifications(prev => prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n) ); setUnreadCount(prev => Math.max(0, prev - 1)); @@ -111,14 +112,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on if (requestNumber) { // Determine which tab to open based on notification type let navigationUrl = `request/${requestNumber}`; - + // Work note related notifications should open Work Notes tab - if (notification.notificationType === 'mention' || - notification.notificationType === 'comment' || - notification.notificationType === 'worknote') { + if (notification.notificationType === 'mention' || + notification.notificationType === 'comment' || + notification.notificationType === 'worknote') { navigationUrl += '?tab=worknotes'; } - + // Navigate to request detail page onNavigate(navigationUrl); } @@ -152,7 +153,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on try { const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false }); if (!mounted) return; - + const notifs = result.data?.notifications || []; setNotifications(notifs); setUnreadCount(result.data?.unreadCount || 0); @@ -173,7 +174,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on // Listen for new notifications const handleNewNotification = (data: { notification: Notification }) => { if (!mounted) return; - + setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown setUnreadCount(prev => prev + 1); }; @@ -214,7 +215,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{/* Mobile Overlay */} {sidebarOpen && ( -
setSidebarOpen(false)} /> @@ -238,9 +239,9 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
- Royal Enfield Logo

RE Flow

@@ -248,28 +249,31 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
- {menuItems.map((item) => ( + {menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => ( ))}
- + {/* Quick Action in Sidebar - Right below menu items */} {!isDealer && (
@@ -292,14 +296,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on {/* Header */}
- + {/* Search bar commented out */} {/*
@@ -361,9 +365,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on {notifications.map((notif) => (
handleNotificationClick(notif)} >
@@ -422,8 +425,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on Settings - setShowLogoutDialog(true)} + setShowLogoutDialog(true)} className="text-red-600 focus:text-red-600" > diff --git a/src/components/workflow/CreateRequest/TemplateSelectionStep.tsx b/src/components/workflow/CreateRequest/TemplateSelectionStep.tsx index e1d86bc..555dc28 100644 --- a/src/components/workflow/CreateRequest/TemplateSelectionStep.tsx +++ b/src/components/workflow/CreateRequest/TemplateSelectionStep.tsx @@ -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 { Badge } from '@/components/ui/badge'; 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 { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; interface TemplateSelectionStepProps { templates: RequestTemplate[]; selectedTemplate: RequestTemplate | null; onSelectTemplate: (template: RequestTemplate) => void; + adminTemplates?: RequestTemplate[]; } 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({ templates, selectedTemplate, - onSelectTemplate + onSelectTemplate, + adminTemplates = [] }: 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 (

- Choose Your Request Type + {viewMode === 'main' ? 'Choose Your Request Type' : 'Organization Templates'}

- 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.'}

+ {viewMode === 'admin' && ( +
+ +
+ )} + {/* Template Cards Grid */} -
- {templates.map((template) => ( - - onSelectTemplate(template)} - data-testid={`template-card-${template.id}-clickable`} - > - -
-
+ +

No admin templates available yet.

+
+ ) : ( + displayTemplates.map((template) => { + const isComingSoon = template.id === 'existing-template' && viewMode === 'main'; // Only show coming soon for placeholder + const isDisabled = isComingSoon; + const isCategoryCard = template.id === 'admin-templates-category'; + const isCustomCard = template.id === 'custom'; + const isSelected = selectedTemplate?.id === template.id; + + return ( + + - -
- {selectedTemplate?.id === template.id && ( - -
- -
-
- )} -
-
- - {template.name} - -
- - {template.category} - - {getPriorityIcon(template.priority)} -
-
- - -

handleTemplateClick(template) : undefined} + data-testid={`template-card-${template.id}-clickable`} > - {template.description} -

- -
-
- - {template.estimatedTime} + +
+
+ +
+ {isSelected && ( + +
+ +
+
+ )} +
+
+
+ + {template.name} + + {isComingSoon && ( + + Coming Soon + + )} +
+
+ + {template.category} + + {getPriorityIcon(template.priority)} +
+
+
+ +

+ {template.description} +

+ + {!isCategoryCard && ( + <> + +
+
+ + {template.estimatedTime} +
+
+ + {template.commonApprovers?.length || 0} approvers +
+
+ + )} + {isCategoryCard && ( +
+

+ Click to browse templates → +

+
+ )} +
+ + + ); + }) + )} +
+ + {/* Template Details Card */} + + {selectedTemplate && ( + + + + + + {selectedTemplate.name} - Template Details + + + +
+
+ +

{selectedTemplate.suggestedSLA} hours

-
- - {template.commonApprovers.length} approvers +
+ +
+ {getPriorityIcon(selectedTemplate.priority)} + {selectedTemplate.priority} +
+
+
+ +

{selectedTemplate.estimatedTime}

+
+
+
+ +
+ {selectedTemplate.commonApprovers?.length > 0 ? ( + selectedTemplate.commonApprovers.map((approver, index) => ( + + {approver} + + )) + ) : ( + No specific approvers defined + )}
- ))} -
+ )} +
); } diff --git a/src/components/workflow/CreateRequest/WizardStepper.tsx b/src/components/workflow/CreateRequest/WizardStepper.tsx index d25284a..c0e400a 100644 --- a/src/components/workflow/CreateRequest/WizardStepper.tsx +++ b/src/components/workflow/CreateRequest/WizardStepper.tsx @@ -19,17 +19,20 @@ interface WizardStepperProps { export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) { const progressPercentage = Math.round((currentStep / totalSteps) * 100); + // Use a narrower container for fewer steps to avoid excessive spacing + const containerMaxWidth = stepNames.length <= 3 ? 'max-w-xl' : 'max-w-6xl'; + return ( -
-
+
{/* Mobile: Current step indicator only */}
-
@@ -51,11 +54,11 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
{/* Progress bar */} -
-
-
+
{stepNames.map((_, index) => ( -
-
+
{index + 1 < currentStep ? ( @@ -85,26 +87,24 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep )}
{index < stepNames.length - 1 && ( -
)}
))}
-
{stepNames.map((step, index) => ( - {step} diff --git a/src/hooks/useCreateRequestForm.ts b/src/hooks/useCreateRequestForm.ts index 58260cb..ff69edc 100644 --- a/src/hooks/useCreateRequestForm.ts +++ b/src/hooks/useCreateRequestForm.ts @@ -10,6 +10,7 @@ export interface RequestTemplate { icon: React.ComponentType; estimatedTime: string; commonApprovers: string[]; + workflowApprovers?: any[]; // Full approver objects for Admin Templates suggestedSLA: number; priority: 'high' | 'medium' | 'low'; fields: { @@ -199,7 +200,7 @@ export function useCreateRequestForm( const approvals = Array.isArray(details.approvals) ? details.approvals : []; const participants = Array.isArray(details.participants) ? details.participants : []; const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : []; - + // Store existing documents for tracking setExistingDocuments(documents); diff --git a/src/pages/Admin/Templates/AdminTemplatesList.tsx b/src/pages/Admin/Templates/AdminTemplatesList.tsx new file mode 100644 index 0000000..c6eaa69 --- /dev/null +++ b/src/pages/Admin/Templates/AdminTemplatesList.tsx @@ -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(() => 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(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 ( +
+
+
+

Admin Templates

+

Manage workflow templates for your organization

+
+ +
+ +
+
+ + setSearchQuery(e.target.value)} + /> +
+
+ + {loading ? ( +
+ {[1, 2, 3].map(i => ( + + + + + + + + + + + ))} +
+ ) : filteredTemplates.length === 0 ? ( +
+
+ +
+

No templates found

+

+ {searchQuery ? 'Try adjusting your search terms' : 'Get started by creating your first workflow template'} +

+ {!searchQuery && ( + + )} +
+ ) : ( +
+ {filteredTemplates.map((template) => ( + + +
+
+ +
+ + {template.priority} + +
+ {template.name} + + {template.description} + +
+ +
+
+ Category: + {template.category} +
+
+ SLA: + {template.suggestedSLA} hours +
+
+ Approvers: + {template.approvers?.length || 0} levels +
+
+ +
+ + +
+
+
+ ))} +
+ )} + + !open && setDeleteId(null)}> + + + + + Delete Template + + + Are you sure you want to delete this template? This action cannot be undone. + Active requests using this template will not be affected. + + + + Cancel + { + e.preventDefault(); + handleDelete(); + }} + className="bg-red-600 hover:bg-red-700" + disabled={deleting} + > + {deleting ? 'Deleting...' : 'Delete'} + + + + +
+ ); +} diff --git a/src/pages/Admin/Templates/CreateTemplate.tsx b/src/pages/Admin/Templates/CreateTemplate.tsx new file mode 100644 index 0000000..6246e0e --- /dev/null +++ b/src/pages/Admin/Templates/CreateTemplate.tsx @@ -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) => { + 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: +
+ handleApproverSearch(e.target.value)} + className="border-gray-200" + /> +
+ + 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 ( +
+ +
+ ); + } + + 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 ( +
+
+ +
+

+ {isEditing ? 'Edit Workflow Template' : 'Create Workflow Template'} +

+

+ {isEditing ? 'Update existing workflow configuration' : 'Define a new standardized request workflow'} +

+
+
+ +
+ + + Basic Information + General details about the template + + +
+
+ + +
+
+ +
+ {/* Simple text input for now, could be select */} + +
+
+
+ +
+ +