229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|