admin template flow addd with edit and delete option

This commit is contained in:
laxmanhalaki 2026-01-23 16:25:45 +05:30
parent ec8987032f
commit 1b4091c3d3
7 changed files with 585 additions and 61 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';
@ -20,6 +20,7 @@ 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 { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest'; import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; 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 */}
<Route <Route
path="/admin/create-template"
element={ element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<CreateTemplate /> <Outlet />
</PageLayout> </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) */} {/* Create Request from Admin Template (Dedicated Flow) */}
<Route <Route

View File

@ -61,7 +61,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{ 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/create-template', label: 'Create Template', icon: Plus, adminOnly: true }, // Added Create Template { id: 'admin/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
]; ];
// Add remaining menu items // Add remaining menu items
@ -238,8 +238,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => {
if (item.id === 'admin/create-template') { if (item.id === 'admin/templates') {
onNavigate?.('admin/create-template'); onNavigate?.('admin/templates');
} else { } else {
onNavigate?.(item.id); onNavigate?.(item.id);
} }

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

@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@ -8,14 +8,18 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; 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 { useUserSearch } from '@/hooks/useUserSearch';
import { createTemplate } from '@/services/workflowTemplateApi'; import { createTemplate, updateTemplate, getTemplates, WorkflowTemplate } from '@/services/workflowTemplateApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
export function CreateTemplate() { export function CreateTemplate() {
const navigate = useNavigate(); const navigate = useNavigate();
const { templateId } = useParams<{ templateId: string }>();
const isEditing = !!templateId;
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const { searchResults, searchLoading, searchUsersDebounced, clearSearch } = useUserSearch(); const { searchResults, searchLoading, searchUsersDebounced, clearSearch } = useUserSearch();
const [approverSearchInput, setApproverSearchInput] = useState(''); const [approverSearchInput, setApproverSearchInput] = useState('');
@ -29,6 +33,39 @@ export function CreateTemplate() {
approvers: [] as any[] 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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value })); setFormData(prev => ({ ...prev, [name]: value }));
@ -40,13 +77,34 @@ export function CreateTemplate() {
const handleApproverSearch = (val: string) => { const handleApproverSearch = (val: string) => {
setApproverSearchInput(val); setApproverSearchInput(val);
if (val.length >= 2) { // 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); searchUsersDebounced(val, 5);
} else { return;
clearSearch();
} }
}
// 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) => { const addApprover = (user: any) => {
if (formData.approvers.some(a => a.userId === user.userId)) { if (formData.approvers.some(a => a.userId === user.userId)) {
toast.error('Approver already added'); toast.error('Approver already added');
@ -59,7 +117,8 @@ export function CreateTemplate() {
name: user.displayName || user.email, name: user.displayName || user.email,
email: user.email, email: user.email,
level: prev.approvers.length + 1, level: prev.approvers.length + 1,
tat: 24 // Default TAT in hours tat: 24, // Default TAT in hours
tatType: 'hours' // Default unit
}] }]
})); }));
setApproverSearchInput(''); setApproverSearchInput('');
@ -81,28 +140,68 @@ export function CreateTemplate() {
return; 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 { try {
setLoading(true); setLoading(true);
await createTemplate(formData); if (isEditing && templateId) {
await updateTemplate(templateId, payload);
toast.success('Template updated successfully');
} else {
await createTemplate(payload);
toast.success('Template created successfully'); toast.success('Template created successfully');
navigate('/dashboard'); // Or back to list }
navigate('/admin/templates'); // Back to list
} catch (error) { } catch (error) {
toast.error('Failed to create template'); toast.error(isEditing ? 'Failed to update template' : 'Failed to create template');
console.error(error); console.error(error);
} finally { } finally {
setLoading(false); 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 ( return (
<div className="max-w-4xl mx-auto p-6 space-y-6"> <div className="max-w-4xl mx-auto p-6 space-y-6">
<div className="flex items-center gap-4 mb-6"> <div className="flex items-center gap-4 mb-6">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}> <Button variant="ghost" size="icon" onClick={() => navigate('/admin/templates')}>
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
</Button> </Button>
<div> <div>
<h1 className="text-3xl font-bold text-gray-900">Create Workflow Template</h1> <h1 className="text-3xl font-bold text-gray-900">
<p className="text-gray-500">Define a new standardized request workflow</p> {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>
</div> </div>
@ -122,6 +221,7 @@ export function CreateTemplate() {
placeholder="e.g., Office Stationery Request" placeholder="e.g., Office Stationery Request"
value={formData.name} value={formData.name}
onChange={handleInputChange} onChange={handleInputChange}
className="border-gray-200"
required required
/> />
</div> </div>
@ -135,6 +235,7 @@ export function CreateTemplate() {
placeholder="e.g., Admin, HR, Finance" placeholder="e.g., Admin, HR, Finance"
value={formData.category} value={formData.category}
onChange={handleInputChange} onChange={handleInputChange}
className="border-gray-200"
/> />
</div> </div>
</div> </div>
@ -148,6 +249,7 @@ export function CreateTemplate() {
placeholder="Describe what this request is for..." placeholder="Describe what this request is for..."
value={formData.description} value={formData.description}
onChange={handleInputChange} onChange={handleInputChange}
className="border-gray-200"
required required
/> />
</div> </div>
@ -178,6 +280,7 @@ export function CreateTemplate() {
placeholder="e.g., 2 days" placeholder="e.g., 2 days"
value={formData.estimatedTime} value={formData.estimatedTime}
onChange={handleInputChange} onChange={handleInputChange}
className="border-gray-200"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -189,6 +292,7 @@ export function CreateTemplate() {
placeholder="24" placeholder="24"
value={formData.suggestedSLA} value={formData.suggestedSLA}
onChange={handleInputChange} onChange={handleInputChange}
className="border-gray-200"
/> />
</div> </div>
</div> </div>
@ -209,19 +313,50 @@ export function CreateTemplate() {
<div className="font-medium text-gray-900">{approver.name}</div> <div className="font-medium text-gray-900">{approver.name}</div>
<div className="text-sm text-gray-500">{approver.email}</div> <div className="text-sm text-gray-500">{approver.email}</div>
</div> </div>
<div className="w-32 flex items-center gap-2"> <div className="flex items-center gap-2">
<Label htmlFor={`tat-${index}`} className="text-xs whitespace-nowrap">TAT (Hrs)</Label> <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 <Input
id={`tat-${index}`} id={`tat-${index}`}
type="number" type="number"
className="h-8 w-16" className="h-8 w-16 border-gray-200"
value={approver.tat || 24} value={approver.tat || ''}
min={1}
max={approver.tatType === 'days' ? 7 : 24}
placeholder={approver.tatType === 'days' ? '1' : '24'}
onChange={(e) => { 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]; const newApprovers = [...formData.approvers];
newApprovers[index].tat = parseInt(e.target.value) || 0; newApprovers[index].tat = e.target.value; // Store as string to allow clearing
setFormData(prev => ({ ...prev, approvers: newApprovers })); 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> </div>
<Button type="button" variant="ghost" size="sm" onClick={() => removeApprover(index)}> <Button type="button" variant="ghost" size="sm" onClick={() => removeApprover(index)}>
<X className="w-4 h-4 text-gray-500 hover:text-red-600" /> <X className="w-4 h-4 text-gray-500 hover:text-red-600" />
@ -240,9 +375,10 @@ export function CreateTemplate() {
<Label>Add Approver</Label> <Label>Add Approver</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
placeholder="Search user by name or email..." placeholder="Type '@' to search user by name or email..."
value={approverSearchInput} value={approverSearchInput}
onChange={(e) => handleApproverSearch(e.target.value)} onChange={(e) => handleApproverSearch(e.target.value)}
className="border-gray-200"
/> />
</div> </div>
@ -267,16 +403,96 @@ export function CreateTemplate() {
</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> </CardContent>
</Card> </Card>
<div className="flex justify-end gap-3 pt-4"> <div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={() => navigate(-1)}>Cancel</Button> <Button type="button" variant="outline" onClick={() => navigate('/admin/templates')}>Cancel</Button>
<Button type="submit" disabled={loading} className="bg-re-green hover:bg-re-green/90"> <Button type="submit" disabled={loading || !isFormValid} className="bg-re-green hover:bg-re-green/90">
{loading ? 'Creating...' : ( {loading ? 'Saving...' : (
<> <>
<Save className="w-4 h-4 mr-2" /> <Save className="w-4 h-4 mr-2" />
Create Template {isEditing ? 'Update Template' : 'Create Template'}
</> </>
)} )}
</Button> </Button>

View File

@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; 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 { Button } from '@/components/ui/button';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi'; import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
import { createWorkflowMultipart, submitWorkflow, CreateWorkflowFromFormPayload } from '@/services/workflowApi';
import { RequestTemplate } from '@/hooks/useCreateRequestForm'; import { RequestTemplate } from '@/hooks/useCreateRequestForm';
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
import { AdminRequestDetailsStep } from './components/AdminRequestDetailsStep'; import { AdminRequestDetailsStep } from './components/AdminRequestDetailsStep';
@ -15,7 +16,7 @@ import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper
export function CreateAdminRequest() { export function CreateAdminRequest() {
const { templateId } = useParams<{ templateId: string }>(); const { templateId } = useParams<{ templateId: string }>();
const navigate = useNavigate(); 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 [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -63,7 +64,8 @@ export function CreateAdminRequest() {
}); });
} else { } else {
toast.error('Template not found'); 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) { } catch (error) {
console.error('Error loading template:', error); console.error('Error loading template:', error);
@ -84,13 +86,28 @@ export function CreateAdminRequest() {
try { try {
setSubmitting(true); setSubmitting(true);
// Construct the request payload const formPayload: CreateWorkflowFromFormPayload = {
// This matches the structure expected by the backend for a generic request templateId: template.id,
// But we will likely need to adjust based on how "createRequest" is implemented globally templateType: 'TEMPLATE',
// For now, we simulate the submission or call the common handler if available 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 const response = await createWorkflowMultipart(formPayload, documents);
await new Promise(resolve => setTimeout(resolve, 1500));
if (response && response.id) {
await submitWorkflow(response.id);
}
toast.success('Request Submitted Successfully', { toast.success('Request Submitted Successfully', {
description: `Your request "${formData.title}" has been created.` description: `Your request "${formData.title}" has been created.`
@ -114,7 +131,29 @@ export function CreateAdminRequest() {
); );
} }
if (!template) return null; 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 ( return (
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden"> <div className="h-screen flex flex-col bg-gray-50 overflow-hidden">

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';
@ -95,7 +95,7 @@ export function CreateRequest({
fetchTemplates(); fetchTemplates();
}, []); }, []);
const allTemplates = [...REQUEST_TEMPLATES, ...adminTemplates]; const allTemplates = useMemo(() => [...REQUEST_TEMPLATES, ...adminTemplates], [adminTemplates]);
// Form and state management hooks // Form and state management hooks
const { const {
@ -180,16 +180,30 @@ export function CreateRequest({
// - 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 });
} }
} }

View File

@ -13,12 +13,35 @@ export interface WorkflowTemplate {
fields?: any; fields?: any;
} }
// Simple in-memory cache
let templatesCache: WorkflowTemplate[] | null = null;
export const getCachedTemplates = () => templatesCache;
export const createTemplate = async (templateData: Partial<WorkflowTemplate>): Promise<WorkflowTemplate> => { export const createTemplate = async (templateData: Partial<WorkflowTemplate>): Promise<WorkflowTemplate> => {
const response = await apiClient.post('/templates', templateData); const response = await apiClient.post('/templates', templateData);
// Invalidate cache or add to it
if (templatesCache) templatesCache.push(response.data.data);
return response.data.data; return response.data.data;
}; };
export const getTemplates = async (): Promise<WorkflowTemplate[]> => { export const getTemplates = async (): Promise<WorkflowTemplate[]> => {
const response = await apiClient.get('/templates'); const response = await apiClient.get('/templates');
templatesCache = response.data.data;
return response.data.data;
};
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);
if (templatesCache) {
templatesCache = templatesCache.map(t => t.id === id ? response.data.data : t);
}
return response.data.data; return response.data.data;
}; };