diff --git a/src/App.tsx b/src/App.tsx index b335d11..91200ad 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'; @@ -20,6 +20,7 @@ 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'; @@ -485,15 +486,18 @@ function AppRoutes({ onLogout }: AppProps) { } /> - {/* Admin Routes - Placed higher to prevent matching issues */} + {/* Admin Routes Group with Shared Layout */} - + + } - /> + > + } /> + } /> + } /> + {/* Create Request from Admin Template (Dedicated Flow) */} { - if (item.id === 'admin/create-template') { - onNavigate?.('admin/create-template'); + if (item.id === 'admin/templates') { + onNavigate?.('admin/templates'); } else { onNavigate?.(item.id); } @@ -249,8 +249,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on } }} className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id - ? 'bg-re-green text-white font-medium' - : 'text-gray-300 hover:bg-gray-900 hover:text-white' + ? 'bg-re-green text-white font-medium' + : 'text-gray-300 hover:bg-gray-900 hover:text-white' }`} > 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 index aaf2cf9..6246e0e 100644 --- a/src/pages/Admin/Templates/CreateTemplate.tsx +++ b/src/pages/Admin/Templates/CreateTemplate.tsx @@ -1,5 +1,5 @@ -import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +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'; @@ -8,14 +8,18 @@ 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 } from 'lucide-react'; +import { X, Save, ArrowLeft, Loader2, Clock } from 'lucide-react'; import { useUserSearch } from '@/hooks/useUserSearch'; -import { createTemplate } from '@/services/workflowTemplateApi'; +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(''); @@ -29,6 +33,39 @@ export function CreateTemplate() { 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 })); @@ -40,13 +77,34 @@ export function CreateTemplate() { const handleApproverSearch = (val: string) => { setApproverSearchInput(val); - if (val.length >= 2) { - searchUsersDebounced(val, 5); - } else { - clearSearch(); + // 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'); @@ -59,7 +117,8 @@ export function CreateTemplate() { name: user.displayName || user.email, email: user.email, level: prev.approvers.length + 1, - tat: 24 // Default TAT in hours + tat: 24, // Default TAT in hours + tatType: 'hours' // Default unit }] })); setApproverSearchInput(''); @@ -81,28 +140,68 @@ export function CreateTemplate() { 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); - await createTemplate(formData); - toast.success('Template created successfully'); - navigate('/dashboard'); // Or back to list + 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('Failed to create template'); + 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 (
-
-

Create Workflow Template

-

Define a new standardized request workflow

+

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

+

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

@@ -122,6 +221,7 @@ export function CreateTemplate() { placeholder="e.g., Office Stationery Request" value={formData.name} onChange={handleInputChange} + className="border-gray-200" required />
@@ -135,6 +235,7 @@ export function CreateTemplate() { placeholder="e.g., Admin, HR, Finance" value={formData.category} onChange={handleInputChange} + className="border-gray-200" /> @@ -148,6 +249,7 @@ export function CreateTemplate() { placeholder="Describe what this request is for..." value={formData.description} onChange={handleInputChange} + className="border-gray-200" required /> @@ -178,6 +280,7 @@ export function CreateTemplate() { placeholder="e.g., 2 days" value={formData.estimatedTime} onChange={handleInputChange} + className="border-gray-200" />
@@ -189,6 +292,7 @@ export function CreateTemplate() { placeholder="24" value={formData.suggestedSLA} onChange={handleInputChange} + className="border-gray-200" />
@@ -209,19 +313,50 @@ export function CreateTemplate() {
{approver.name}
{approver.email}
-
- - { - const newApprovers = [...formData.approvers]; - newApprovers[index].tat = parseInt(e.target.value) || 0; - setFormData(prev => ({ ...prev, approvers: newApprovers })); - }} - /> +
+
+ +
+ { + 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 })); + }} + /> + +
+
)} + + {/* TAT Summary */} + {formData.approvers.length > 0 && ( +
+
+ +
+
+

TAT Summary

+
+ {(() => { + 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 ( + <> +
{displayDays} {displayDays === 1 ? 'Day' : 'Days'}
+
Total Duration
+ + ); + })()} +
+
+
+
+ {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 ( +
+
+ Level {idx + 1} + {hours} {hours === 1 ? 'hour' : 'hours'} +
+
+ ); + })} +
+ {(() => { + 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 ( +
+
+
+
{totalHours}h
+
Total Hours
+
+
+
{workingDays}
+
Working Days*
+
+
+

*Based on 8-hour working days

+
+ ); + })()} +
+
+
+
+ )}
- - + diff --git a/src/pages/CreateAdminRequest/CreateAdminRequest.tsx b/src/pages/CreateAdminRequest/CreateAdminRequest.tsx index 5ebe721..7800ae9 100644 --- a/src/pages/CreateAdminRequest/CreateAdminRequest.tsx +++ b/src/pages/CreateAdminRequest/CreateAdminRequest.tsx @@ -1,9 +1,10 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Save, ChevronRight, Check } from 'lucide-react'; +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'; @@ -15,7 +16,7 @@ import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper export function CreateAdminRequest() { const { templateId } = useParams<{ templateId: string }>(); const navigate = useNavigate(); - const { user } = useAuth(); + 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); @@ -63,7 +64,8 @@ export function CreateAdminRequest() { }); } else { toast.error('Template not found'); - navigate('/new-request'); + // 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); @@ -84,13 +86,28 @@ export function CreateAdminRequest() { try { setSubmitting(true); - // Construct the request payload - // This matches the structure expected by the backend for a generic request - // But we will likely need to adjust based on how "createRequest" is implemented globally - // For now, we simulate the submission or call the common handler if available + 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: [] + }; - // Simulating API call for demonstration of flow - await new Promise(resolve => setTimeout(resolve, 1500)); + 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.` @@ -114,7 +131,29 @@ export function CreateAdminRequest() { ); } - if (!template) return null; + if (!template) { + return ( +
+
+
+ +
+

Template Not Found

+

+ The requested template could not be loaded. It may have been deleted or you do not have permission to view it. +

+
+ + +
+
+
+ ); + } return (
diff --git a/src/pages/CreateRequest/CreateRequest.tsx b/src/pages/CreateRequest/CreateRequest.tsx index 47968d2..fc932a3 100644 --- a/src/pages/CreateRequest/CreateRequest.tsx +++ b/src/pages/CreateRequest/CreateRequest.tsx @@ -13,7 +13,7 @@ * - 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 { useAuth } from '@/contexts/AuthContext'; import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal'; @@ -95,7 +95,7 @@ export function CreateRequest({ fetchTemplates(); }, []); - const allTemplates = [...REQUEST_TEMPLATES, ...adminTemplates]; + const allTemplates = useMemo(() => [...REQUEST_TEMPLATES, ...adminTemplates], [adminTemplates]); // Form and state management hooks const { @@ -180,16 +180,30 @@ export function CreateRequest({ // - Steps 1, 3, or 4: Navigate back to previous screen (browser history) // - Other steps: Go to previous step in wizard const handleBackButton = useCallback(() => { - if (currentStep === 1 || currentStep === 3 || currentStep === 4) { - // On steps 1, 3, or 4, navigate back to previous screen using browser history + // If on the first step (Template Selection), always go back to dashboard + // This prevents infinite loops if the user was redirected here from an error page + if (currentStep === 1) { + navigate('/dashboard', { replace: true }); + return; + } + + // For other major steps (3=Approval, 4=Participants), we might want to go back to prev screen + // But for consistency and safety against loops, let's treat "Back" as "Previous Step" + // or explicit exit if at the start of a flow. + + // Actually, keep the history logic ONLY for later steps if needed, but Step 1 MUST be explicit. + if (currentStep === 3 || currentStep === 4) { + // ... existing logic for these steps if we want to keep it, + // but typically "Back" in a wizard should go to previous wizard step. + // However, the original code had this specific logic. + // Let's defer to prevStep() for wizard navigation, and only use history/dashboard for exit. + if (onBack) { onBack(); } else { - // Use window.history.back() as fallback for more reliable navigation if (window.history.length > 1) { window.history.back(); } else { - // If no history, navigate to dashboard navigate('/dashboard', { replace: true }); } } diff --git a/src/services/workflowTemplateApi.ts b/src/services/workflowTemplateApi.ts index 4460f6a..ecfc39d 100644 --- a/src/services/workflowTemplateApi.ts +++ b/src/services/workflowTemplateApi.ts @@ -13,12 +13,35 @@ export interface WorkflowTemplate { fields?: any; } +// Simple in-memory cache +let templatesCache: WorkflowTemplate[] | null = null; + +export const getCachedTemplates = () => templatesCache; + export const createTemplate = async (templateData: Partial): Promise => { const response = await apiClient.post('/templates', templateData); + // Invalidate cache or add to it + if (templatesCache) templatesCache.push(response.data.data); return response.data.data; }; export const getTemplates = async (): Promise => { const response = await apiClient.get('/templates'); + templatesCache = response.data.data; + return response.data.data; +}; + +export const deleteTemplate = async (id: string): Promise => { + await apiClient.delete(`/templates/${id}`); + if (templatesCache) { + templatesCache = templatesCache.filter(t => t.id !== id); + } +}; + +export const updateTemplate = async (id: string, templateData: Partial): Promise => { + const response = await apiClient.put(`/templates/${id}`, templateData); + if (templatesCache) { + templatesCache = templatesCache.map(t => t.id === id ? response.data.data : t); + } return response.data.data; };