Compare commits
No commits in common. "fdbc8dcfa1111285a663c335e8a7a606a83e618b" and "66c33703e1afb7d0a14ec7a608c82689cd8f7c7a" have entirely different histories.
fdbc8dcfa1
...
66c33703e1
81
src/App.tsx
81
src/App.tsx
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { BrowserRouter, Routes, Route, useNavigate, Outlet } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, useNavigate } from 'react-router-dom';
|
||||||
import { PageLayout } from '@/components/layout/PageLayout';
|
import { PageLayout } from '@/components/layout/PageLayout';
|
||||||
import { Dashboard } from '@/pages/Dashboard';
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
import { OpenRequests } from '@/pages/OpenRequests';
|
import { OpenRequests } from '@/pages/OpenRequests';
|
||||||
@ -21,9 +21,6 @@ import { Settings } from '@/pages/Settings';
|
|||||||
import { Notifications } from '@/pages/Notifications';
|
import { Notifications } from '@/pages/Notifications';
|
||||||
import { DetailedReports } from '@/pages/DetailedReports';
|
import { DetailedReports } from '@/pages/DetailedReports';
|
||||||
import { Admin } from '@/pages/Admin';
|
import { Admin } from '@/pages/Admin';
|
||||||
import { AdminTemplatesList } from '@/pages/Admin/Templates/AdminTemplatesList';
|
|
||||||
import { CreateTemplate } from '@/pages/Admin/Templates/CreateTemplate';
|
|
||||||
import { CreateAdminRequest } from '@/pages/CreateAdminRequest/CreateAdminRequest';
|
|
||||||
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -293,6 +290,10 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
||||||
|
|
||||||
navigate('/my-requests');
|
navigate('/my-requests');
|
||||||
|
toast.success('Request Submitted Successfully!', {
|
||||||
|
description: `Your request "${requestData.title}" (${requestId}) has been created and sent for approval.`,
|
||||||
|
duration: 5000,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
|
const handleApprovalSubmit = (action: 'approve' | 'reject', _comment: string) => {
|
||||||
@ -637,66 +638,6 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin Routes Group with Shared Layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Outlet />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/create-template" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/templates" element={<AdminTemplatesList />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Create Request from Admin Template (Dedicated Flow) */}
|
|
||||||
<Route
|
|
||||||
path="/create-admin-request/:templateId"
|
|
||||||
element={
|
|
||||||
<CreateAdminRequest />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin Routes Group with Shared Layout */}
|
|
||||||
<Route
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin-templates" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Outlet />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Route path="/admin/create-template" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/edit-template/:templateId" element={<CreateTemplate />} />
|
|
||||||
<Route path="/admin/templates" element={<AdminTemplatesList />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
{/* Create Request from Admin Template (Dedicated Flow) */}
|
|
||||||
<Route
|
|
||||||
path="/create-admin-request/:templateId"
|
|
||||||
element={
|
|
||||||
<CreateAdminRequest />
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
|
||||||
<Admin />
|
|
||||||
</PageLayout>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Open Requests */}
|
{/* Open Requests */}
|
||||||
<Route
|
<Route
|
||||||
path="/open-requests"
|
path="/open-requests"
|
||||||
@ -862,9 +803,15 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Admin Control Panel */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<Admin />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
@ -73,7 +73,6 @@ 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/templates', label: 'Admin Templates', icon: Plus, adminOnly: true },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add remaining menu items (exclude "My Requests" for dealers)
|
// Add remaining menu items (exclude "My Requests" for dealers)
|
||||||
@ -249,21 +248,18 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-3 flex-1 overflow-y-auto">
|
<div className="p-3 flex-1 overflow-y-auto">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{menuItems.filter(item => !item.adminOnly || (user as any)?.role === 'ADMIN').map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (item.id === 'admin/templates') {
|
|
||||||
onNavigate?.('admin/templates');
|
|
||||||
} else {
|
|
||||||
onNavigate?.(item.id);
|
onNavigate?.(item.id);
|
||||||
}
|
|
||||||
// Close sidebar on mobile after navigation
|
// Close sidebar on mobile after navigation
|
||||||
if (window.innerWidth < 768) {
|
if (window.innerWidth < 768) {
|
||||||
setSidebarOpen(false);
|
setSidebarOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${currentPage === item.id
|
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'
|
? 'bg-re-green text-white font-medium'
|
||||||
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
: 'text-gray-300 hover:bg-gray-900 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
@ -365,7 +361,8 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{notifications.map((notif) => (
|
{notifications.map((notif) => (
|
||||||
<div
|
<div
|
||||||
key={notif.notificationId}
|
key={notif.notificationId}
|
||||||
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${!notif.isRead ? 'bg-blue-50' : ''
|
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${
|
||||||
|
!notif.isRead ? 'bg-blue-50' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => handleNotificationClick(notif)}
|
onClick={() => handleNotificationClick(notif)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,19 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { motion } from 'framer-motion';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import { Check, Clock, Users, Flame, Target, TrendingUp, FolderOpen, ArrowLeft, Info } from 'lucide-react';
|
import { Check, Clock, Users, Flame, Target, TrendingUp } from 'lucide-react';
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
|
|
||||||
interface TemplateSelectionStepProps {
|
interface TemplateSelectionStepProps {
|
||||||
templates: RequestTemplate[];
|
templates: RequestTemplate[];
|
||||||
selectedTemplate: RequestTemplate | null;
|
selectedTemplate: RequestTemplate | null;
|
||||||
onSelectTemplate: (template: RequestTemplate) => void;
|
onSelectTemplate: (template: RequestTemplate) => void;
|
||||||
adminTemplates?: RequestTemplate[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getPriorityIcon = (priority: string) => {
|
const getPriorityIcon = (priority: string) => {
|
||||||
@ -25,48 +20,21 @@ const getPriorityIcon = (priority: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component: TemplateSelectionStep
|
||||||
|
*
|
||||||
|
* Purpose: Step 1 - Template selection for request creation
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays available templates
|
||||||
|
* - Shows template details when selected
|
||||||
|
* - Test IDs for testing
|
||||||
|
*/
|
||||||
export function TemplateSelectionStep({
|
export function TemplateSelectionStep({
|
||||||
templates,
|
templates,
|
||||||
selectedTemplate,
|
selectedTemplate,
|
||||||
onSelectTemplate,
|
onSelectTemplate
|
||||||
adminTemplates = []
|
|
||||||
}: TemplateSelectionStepProps) {
|
}: TemplateSelectionStepProps) {
|
||||||
const [viewMode, setViewMode] = useState<'main' | 'admin'>('main');
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const handleTemplateClick = (template: RequestTemplate) => {
|
|
||||||
if (template.id === 'admin-templates-category') {
|
|
||||||
setViewMode('admin');
|
|
||||||
} else {
|
|
||||||
if (viewMode === 'admin') {
|
|
||||||
// If selecting an actual admin template, redirect to dedicated flow
|
|
||||||
navigate(`/create-admin-request/${template.id}`);
|
|
||||||
} else {
|
|
||||||
// Default behavior for standard templates
|
|
||||||
onSelectTemplate(template);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const displayTemplates = viewMode === 'main'
|
|
||||||
? [
|
|
||||||
...templates,
|
|
||||||
{
|
|
||||||
id: 'admin-templates-category',
|
|
||||||
name: 'Admin Templates',
|
|
||||||
description: 'Browse standardized request workflows created by your organization administrators',
|
|
||||||
category: 'Organization',
|
|
||||||
icon: FolderOpen,
|
|
||||||
estimatedTime: 'Variable',
|
|
||||||
commonApprovers: [],
|
|
||||||
suggestedSLA: 0,
|
|
||||||
priority: 'medium',
|
|
||||||
fields: {}
|
|
||||||
} as any
|
|
||||||
]
|
|
||||||
: adminTemplates;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@ -78,83 +46,54 @@ export function TemplateSelectionStep({
|
|||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
|
<div className="text-center mb-12 max-w-3xl" data-testid="template-selection-header">
|
||||||
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4" data-testid="template-selection-title">
|
||||||
{viewMode === 'main' ? 'Choose Your Request Type' : 'Organization Templates'}
|
Choose Your Request Type
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg text-gray-600" data-testid="template-selection-description">
|
<p className="text-lg text-gray-600" data-testid="template-selection-description">
|
||||||
{viewMode === 'main'
|
Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.
|
||||||
? 'Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.'
|
|
||||||
: 'Select a pre-configured workflow template defined by your organization.'}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{viewMode === 'admin' && (
|
|
||||||
<div className="w-full max-w-6xl mb-6 flex justify-start">
|
|
||||||
<Button variant="ghost" className="gap-2" onClick={() => setViewMode('main')}>
|
|
||||||
<ArrowLeft className="w-4 h-4" />
|
|
||||||
Back to All Types
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Template Cards Grid */}
|
{/* Template Cards Grid */}
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
|
className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8"
|
||||||
data-testid="template-selection-grid"
|
data-testid="template-selection-grid"
|
||||||
>
|
>
|
||||||
{displayTemplates.length === 0 && viewMode === 'admin' ? (
|
{templates.map((template) => (
|
||||||
<div className="col-span-full text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200">
|
|
||||||
<FolderOpen className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
|
||||||
<p>No admin templates available yet.</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
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 (
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key={template.id}
|
key={template.id}
|
||||||
whileHover={!isDisabled ? { scale: 1.03 } : {}}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={!isDisabled ? { scale: 0.98 } : {}}
|
whileTap={{ scale: 0.98 }}
|
||||||
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
||||||
data-testid={`template-card-${template.id}`}
|
data-testid={`template-card-${template.id}`}
|
||||||
>
|
>
|
||||||
<Card
|
<Card
|
||||||
className={`h-full transition-all duration-300 border-2 ${isDisabled
|
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
|
||||||
? 'border-gray-200 bg-gray-50/50 opacity-85 cursor-not-allowed'
|
selectedTemplate?.id === template.id
|
||||||
: isSelected
|
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
||||||
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200 cursor-pointer'
|
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
||||||
: isCategoryCard
|
|
||||||
? 'border-blue-200 bg-blue-50/30 hover:border-blue-400 hover:shadow-lg cursor-pointer'
|
|
||||||
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg cursor-pointer'
|
|
||||||
}`}
|
}`}
|
||||||
onClick={!isDisabled ? () => handleTemplateClick(template) : undefined}
|
onClick={() => onSelectTemplate(template)}
|
||||||
data-testid={`template-card-${template.id}-clickable`}
|
data-testid={`template-card-${template.id}-clickable`}
|
||||||
>
|
>
|
||||||
<CardHeader className="space-y-4 pb-4">
|
<CardHeader className="space-y-4 pb-4">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div
|
<div
|
||||||
className={`w-14 h-14 rounded-xl flex items-center justify-center ${isSelected
|
className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
||||||
? 'bg-blue-100'
|
selectedTemplate?.id === template.id
|
||||||
: isCategoryCard
|
|
||||||
? 'bg-blue-100'
|
? 'bg-blue-100'
|
||||||
: 'bg-gray-100'
|
: 'bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`template-card-${template.id}-icon`}
|
data-testid={`template-card-${template.id}-icon`}
|
||||||
>
|
>
|
||||||
<template.icon
|
<template.icon
|
||||||
className={`w-7 h-7 ${isSelected
|
className={`w-7 h-7 ${
|
||||||
? 'text-blue-600'
|
selectedTemplate?.id === template.id
|
||||||
: isCategoryCard
|
|
||||||
? 'text-blue-600'
|
? 'text-blue-600'
|
||||||
: 'text-gray-600'
|
: 'text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && (
|
{selectedTemplate?.id === template.id && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0 }}
|
initial={{ scale: 0 }}
|
||||||
animate={{ scale: 1 }}
|
animate={{ scale: 1 }}
|
||||||
@ -168,20 +107,9 @@ export function TemplateSelectionStep({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-left">
|
<div className="text-left">
|
||||||
<div className="flex items-start justify-between gap-2 mb-2">
|
<CardTitle className="text-xl mb-2" data-testid={`template-card-${template.id}-name`}>
|
||||||
<CardTitle className="text-xl" data-testid={`template-card-${template.id}-name`}>
|
|
||||||
{template.name}
|
{template.name}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{isComingSoon && (
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs bg-yellow-100 text-yellow-700 border-yellow-300 font-semibold"
|
|
||||||
data-testid={`template-card-${template.id}-coming-soon-badge`}
|
|
||||||
>
|
|
||||||
Coming Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
|
<Badge variant="secondary" className="text-xs" data-testid={`template-card-${template.id}-category`}>
|
||||||
{template.category}
|
{template.category}
|
||||||
@ -197,9 +125,6 @@ export function TemplateSelectionStep({
|
|||||||
>
|
>
|
||||||
{template.description}
|
{template.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!isCategoryCard && (
|
|
||||||
<>
|
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
||||||
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
|
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-estimated-time`}>
|
||||||
@ -208,86 +133,14 @@ export function TemplateSelectionStep({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
|
<div className="flex items-center gap-1.5" data-testid={`template-card-${template.id}-approvers-count`}>
|
||||||
<Users className="w-3.5 h-3.5" />
|
<Users className="w-3.5 h-3.5" />
|
||||||
<span>{template.commonApprovers?.length || 0} approvers</span>
|
<span>{template.commonApprovers.length} approvers</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isCategoryCard && (
|
|
||||||
<div className="pt-2">
|
|
||||||
<p className="text-xs text-blue-600 font-medium flex items-center gap-1">
|
|
||||||
Click to browse templates →
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Template Details Card */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{selectedTemplate && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20, height: 0 }}
|
|
||||||
animate={{ opacity: 1, y: 0, height: 'auto' }}
|
|
||||||
exit={{ opacity: 0, y: -20, height: 0 }}
|
|
||||||
transition={{ duration: 0.3 }}
|
|
||||||
className="w-full max-w-6xl"
|
|
||||||
data-testid="template-details-card"
|
|
||||||
>
|
|
||||||
<Card className="bg-gradient-to-br from-blue-50 to-indigo-50 border-2 border-blue-200">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-blue-900" data-testid="template-details-title">
|
|
||||||
<Info className="w-5 h-5" />
|
|
||||||
{selectedTemplate.name} - Template Details
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-sla">
|
|
||||||
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
|
|
||||||
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} hours</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-priority">
|
|
||||||
<Label className="text-blue-900 font-semibold">Priority Level</Label>
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
{getPriorityIcon(selectedTemplate.priority)}
|
|
||||||
<span className="text-blue-700 capitalize">{selectedTemplate.priority}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-duration">
|
|
||||||
<Label className="text-blue-900 font-semibold">Estimated Duration</Label>
|
|
||||||
<p className="text-blue-700 mt-1">{selectedTemplate.estimatedTime}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white/60 p-3 rounded-lg" data-testid="template-details-approvers">
|
|
||||||
<Label className="text-blue-900 font-semibold">Approvers</Label>
|
|
||||||
<div className="flex flex-wrap gap-2 mt-2">
|
|
||||||
{selectedTemplate.commonApprovers?.length > 0 ? (
|
|
||||||
selectedTemplate.commonApprovers.map((approver, index) => (
|
|
||||||
<Badge
|
|
||||||
key={`${selectedTemplate.id}-approver-${index}-${approver}`}
|
|
||||||
variant="outline"
|
|
||||||
className="border-blue-300 text-blue-700 bg-white"
|
|
||||||
data-testid={`template-details-approver-${index}`}
|
|
||||||
>
|
|
||||||
{approver}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<span className="text-sm text-gray-500 italic">No specific approvers defined</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
))}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,15 +19,12 @@ interface WizardStepperProps {
|
|||||||
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
|
export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStepperProps) {
|
||||||
const progressPercentage = Math.round((currentStep / totalSteps) * 100);
|
const progressPercentage = Math.round((currentStep / totalSteps) * 100);
|
||||||
|
|
||||||
// Use a narrower container for fewer steps to avoid excessive spacing
|
|
||||||
const containerMaxWidth = stepNames.length <= 3 ? 'max-w-xl' : 'max-w-6xl';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
|
className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0"
|
||||||
data-testid="wizard-stepper"
|
data-testid="wizard-stepper"
|
||||||
>
|
>
|
||||||
<div className={`${containerMaxWidth} mx-auto`}>
|
<div className="max-w-6xl mx-auto">
|
||||||
{/* Mobile: Current step indicator only */}
|
{/* Mobile: Current step indicator only */}
|
||||||
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
|
<div className="block sm:hidden" data-testid="wizard-stepper-mobile">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
@ -68,14 +65,15 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
|
|||||||
|
|
||||||
{/* Desktop: Full step indicator */}
|
{/* Desktop: Full step indicator */}
|
||||||
<div className="hidden sm:block" data-testid="wizard-stepper-desktop">
|
<div className="hidden sm:block" data-testid="wizard-stepper-desktop">
|
||||||
<div className="flex items-center justify-center gap-4 mb-2" data-testid="wizard-stepper-desktop-steps">
|
<div className="flex items-center justify-between mb-2" data-testid="wizard-stepper-desktop-steps">
|
||||||
{stepNames.map((_, index) => (
|
{stepNames.map((_, index) => (
|
||||||
<div key={index} className="flex items-center flex-1 last:flex-none" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
|
<div key={index} className="flex items-center" data-testid={`wizard-stepper-desktop-step-${index + 1}`}>
|
||||||
<div
|
<div
|
||||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold flex-shrink-0 ${index + 1 < currentStep
|
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
||||||
? 'bg-green-500 text-white'
|
index + 1 < currentStep
|
||||||
|
? 'bg-green-600 text-white'
|
||||||
: index + 1 === currentStep
|
: index + 1 === currentStep
|
||||||
? 'bg-green-500 text-white ring-2 ring-green-500/30 ring-offset-1'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-200 text-gray-600'
|
: 'bg-gray-200 text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
|
data-testid={`wizard-stepper-desktop-step-${index + 1}-indicator`}
|
||||||
@ -88,7 +86,8 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
|
|||||||
</div>
|
</div>
|
||||||
{index < stepNames.length - 1 && (
|
{index < stepNames.length - 1 && (
|
||||||
<div
|
<div
|
||||||
className={`flex-1 h-0.5 mx-2 ${index + 1 < currentStep ? 'bg-green-500' : 'bg-gray-200'
|
className={`w-8 md:w-12 lg:w-16 h-1 mx-1 md:mx-2 ${
|
||||||
|
index + 1 < currentStep ? 'bg-green-600' : 'bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
|
data-testid={`wizard-stepper-desktop-step-${index + 1}-connector`}
|
||||||
/>
|
/>
|
||||||
@ -97,13 +96,14 @@ export function WizardStepper({ currentStep, totalSteps, stepNames }: WizardStep
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2 px-1"
|
className="hidden lg:flex justify-between text-xs text-gray-600 mt-2"
|
||||||
data-testid="wizard-stepper-desktop-labels"
|
data-testid="wizard-stepper-desktop-labels"
|
||||||
>
|
>
|
||||||
{stepNames.map((step, index) => (
|
{stepNames.map((step, index) => (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className={`${index + 1 === currentStep ? 'font-semibold text-green-600' : ''
|
className={`${
|
||||||
|
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
||||||
}`}
|
}`}
|
||||||
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
|
data-testid={`wizard-stepper-desktop-label-${index + 1}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -31,14 +31,14 @@ export function StandardClosedRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -77,7 +77,7 @@ export function StandardClosedRequestsFilters({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<Input
|
<Input
|
||||||
@ -130,7 +130,7 @@ export function StandardClosedRequestsFilters({
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{/*
|
|
||||||
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
@ -140,7 +140,7 @@ export function StandardClosedRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
||||||
|
|||||||
@ -31,13 +31,13 @@ export function StandardRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusFilterChange,
|
onStatusFilterChange,
|
||||||
onPriorityFilterChange,
|
onPriorityFilterChange,
|
||||||
// onTemplateTypeFilterChange,
|
onTemplateTypeFilterChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -77,7 +77,7 @@ export function StandardRequestsFilters({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
||||||
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
|
{/* Standard filters - Search, Status, Priority, Template Type, and Sort */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<Input
|
<Input
|
||||||
@ -120,7 +120,7 @@ export function StandardRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeFilterChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -129,7 +129,7 @@ export function StandardRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
|
<Select value={sortBy} onValueChange={(value: any) => onSortByChange(value)}>
|
||||||
|
|||||||
@ -87,7 +87,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter,
|
||||||
departmentFilter,
|
departmentFilter,
|
||||||
slaComplianceFilter,
|
slaComplianceFilter,
|
||||||
initiatorFilter: _initiatorFilter,
|
initiatorFilter: _initiatorFilter,
|
||||||
@ -104,7 +104,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange,
|
||||||
onDepartmentChange,
|
onDepartmentChange,
|
||||||
onSlaComplianceChange,
|
onSlaComplianceChange,
|
||||||
onInitiatorChange: _onInitiatorChange,
|
onInitiatorChange: _onInitiatorChange,
|
||||||
@ -143,7 +143,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Primary Filters */}
|
{/* Primary Filters */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||||
<div className="relative md:col-span-3 lg:col-span-1">
|
<div className="relative md:col-span-3 lg:col-span-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
@ -180,7 +180,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
||||||
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -189,7 +189,7 @@ export function StandardUserAllRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Custom</SelectItem>
|
<SelectItem value="CUSTOM">Custom</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={departmentFilter}
|
value={departmentFilter}
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export interface RequestTemplate {
|
|||||||
icon: React.ComponentType<any>;
|
icon: React.ComponentType<any>;
|
||||||
estimatedTime: string;
|
estimatedTime: string;
|
||||||
commonApprovers: string[];
|
commonApprovers: string[];
|
||||||
workflowApprovers?: any[]; // Full approver objects for Admin Templates
|
|
||||||
suggestedSLA: number;
|
suggestedSLA: number;
|
||||||
priority: 'high' | 'medium' | 'low';
|
priority: 'high' | 'medium' | 'low';
|
||||||
fields: {
|
fields: {
|
||||||
|
|||||||
@ -1,228 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,503 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
||||||
import { X, Save, ArrowLeft, Loader2, Clock } from 'lucide-react';
|
|
||||||
import { useUserSearch } from '@/hooks/useUserSearch';
|
|
||||||
import { createTemplate, updateTemplate, getTemplates, WorkflowTemplate } from '@/services/workflowTemplateApi';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export function CreateTemplate() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { templateId } = useParams<{ templateId: string }>();
|
|
||||||
const isEditing = !!templateId;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [fetching, setFetching] = useState(false);
|
|
||||||
const { searchResults, searchLoading, searchUsersDebounced, clearSearch } = useUserSearch();
|
|
||||||
const [approverSearchInput, setApproverSearchInput] = useState('');
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category: 'General',
|
|
||||||
priority: 'medium' as 'low' | 'medium' | 'high',
|
|
||||||
estimatedTime: '2 days',
|
|
||||||
suggestedSLA: 24,
|
|
||||||
approvers: [] as any[]
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isEditing && templateId) {
|
|
||||||
const fetchTemplate = async () => {
|
|
||||||
try {
|
|
||||||
setFetching(true);
|
|
||||||
const templates = await getTemplates();
|
|
||||||
const template = templates.find((t: WorkflowTemplate) => t.id === templateId);
|
|
||||||
|
|
||||||
if (template) {
|
|
||||||
setFormData({
|
|
||||||
name: template.name,
|
|
||||||
description: template.description,
|
|
||||||
category: template.category,
|
|
||||||
priority: template.priority,
|
|
||||||
estimatedTime: template.estimatedTime,
|
|
||||||
suggestedSLA: template.suggestedSLA,
|
|
||||||
approvers: template.approvers || []
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.error('Template not found');
|
|
||||||
navigate('/admin/templates');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load template:', error);
|
|
||||||
toast.error('Failed to load template details');
|
|
||||||
} finally {
|
|
||||||
setFetching(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchTemplate();
|
|
||||||
}
|
|
||||||
}, [isEditing, templateId, navigate]);
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectChange = (name: string, value: string) => {
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleApproverSearch = (val: string) => {
|
|
||||||
setApproverSearchInput(val);
|
|
||||||
// Only trigger search if specifically starting with '@'
|
|
||||||
// This prevents triggering on email addresses like "user@example.com"
|
|
||||||
if (val.startsWith('@')) {
|
|
||||||
const query = val.slice(1);
|
|
||||||
// Search if we have at least 1 character after @
|
|
||||||
// This allows searching for "L" in "@L"
|
|
||||||
if (query.length >= 1) {
|
|
||||||
// Pass the full query starting with @, as useUserSearch expects it
|
|
||||||
searchUsersDebounced(val, 5);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no @ at start or query too short, clear results
|
|
||||||
clearSearch();
|
|
||||||
};
|
|
||||||
|
|
||||||
// ... (rest of the component)
|
|
||||||
|
|
||||||
// In the return JSX:
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Type '@' to search user by name or email..."
|
|
||||||
value={approverSearchInput}
|
|
||||||
onChange={(e) => handleApproverSearch(e.target.value)}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
const addApprover = (user: any) => {
|
|
||||||
if (formData.approvers.some(a => a.userId === user.userId)) {
|
|
||||||
toast.error('Approver already added');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
approvers: [...prev.approvers, {
|
|
||||||
userId: user.userId,
|
|
||||||
name: user.displayName || user.email,
|
|
||||||
email: user.email,
|
|
||||||
level: prev.approvers.length + 1,
|
|
||||||
tat: 24, // Default TAT in hours
|
|
||||||
tatType: 'hours' // Default unit
|
|
||||||
}]
|
|
||||||
}));
|
|
||||||
setApproverSearchInput('');
|
|
||||||
clearSearch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeApprover = (index: number) => {
|
|
||||||
const newApprovers = [...formData.approvers];
|
|
||||||
newApprovers.splice(index, 1);
|
|
||||||
// Re-index levels
|
|
||||||
newApprovers.forEach((a, i) => a.level = i + 1);
|
|
||||||
setFormData(prev => ({ ...prev, approvers: newApprovers }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!formData.name || !formData.description) {
|
|
||||||
toast.error('Please fill in required fields');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.approvers.length === 0) {
|
|
||||||
toast.error('Please add at least one approver');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare payload with TAT conversion
|
|
||||||
const payload = {
|
|
||||||
...formData,
|
|
||||||
approvers: formData.approvers.map(a => ({
|
|
||||||
...a,
|
|
||||||
tat: a.tatType === 'days' ? (parseInt(a.tat) * 24) : parseInt(a.tat)
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
if (isEditing && templateId) {
|
|
||||||
await updateTemplate(templateId, payload);
|
|
||||||
toast.success('Template updated successfully');
|
|
||||||
} else {
|
|
||||||
await createTemplate(payload);
|
|
||||||
toast.success('Template created successfully');
|
|
||||||
}
|
|
||||||
navigate('/admin/templates'); // Back to list
|
|
||||||
} catch (error) {
|
|
||||||
toast.error(isEditing ? 'Failed to update template' : 'Failed to create template');
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (fetching) {
|
|
||||||
return (
|
|
||||||
<div className="flex h-96 items-center justify-center">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isFormValid = formData.name &&
|
|
||||||
formData.description &&
|
|
||||||
formData.approvers.length > 0 &&
|
|
||||||
formData.approvers.every((a: any) => {
|
|
||||||
const val = parseInt(String(a.tat)) || 0;
|
|
||||||
const max = a.tatType === 'days' ? 7 : 24;
|
|
||||||
return val >= 1 && val <= max;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
|
||||||
<div className="flex items-center gap-4 mb-6">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/admin/templates')}>
|
|
||||||
<ArrowLeft className="w-5 h-5" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900">
|
|
||||||
{isEditing ? 'Edit Workflow Template' : 'Create Workflow Template'}
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-500">
|
|
||||||
{isEditing ? 'Update existing workflow configuration' : 'Define a new standardized request workflow'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Basic Information</CardTitle>
|
|
||||||
<CardDescription>General details about the template</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="name">Template Name *</Label>
|
|
||||||
<Input
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
placeholder="e.g., Office Stationery Request"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="border-gray-200"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="category">Category</Label>
|
|
||||||
<div className="relative">
|
|
||||||
{/* Simple text input for now, could be select */}
|
|
||||||
<Input
|
|
||||||
id="category"
|
|
||||||
name="category"
|
|
||||||
placeholder="e.g., Admin, HR, Finance"
|
|
||||||
value={formData.category}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description *</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
placeholder="Describe what this request is for..."
|
|
||||||
value={formData.description}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="border-gray-200"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="priority">Default Priority</Label>
|
|
||||||
<Select
|
|
||||||
name="priority"
|
|
||||||
value={formData.priority}
|
|
||||||
onValueChange={(val) => handleSelectChange('priority', val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select priority" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="low">Low</SelectItem>
|
|
||||||
<SelectItem value="medium">Medium</SelectItem>
|
|
||||||
<SelectItem value="high">High</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="estimatedTime">Estimated Time</Label>
|
|
||||||
<Input
|
|
||||||
id="estimatedTime"
|
|
||||||
name="estimatedTime"
|
|
||||||
placeholder="e.g., 2 days"
|
|
||||||
value={formData.estimatedTime}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="suggestedSLA">SLA (Hours)</Label>
|
|
||||||
<Input
|
|
||||||
id="suggestedSLA"
|
|
||||||
name="suggestedSLA"
|
|
||||||
type="number"
|
|
||||||
placeholder="24"
|
|
||||||
value={formData.suggestedSLA}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Approver Workflow</CardTitle>
|
|
||||||
<CardDescription>Define static approvers for this template</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-6">
|
|
||||||
<div className="space-y-4">
|
|
||||||
{formData.approvers.map((approver, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-4 p-3 bg-gray-50 rounded-lg border border-gray-100">
|
|
||||||
<Badge variant="outline" className="bg-white">Level {approver.level}</Badge>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-gray-900">{approver.name}</div>
|
|
||||||
<div className="text-sm text-gray-500">{approver.email}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<Label htmlFor={`tat-${index}`} className="text-xs whitespace-nowrap">TAT</Label>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
id={`tat-${index}`}
|
|
||||||
type="number"
|
|
||||||
className="h-8 w-16 border-gray-200"
|
|
||||||
value={approver.tat || ''}
|
|
||||||
min={1}
|
|
||||||
max={approver.tatType === 'days' ? 7 : 24}
|
|
||||||
placeholder={approver.tatType === 'days' ? '1' : '24'}
|
|
||||||
onChange={(e) => {
|
|
||||||
const val = parseInt(e.target.value) || 0;
|
|
||||||
// const max = approver.tatType === 'days' ? 7 : 24;
|
|
||||||
// Optional: strict clamping or just allow typing and validate later
|
|
||||||
// For better UX, let's allow typing but validate in isFormValid
|
|
||||||
// But prevent entering negative numbers
|
|
||||||
if (val < 0) return;
|
|
||||||
|
|
||||||
const newApprovers = [...formData.approvers];
|
|
||||||
newApprovers[index].tat = e.target.value; // Store as string to allow clearing
|
|
||||||
setFormData(prev => ({ ...prev, approvers: newApprovers }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={approver.tatType || 'hours'}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
const newApprovers = [...formData.approvers];
|
|
||||||
newApprovers[index].tatType = val;
|
|
||||||
newApprovers[index].tat = 1; // Reset to 1 on change
|
|
||||||
setFormData(prev => ({ ...prev, approvers: newApprovers }));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-20 text-xs px-2">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="hours">Hours</SelectItem>
|
|
||||||
<SelectItem value="days">Days</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => removeApprover(index)}>
|
|
||||||
<X className="w-4 h-4 text-gray-500 hover:text-red-600" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{formData.approvers.length === 0 && (
|
|
||||||
<div className="text-center p-8 border-2 border-dashed rounded-lg text-gray-500 text-sm">
|
|
||||||
No approvers defined. Requests will be auto-approved or require manual assignment.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 relative">
|
|
||||||
<Label>Add Approver</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Type '@' to search user by name or email..."
|
|
||||||
value={approverSearchInput}
|
|
||||||
onChange={(e) => handleApproverSearch(e.target.value)}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(searchLoading || searchResults.length > 0) && (
|
|
||||||
<div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded-lg shadow-lg z-10 max-h-60 overflow-y-auto">
|
|
||||||
{searchLoading && <div className="p-2 text-sm text-gray-500">Searching...</div>}
|
|
||||||
{searchResults.map(user => (
|
|
||||||
<div
|
|
||||||
key={user.userId}
|
|
||||||
className="p-2 hover:bg-gray-50 cursor-pointer flex items-center gap-3"
|
|
||||||
onClick={() => addApprover(user)}
|
|
||||||
>
|
|
||||||
<Avatar className="h-8 w-8">
|
|
||||||
<AvatarFallback>{(user.displayName || 'U').substring(0, 2)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium">{user.displayName}</div>
|
|
||||||
<div className="text-xs text-gray-500">{user.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TAT Summary */}
|
|
||||||
{formData.approvers.length > 0 && (
|
|
||||||
<div className="mt-6 p-4 bg-gradient-to-r from-emerald-50 to-teal-50 rounded-lg border border-emerald-200">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Clock className="w-5 h-5 text-emerald-600 mt-0.5" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<h4 className="font-semibold text-emerald-900">TAT Summary</h4>
|
|
||||||
<div className="text-right">
|
|
||||||
{(() => {
|
|
||||||
const totalCalendarDays = formData.approvers.reduce((sum: number, a: any) => {
|
|
||||||
const tat = Number(a.tat || 0);
|
|
||||||
const tatType = a.tatType || 'hours';
|
|
||||||
if (tatType === 'days') {
|
|
||||||
return sum + tat;
|
|
||||||
} else {
|
|
||||||
return sum + (tat / 24);
|
|
||||||
}
|
|
||||||
}, 0) || 0;
|
|
||||||
const displayDays = Math.ceil(totalCalendarDays);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="text-lg font-bold text-emerald-800">{displayDays} {displayDays === 1 ? 'Day' : 'Days'}</div>
|
|
||||||
<div className="text-xs text-emerald-600">Total Duration</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{formData.approvers.map((approver: any, idx: number) => {
|
|
||||||
const tat = Number(approver.tat || 0);
|
|
||||||
const tatType = approver.tatType || 'hours';
|
|
||||||
const hours = tatType === 'days' ? tat * 24 : tat;
|
|
||||||
if (!tat) return null;
|
|
||||||
return (
|
|
||||||
<div key={idx} className="bg-white/60 p-2 rounded border border-emerald-100">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium text-emerald-900">Level {idx + 1}</span>
|
|
||||||
<span className="text-sm text-emerald-700">{hours} {hours === 1 ? 'hour' : 'hours'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{(() => {
|
|
||||||
const totalHours = formData.approvers.reduce((sum: number, a: any) => {
|
|
||||||
const tat = Number(a.tat || 0);
|
|
||||||
const tatType = a.tatType || 'hours';
|
|
||||||
if (tatType === 'days') {
|
|
||||||
return sum + (tat * 24);
|
|
||||||
} else {
|
|
||||||
return sum + tat;
|
|
||||||
}
|
|
||||||
}, 0) || 0;
|
|
||||||
const workingDays = Math.ceil(totalHours / 8);
|
|
||||||
if (totalHours === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className="bg-white/80 p-3 rounded border border-emerald-200">
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-lg font-bold text-emerald-800">{totalHours}h</div>
|
|
||||||
<div className="text-xs text-emerald-600">Total Hours</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-lg font-bold text-emerald-800">{workingDays}</div>
|
|
||||||
<div className="text-xs text-emerald-600">Working Days*</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-emerald-600 mt-2 text-center">*Based on 8-hour working days</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
|
||||||
<Button type="button" variant="outline" onClick={() => navigate('/admin/templates')}>Cancel</Button>
|
|
||||||
<Button type="submit" disabled={loading || !isFormValid} className="bg-re-green hover:bg-re-green/90">
|
|
||||||
{loading ? 'Saving...' : (
|
|
||||||
<>
|
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
{isEditing ? 'Update Template' : 'Create Template'}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,13 +2,13 @@ import { useState, useEffect } from 'react';
|
|||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||||
import { LogIn } from 'lucide-react';
|
import { LogIn, Shield } from 'lucide-react';
|
||||||
import { ReLogo, LandingPageImage } from '@/assets';
|
import { ReLogo, LandingPageImage } from '@/assets';
|
||||||
// import { initiateTanflowLogin } from '@/services/tanflowAuth';
|
import { initiateTanflowLogin } from '@/services/tanflowAuth';
|
||||||
|
|
||||||
export function Auth() {
|
export function Auth() {
|
||||||
const { login, isLoading, error } = useAuth();
|
const { login, isLoading, error } = useAuth();
|
||||||
const [tanflowLoading] = useState(false);
|
const [tanflowLoading, setTanflowLoading] = useState(false);
|
||||||
const [imageLoaded, setImageLoaded] = useState(false);
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
|
||||||
// Preload the background image
|
// Preload the background image
|
||||||
@ -41,7 +41,7 @@ export function Auth() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/* const handleTanflowLogin = () => {
|
const handleTanflowLogin = () => {
|
||||||
// Clear any existing session data
|
// Clear any existing session data
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
sessionStorage.clear();
|
sessionStorage.clear();
|
||||||
@ -55,7 +55,7 @@ export function Auth() {
|
|||||||
console.error('Error details:', loginError);
|
console.error('Error details:', loginError);
|
||||||
setTanflowLoading(false);
|
setTanflowLoading(false);
|
||||||
}
|
}
|
||||||
}; */
|
};
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Auth Error in Auth Component:', {
|
console.error('Auth Error in Auth Component:', {
|
||||||
@ -123,7 +123,7 @@ export function Auth() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{/*
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 flex items-center">
|
<div className="absolute inset-0 flex items-center">
|
||||||
<span className="w-full border-t border-gray-700"></span>
|
<span className="w-full border-t border-gray-700"></span>
|
||||||
@ -152,7 +152,7 @@ export function Auth() {
|
|||||||
Dealer Login
|
Dealer Login
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button> */}
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-sm text-gray-400 mt-4">
|
<div className="text-center text-sm text-gray-400 mt-4">
|
||||||
|
|||||||
@ -29,14 +29,14 @@ export function ClosedRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
activeFiltersCount,
|
activeFiltersCount,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange,
|
||||||
onSortByChange,
|
onSortByChange,
|
||||||
onSortOrderChange,
|
onSortOrderChange,
|
||||||
onClearFilters,
|
onClearFilters,
|
||||||
@ -75,7 +75,7 @@ export function ClosedRequestsFilters({
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<Input
|
<Input
|
||||||
@ -129,7 +129,7 @@ export function ClosedRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white" data-testid="closed-requests-template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -138,7 +138,7 @@ export function ClosedRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
<Select value={sortBy} onValueChange={(value) => onSortByChange(value as 'created' | 'due' | 'priority')}>
|
||||||
|
|||||||
@ -1,232 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { ArrowLeft, ChevronRight, Check } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
|
||||||
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
|
|
||||||
import { createWorkflowMultipart, submitWorkflow, CreateWorkflowFromFormPayload } from '@/services/workflowApi';
|
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
|
||||||
import { FileText } from 'lucide-react';
|
|
||||||
import { AdminRequestDetailsStep } from './components/AdminRequestDetailsStep';
|
|
||||||
import { AdminRequestReviewStep } from './components/AdminRequestReviewStep';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
|
|
||||||
|
|
||||||
export function CreateAdminRequest() {
|
|
||||||
const { templateId } = useParams<{ templateId: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user: _ } = useAuth(); // Keeping hook call but ignoring return if needed for auth check side effect, or just remove destructuring
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [template, setTemplate] = useState<RequestTemplate | null>(null);
|
|
||||||
const [step, setStep] = useState(1);
|
|
||||||
const [documents, setDocuments] = useState<File[]>([]);
|
|
||||||
|
|
||||||
// Simplified form data
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const stepNames = ['Request Details', 'Review & Submit'];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadTemplate = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
// Ideally we would have a getTemplateById API, but for now we filter from list
|
|
||||||
// Optimization: In a real app, create a specific endpoint
|
|
||||||
const templates = await getTemplates();
|
|
||||||
const found = templates.find((t: BackendTemplate) => t.id === templateId);
|
|
||||||
|
|
||||||
if (found) {
|
|
||||||
const mapped: RequestTemplate = {
|
|
||||||
id: found.id,
|
|
||||||
name: found.name,
|
|
||||||
description: found.description,
|
|
||||||
category: found.category,
|
|
||||||
icon: FileText,
|
|
||||||
estimatedTime: found.estimatedTime,
|
|
||||||
commonApprovers: found.approvers.map((a: any) => a.name),
|
|
||||||
workflowApprovers: found.approvers,
|
|
||||||
suggestedSLA: found.suggestedSLA,
|
|
||||||
priority: found.priority,
|
|
||||||
fields: found.fields || {}
|
|
||||||
};
|
|
||||||
setTemplate(mapped);
|
|
||||||
|
|
||||||
// Pre-fill
|
|
||||||
setFormData({
|
|
||||||
title: mapped.name,
|
|
||||||
description: mapped.description
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast.error('Template not found');
|
|
||||||
// navigate('/new-request'); // Removed to prevent potential redirect loops
|
|
||||||
// We will show the "Template not found" UI below since template is null
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading template:', error);
|
|
||||||
toast.error('Failed to load template details');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (templateId) {
|
|
||||||
loadTemplate();
|
|
||||||
}
|
|
||||||
}, [templateId, navigate]);
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!template) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setSubmitting(true);
|
|
||||||
|
|
||||||
const formPayload: CreateWorkflowFromFormPayload = {
|
|
||||||
templateId: template.id,
|
|
||||||
templateType: 'TEMPLATE',
|
|
||||||
title: formData.title,
|
|
||||||
description: formData.description,
|
|
||||||
priorityUi: template.priority === 'high' ? 'express' : 'standard',
|
|
||||||
approverCount: template.workflowApprovers?.length || 0,
|
|
||||||
approvers: (template.workflowApprovers || []).map((a: any) => ({
|
|
||||||
email: a.email,
|
|
||||||
name: a.name,
|
|
||||||
tat: a.tat,
|
|
||||||
tatType: 'hours'
|
|
||||||
})),
|
|
||||||
spectators: [],
|
|
||||||
ccList: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await createWorkflowMultipart(formPayload, documents);
|
|
||||||
|
|
||||||
if (response && response.id) {
|
|
||||||
await submitWorkflow(response.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Request Submitted Successfully', {
|
|
||||||
description: `Your request "${formData.title}" has been created.`
|
|
||||||
});
|
|
||||||
|
|
||||||
navigate('/my-requests');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submission failed:', error);
|
|
||||||
toast.error('Failed to submit request');
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-4">
|
|
||||||
<div className="bg-white p-8 rounded-lg shadow-md max-w-md w-full text-center">
|
|
||||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<FileText className="w-8 h-8 text-red-600" />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">Template Not Found</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
The requested template could not be loaded. It may have been deleted or you do not have permission to view it.
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3 justify-center">
|
|
||||||
<Button variant="outline" onClick={() => navigate('/dashboard')}>
|
|
||||||
Go to Dashboard
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => navigate('/new-request')}>
|
|
||||||
Browse Templates
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white border-b flex-shrink-0 z-10">
|
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/new-request')}>
|
|
||||||
<ArrowLeft className="w-5 h-5 text-gray-500" />
|
|
||||||
</Button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold text-gray-900">New Request</h1>
|
|
||||||
<p className="text-sm text-gray-500">{template.name}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="outline" onClick={() => navigate('/dashboard')}>
|
|
||||||
Cancel Request
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stepper */}
|
|
||||||
<WizardStepper
|
|
||||||
currentStep={step}
|
|
||||||
totalSteps={2}
|
|
||||||
stepNames={stepNames}
|
|
||||||
/>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<main className="flex-1 overflow-y-auto py-8 px-6 bg-gray-50/50">
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
{step === 1 ? (
|
|
||||||
<AdminRequestDetailsStep
|
|
||||||
template={template}
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
documents={documents}
|
|
||||||
setDocuments={setDocuments}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AdminRequestReviewStep
|
|
||||||
template={template}
|
|
||||||
formData={formData}
|
|
||||||
documents={documents}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
|
||||||
<footer className="bg-white border-t px-6 py-4 flex-shrink-0 z-10">
|
|
||||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => step === 1 ? navigate('/new-request') : setStep(1)}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{step === 1 ? 'Cancel' : 'Back to Details'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={() => step === 1 ? setStep(2) : handleSubmit()}
|
|
||||||
disabled={submitting}
|
|
||||||
className={step === 2 ? "bg-re-green hover:bg-re-green/90" : "bg-re-green hover:bg-re-green/90"}
|
|
||||||
>
|
|
||||||
{step === 1 ? (
|
|
||||||
<>Review Request <ChevronRight className="w-4 h-4 ml-1" /></>
|
|
||||||
) : (
|
|
||||||
<>{submitting ? 'Submitting...' : 'Submit Request'} <Check className="w-4 h-4 ml-1" /></>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
import { useRef, useState } from 'react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Upload, X, FileText, Eye } from 'lucide-react';
|
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
|
||||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
|
||||||
import { FilePreview } from '@/components/common/FilePreview/FilePreview';
|
|
||||||
|
|
||||||
interface AdminRequestDetailsStepProps {
|
|
||||||
template: RequestTemplate;
|
|
||||||
formData: any;
|
|
||||||
setFormData: (data: any) => void;
|
|
||||||
documents: File[];
|
|
||||||
setDocuments: (docs: File[]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminRequestDetailsStep({
|
|
||||||
template,
|
|
||||||
formData,
|
|
||||||
setFormData,
|
|
||||||
documents,
|
|
||||||
setDocuments
|
|
||||||
}: AdminRequestDetailsStepProps) {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [previewFile, setPreviewFile] = useState<{ file: File; url: string } | null>(null);
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
setDocuments([...documents, ...Array.from(e.target.files)]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDocument = (index: number) => {
|
|
||||||
const newDocs = [...documents];
|
|
||||||
newDocs.splice(index, 1);
|
|
||||||
setDocuments(newDocs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePreview = (file: File) => {
|
|
||||||
const url = URL.createObjectURL(file);
|
|
||||||
setPreviewFile({ file, url });
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePreview = () => {
|
|
||||||
if (previewFile?.url) {
|
|
||||||
URL.revokeObjectURL(previewFile.url);
|
|
||||||
}
|
|
||||||
setPreviewFile(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const canPreview = (file: File) => {
|
|
||||||
return file.type.includes('image') || file.type.includes('pdf');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-4xl mx-auto">
|
|
||||||
<Card className="shadow-sm">
|
|
||||||
<CardContent className="pt-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-xl font-bold text-gray-800">{template.name}</h2>
|
|
||||||
<p className="text-sm text-gray-500 mt-1">{template.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 text-sm text-gray-600 bg-gray-50 p-3 rounded-lg">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="font-semibold">Category:</span> {template.category}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="font-semibold">Priority:</span>
|
|
||||||
<span className="capitalize">{template.priority}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="font-semibold">SLA:</span> {template.suggestedSLA} Hours
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="requestTitle">Request Title *</Label>
|
|
||||||
<Input
|
|
||||||
id="requestTitle"
|
|
||||||
value={formData.title}
|
|
||||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
|
||||||
placeholder={`Request for ${template.name}`}
|
|
||||||
className="border-gray-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="justification" className="text-base font-semibold">Request Detail *</Label>
|
|
||||||
<p className="text-sm text-gray-600 mb-2">
|
|
||||||
Explain what you need approval for, why it's needed, and any relevant details.
|
|
||||||
</p>
|
|
||||||
<RichTextEditor
|
|
||||||
value={formData.description || ''}
|
|
||||||
onChange={(html) => setFormData({ ...formData, description: html })}
|
|
||||||
placeholder="Provide comprehensive details about your request..."
|
|
||||||
className="min-h-[120px] text-base border-gray-200 bg-white shadow-sm"
|
|
||||||
minHeight="120px"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardContent className="pt-6 space-y-4">
|
|
||||||
<Label>Supporting Documents</Label>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:bg-gray-50 transition-colors cursor-pointer"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<Upload className="w-10 h-10 text-gray-400 mx-auto mb-3" />
|
|
||||||
<p className="text-sm font-medium text-gray-700">Click to upload files</p>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">PDF, Excel, Images (Max 10MB)</p>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{documents.length > 0 && (
|
|
||||||
<div className="grid grid-cols-1 gap-2 mt-4">
|
|
||||||
{documents.map((file, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between p-3 bg-white border rounded-lg shadow-sm">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center">
|
|
||||||
<FileText className="w-5 h-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-800 truncate max-w-[200px]">{file.name}</p>
|
|
||||||
<p className="text-xs text-gray-500">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{canPreview(file) && (
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => handlePreview(file)}>
|
|
||||||
<Eye className="w-4 h-4 text-gray-500 hover:text-blue-600" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => removeDocument(index)}>
|
|
||||||
<X className="w-4 h-4 text-gray-500 hover:text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{previewFile && (
|
|
||||||
<FilePreview
|
|
||||||
fileName={previewFile.file.name}
|
|
||||||
fileType={previewFile.file.type}
|
|
||||||
fileUrl={previewFile.url}
|
|
||||||
fileSize={previewFile.file.size}
|
|
||||||
open={!!previewFile}
|
|
||||||
onClose={closePreview}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { FileText, AlertCircle } from 'lucide-react';
|
|
||||||
import { RequestTemplate } from '@/hooks/useCreateRequestForm';
|
|
||||||
|
|
||||||
interface AdminRequestReviewStepProps {
|
|
||||||
template: RequestTemplate;
|
|
||||||
formData: any;
|
|
||||||
documents: File[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AdminRequestReviewStep({
|
|
||||||
template,
|
|
||||||
formData,
|
|
||||||
documents
|
|
||||||
}: AdminRequestReviewStepProps) {
|
|
||||||
|
|
||||||
// Use template approvers if available, otherwise fallback (though should always be there for admin templates)
|
|
||||||
const approvers = template.workflowApprovers || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 max-w-4xl mx-auto">
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
|
|
||||||
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<h4 className="font-semibold text-blue-900">Ready to Submit?</h4>
|
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
|
||||||
Please review the details below. This request will follow the standardized approval workflow defined by the administrator.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="md:col-span-2 space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg">Request Overview</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Title</span>
|
|
||||||
<p className="text-base font-medium text-gray-900 mt-1">{formData.title}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Request Detail</span>
|
|
||||||
<div
|
|
||||||
className="text-sm text-gray-700 mt-1 prose prose-sm max-w-none"
|
|
||||||
dangerouslySetInnerHTML={{ __html: formData.description }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{documents.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wider block mb-2">Attachments ({documents.length})</span>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{documents.map((doc, i) => (
|
|
||||||
<Badge key={i} variant="secondary" className="pl-1 pr-2 py-1 flex items-center gap-1.5 h-auto">
|
|
||||||
<FileText className="w-3 h-3 text-gray-500" />
|
|
||||||
<span className="truncate max-w-[150px]">{doc.name}</span>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-lg">Approval Workflow</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="relative pl-6 border-l-2 border-gray-100 space-y-8 py-2">
|
|
||||||
{approvers.map((approver: any, index: number) => (
|
|
||||||
<div key={index} className="relative">
|
|
||||||
{/* Timeline dot */}
|
|
||||||
<div className="absolute -left-[31px] top-1 w-4 h-4 rounded-full bg-white border-2 border-blue-500 flex items-center justify-center">
|
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-100">
|
|
||||||
<div className="flex justify-between items-start mb-1">
|
|
||||||
<div>
|
|
||||||
<h5 className="font-semibold text-gray-800 text-sm">{approver.name || approver.email}</h5>
|
|
||||||
<p className="text-xs text-gray-500">Level {approver.level} Approver</p>
|
|
||||||
</div>
|
|
||||||
<Badge variant="outline" className="bg-white text-xs">
|
|
||||||
{approver.tat || 24} Hours TAT
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400">{approver.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-6">
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<CardTitle className="text-sm uppercase text-gray-500">Properties</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Template</span>
|
|
||||||
<span className="text-sm font-medium text-right">{template.name}</span>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Priority</span>
|
|
||||||
<Badge className={
|
|
||||||
template.priority === 'high' ? 'bg-red-100 text-red-700 hover:bg-red-100' :
|
|
||||||
template.priority === 'medium' ? 'bg-orange-100 text-orange-700 hover:bg-orange-100' :
|
|
||||||
'bg-green-100 text-green-700 hover:bg-green-100'
|
|
||||||
}>
|
|
||||||
{template.priority.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-sm text-gray-600">Est. Time</span>
|
|
||||||
<span className="text-sm text-gray-900">{template.estimatedTime}</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -13,7 +13,7 @@
|
|||||||
* - components/ - UI components
|
* - components/ - UI components
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
||||||
@ -22,7 +22,7 @@ import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
|||||||
import { downloadDocument } from '@/services/workflowApi';
|
import { downloadDocument } from '@/services/workflowApi';
|
||||||
|
|
||||||
// Custom Hooks
|
// Custom Hooks
|
||||||
import { useCreateRequestForm, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
import { useCreateRequestForm } from '@/hooks/useCreateRequestForm';
|
||||||
import { useWizardNavigation } from '@/hooks/useWizardNavigation';
|
import { useWizardNavigation } from '@/hooks/useWizardNavigation';
|
||||||
import { useRequestModals } from './hooks/useRequestModals';
|
import { useRequestModals } from './hooks/useRequestModals';
|
||||||
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
|
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
|
||||||
@ -31,10 +31,6 @@ import { useCreateRequestHandlers } from './hooks/useCreateRequestHandlers';
|
|||||||
// Constants
|
// Constants
|
||||||
import { REQUEST_TEMPLATES } from './constants/requestTemplates';
|
import { REQUEST_TEMPLATES } from './constants/requestTemplates';
|
||||||
|
|
||||||
// Services
|
|
||||||
import { getTemplates, WorkflowTemplate as BackendTemplate } from '@/services/workflowTemplateApi';
|
|
||||||
import { FileText } from 'lucide-react';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
|
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
|
||||||
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
|
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
|
||||||
@ -68,35 +64,6 @@ export function CreateRequest({
|
|||||||
const isEditing = isEditMode && !!editRequestId;
|
const isEditing = isEditMode && !!editRequestId;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [adminTemplates, setAdminTemplates] = useState<RequestTemplate[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchTemplates = async () => {
|
|
||||||
try {
|
|
||||||
const templates = await getTemplates();
|
|
||||||
const mappedTemplates: RequestTemplate[] = templates.map((t: BackendTemplate) => ({
|
|
||||||
id: t.id,
|
|
||||||
name: t.name,
|
|
||||||
description: t.description,
|
|
||||||
category: t.category,
|
|
||||||
icon: FileText,
|
|
||||||
estimatedTime: t.estimatedTime,
|
|
||||||
commonApprovers: t.approvers.map((a: any) => a.name),
|
|
||||||
workflowApprovers: t.approvers,
|
|
||||||
suggestedSLA: t.suggestedSLA,
|
|
||||||
priority: t.priority,
|
|
||||||
fields: t.fields || {}
|
|
||||||
}));
|
|
||||||
setAdminTemplates(mappedTemplates);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch admin templates:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
fetchTemplates();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const allTemplates = useMemo(() => [...REQUEST_TEMPLATES, ...adminTemplates], [adminTemplates]);
|
|
||||||
|
|
||||||
// Form and state management hooks
|
// Form and state management hooks
|
||||||
const {
|
const {
|
||||||
formData,
|
formData,
|
||||||
@ -108,7 +75,7 @@ export function CreateRequest({
|
|||||||
documentPolicy,
|
documentPolicy,
|
||||||
existingDocuments,
|
existingDocuments,
|
||||||
setExistingDocuments,
|
setExistingDocuments,
|
||||||
} = useCreateRequestForm(isEditing, editRequestId, allTemplates);
|
} = useCreateRequestForm(isEditing, editRequestId, REQUEST_TEMPLATES);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
currentStep,
|
currentStep,
|
||||||
@ -117,7 +84,6 @@ export function CreateRequest({
|
|||||||
isStepValid,
|
isStepValid,
|
||||||
nextStep: wizardNextStep,
|
nextStep: wizardNextStep,
|
||||||
prevStep: wizardPrevStep,
|
prevStep: wizardPrevStep,
|
||||||
goToStep,
|
|
||||||
} = useWizardNavigation(isEditing, selectedTemplate, formData);
|
} = useWizardNavigation(isEditing, selectedTemplate, formData);
|
||||||
|
|
||||||
// Document management state
|
// Document management state
|
||||||
@ -176,37 +142,22 @@ export function CreateRequest({
|
|||||||
systemPolicy,
|
systemPolicy,
|
||||||
onPolicyViolation: openPolicyViolationModal,
|
onPolicyViolation: openPolicyViolationModal,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
goToStep,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle back button:
|
// Handle back button:
|
||||||
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
|
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
|
||||||
// - Other steps: Go to previous step in wizard
|
// - Other steps: Go to previous step in wizard
|
||||||
const handleBackButton = useCallback(() => {
|
const handleBackButton = useCallback(() => {
|
||||||
// If on the first step (Template Selection), always go back to dashboard
|
if (currentStep === 1 || currentStep === 3 || currentStep === 4) {
|
||||||
// This prevents infinite loops if the user was redirected here from an error page
|
// On steps 1, 3, or 4, navigate back to previous screen using browser history
|
||||||
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 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -259,7 +210,6 @@ export function CreateRequest({
|
|||||||
templates={REQUEST_TEMPLATES}
|
templates={REQUEST_TEMPLATES}
|
||||||
selectedTemplate={selectedTemplate}
|
selectedTemplate={selectedTemplate}
|
||||||
onSelectTemplate={selectTemplate}
|
onSelectTemplate={selectTemplate}
|
||||||
adminTemplates={adminTemplates}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 2:
|
case 2:
|
||||||
|
|||||||
@ -32,7 +32,6 @@ interface UseHandlersOptions {
|
|||||||
systemPolicy?: SystemPolicy;
|
systemPolicy?: SystemPolicy;
|
||||||
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
onPolicyViolation?: (violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>) => void;
|
||||||
onSubmit?: (requestData: any) => void;
|
onSubmit?: (requestData: any) => void;
|
||||||
goToStep?: (step: number) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreateRequestHandlers({
|
export function useCreateRequestHandlers({
|
||||||
@ -49,7 +48,6 @@ export function useCreateRequestHandlers({
|
|||||||
systemPolicy,
|
systemPolicy,
|
||||||
onPolicyViolation,
|
onPolicyViolation,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
// goToStep,
|
|
||||||
}: UseHandlersOptions) {
|
}: UseHandlersOptions) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
||||||
@ -68,11 +66,6 @@ export function useCreateRequestHandlers({
|
|||||||
updateFormData('slaEndDate', suggestedDate);
|
updateFormData('slaEndDate', suggestedDate);
|
||||||
|
|
||||||
// Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep)
|
// Note: For 'existing-template', the modal will open when Next is clicked (handled in nextStep)
|
||||||
|
|
||||||
if (template.id !== 'custom' && template.id !== 'existing-template') {
|
|
||||||
// Redirect to dedicated Admin Request flow
|
|
||||||
navigate(`/create-admin-request/${template.id}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTemplateSelection = (templateId: string) => {
|
const handleTemplateSelection = (templateId: string) => {
|
||||||
|
|||||||
@ -22,11 +22,11 @@ export function MyRequestsFilters({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
priorityFilter,
|
priorityFilter,
|
||||||
// templateTypeFilter,
|
templateTypeFilter,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onPriorityChange,
|
onPriorityChange,
|
||||||
// onTemplateTypeChange,
|
onTemplateTypeChange,
|
||||||
}: MyRequestsFiltersProps) {
|
}: MyRequestsFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<Card className="border-gray-200" data-testid="my-requests-filters">
|
<Card className="border-gray-200" data-testid="my-requests-filters">
|
||||||
@ -76,7 +76,7 @@ export function MyRequestsFilters({
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
<Select value={templateTypeFilter} onValueChange={onTemplateTypeChange}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||||
data-testid="template-type-filter"
|
data-testid="template-type-filter"
|
||||||
@ -88,7 +88,7 @@ export function MyRequestsFilters({
|
|||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -204,11 +204,11 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
priority: (r.priority || '').toString().toLowerCase(),
|
priority: (r.priority || '').toString().toLowerCase(),
|
||||||
initiator: {
|
initiator: {
|
||||||
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
||||||
avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase())
|
avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase())
|
||||||
},
|
},
|
||||||
currentApprover: r.currentApprover ? {
|
currentApprover: r.currentApprover ? {
|
||||||
name: (r.currentApprover.name || r.currentApprover.email || '—'),
|
name: (r.currentApprover.name || r.currentApprover.email || '—'),
|
||||||
avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase()),
|
avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()),
|
||||||
sla: r.currentApprover.sla // ← Backend-calculated SLA
|
sla: r.currentApprover.sla // ← Backend-calculated SLA
|
||||||
} : undefined,
|
} : undefined,
|
||||||
createdAt: createdAt || '—',
|
createdAt: createdAt || '—',
|
||||||
|
|||||||
@ -553,7 +553,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* Primary Filters */}
|
{/* Primary Filters */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||||
<div className="relative md:col-span-3 lg:col-span-1">
|
<div className="relative md:col-span-3 lg:col-span-1">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||||
<Input
|
<Input
|
||||||
@ -590,7 +590,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* <Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
<Select value={filters.templateTypeFilter} onValueChange={filters.setTemplateTypeFilter}>
|
||||||
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
<SelectTrigger className="h-10" data-testid="template-type-filter">
|
||||||
<SelectValue placeholder="All Templates" />
|
<SelectValue placeholder="All Templates" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -599,7 +599,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
<SelectItem value="CUSTOM">Non-Templatized</SelectItem>
|
||||||
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
<SelectItem value="DEALER CLAIM">Dealer Claim</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select> */}
|
</Select>
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
value={filters.departmentFilter}
|
value={filters.departmentFilter}
|
||||||
|
|||||||
@ -164,7 +164,7 @@ export function Settings() {
|
|||||||
{/* Tabs for Admin, Cards for Non-Admin */}
|
{/* Tabs for Admin, Cards for Non-Admin */}
|
||||||
{isAdmin ? (
|
{isAdmin ? (
|
||||||
<Tabs defaultValue="user" className="w-full">
|
<Tabs defaultValue="user" className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4 mb-8 bg-slate-100 p-1 rounded-xl h-auto gap-1">
|
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-5 mb-8 bg-slate-100 p-1 rounded-xl h-auto gap-1">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="user"
|
value="user"
|
||||||
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
|
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
|
||||||
@ -197,14 +197,14 @@ export function Settings() {
|
|||||||
<span className="hidden sm:inline">Holidays</span>
|
<span className="hidden sm:inline">Holidays</span>
|
||||||
<span className="sm:hidden">Holidays</span>
|
<span className="sm:hidden">Holidays</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
{/* <TabsTrigger
|
<TabsTrigger
|
||||||
value="templates"
|
value="templates"
|
||||||
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
|
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Templates</span>
|
<span className="hidden sm:inline">Templates</span>
|
||||||
<span className="sm:hidden">Templates</span>
|
<span className="sm:hidden">Templates</span>
|
||||||
</TabsTrigger> */}
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Fixed width container to prevent layout shifts */}
|
{/* Fixed width container to prevent layout shifts */}
|
||||||
|
|||||||
@ -1,95 +0,0 @@
|
|||||||
import apiClient from './authApi';
|
|
||||||
|
|
||||||
export interface WorkflowTemplate {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
category: string;
|
|
||||||
priority: 'low' | 'medium' | 'high';
|
|
||||||
estimatedTime: string;
|
|
||||||
approvers: any[];
|
|
||||||
suggestedSLA: number;
|
|
||||||
isActive: boolean;
|
|
||||||
fields?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simple in-memory cache
|
|
||||||
let templatesCache: WorkflowTemplate[] | null = null;
|
|
||||||
|
|
||||||
export const getCachedTemplates = () => templatesCache;
|
|
||||||
|
|
||||||
export const createTemplate = async (templateData: Partial<WorkflowTemplate>): Promise<WorkflowTemplate> => {
|
|
||||||
const response = await apiClient.post('/templates', templateData);
|
|
||||||
const t = response.data.data;
|
|
||||||
|
|
||||||
// Map backend response
|
|
||||||
const mappedTemplate = {
|
|
||||||
id: t.templateId || t.id,
|
|
||||||
name: t.templateName || t.name,
|
|
||||||
description: t.templateDescription || t.description,
|
|
||||||
category: t.templateCategory || t.category,
|
|
||||||
priority: t.priority || 'medium',
|
|
||||||
estimatedTime: t.estimatedTime || 'Variable',
|
|
||||||
approvers: t.approvalLevelsConfig || t.approvers || [],
|
|
||||||
suggestedSLA: t.defaultTatHours || t.suggestedSLA || 24,
|
|
||||||
isActive: t.isActive,
|
|
||||||
fields: t.userFieldMappings || t.fields
|
|
||||||
};
|
|
||||||
|
|
||||||
// Invalidate cache or add to it
|
|
||||||
if (templatesCache) templatesCache.push(mappedTemplate as WorkflowTemplate);
|
|
||||||
return mappedTemplate as WorkflowTemplate;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTemplates = async (): Promise<WorkflowTemplate[]> => {
|
|
||||||
const response = await apiClient.get('/templates');
|
|
||||||
const data = response.data?.data || [];
|
|
||||||
|
|
||||||
// Map backend response to frontend interface
|
|
||||||
const mappedData = data.map((t: any) => ({
|
|
||||||
id: t.templateId || t.id,
|
|
||||||
name: t.templateName || t.name,
|
|
||||||
description: t.templateDescription || t.description,
|
|
||||||
category: t.templateCategory || t.category,
|
|
||||||
priority: t.priority || 'medium', // Default if missing
|
|
||||||
estimatedTime: t.estimatedTime || 'Variable',
|
|
||||||
approvers: t.approvalLevelsConfig || t.approvers || [],
|
|
||||||
suggestedSLA: t.defaultTatHours || t.suggestedSLA || 24,
|
|
||||||
isActive: t.isActive,
|
|
||||||
fields: t.userFieldMappings || t.fields
|
|
||||||
}));
|
|
||||||
|
|
||||||
templatesCache = mappedData;
|
|
||||||
return mappedData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteTemplate = async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/templates/${id}`);
|
|
||||||
if (templatesCache) {
|
|
||||||
templatesCache = templatesCache.filter(t => t.id !== id);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateTemplate = async (id: string, templateData: Partial<WorkflowTemplate>): Promise<WorkflowTemplate> => {
|
|
||||||
const response = await apiClient.put(`/templates/${id}`, templateData);
|
|
||||||
const t = response.data.data;
|
|
||||||
|
|
||||||
// Map backend response
|
|
||||||
const mappedTemplate = {
|
|
||||||
id: t.templateId || t.id,
|
|
||||||
name: t.templateName || t.name,
|
|
||||||
description: t.templateDescription || t.description,
|
|
||||||
category: t.templateCategory || t.category,
|
|
||||||
priority: t.priority || 'medium',
|
|
||||||
estimatedTime: t.estimatedTime || 'Variable',
|
|
||||||
approvers: t.approvalLevelsConfig || t.approvers || [],
|
|
||||||
suggestedSLA: t.defaultTatHours || t.suggestedSLA || 24,
|
|
||||||
isActive: t.isActive,
|
|
||||||
fields: t.userFieldMappings || t.fields
|
|
||||||
};
|
|
||||||
|
|
||||||
if (templatesCache) {
|
|
||||||
templatesCache = templatesCache.map(cacheItem => cacheItem.id === id ? (mappedTemplate as WorkflowTemplate) : cacheItem);
|
|
||||||
}
|
|
||||||
return mappedTemplate as WorkflowTemplate;
|
|
||||||
};
|
|
||||||
Loading…
Reference in New Issue
Block a user