3345 lines
154 KiB
TypeScript
3345 lines
154 KiB
TypeScript
import { useState, useRef, useEffect } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
|
import { createWorkflowMultipart, submitWorkflow, getWorkflowDetails, updateWorkflow, updateWorkflowMultipart, getDocumentPreviewUrl, downloadDocument } from '@/services/workflowApi';
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } 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 { Separator } from '@/components/ui/separator';
|
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
|
import { FilePreview } from '@/components/common/FilePreview';
|
|
import {
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
Upload,
|
|
X,
|
|
User,
|
|
Clock,
|
|
FileText,
|
|
Check,
|
|
Users,
|
|
Zap,
|
|
Shield,
|
|
Target,
|
|
Flame,
|
|
TrendingUp,
|
|
DollarSign,
|
|
AlertCircle,
|
|
CheckCircle,
|
|
Info,
|
|
Rocket,
|
|
Plus,
|
|
Minus,
|
|
Eye,
|
|
Lightbulb,
|
|
Settings,
|
|
Loader2
|
|
} from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
|
import { toast } from 'sonner';
|
|
|
|
interface CreateRequestProps {
|
|
onBack?: () => void;
|
|
onSubmit?: (requestData: any) => void;
|
|
requestId?: string; // For edit mode
|
|
isEditMode?: boolean; // Flag to indicate edit mode
|
|
}
|
|
|
|
interface RequestTemplate {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
category: string;
|
|
icon: React.ComponentType<any>;
|
|
estimatedTime: string;
|
|
commonApprovers: string[];
|
|
suggestedSLA: number;
|
|
priority: 'high' | 'medium' | 'low';
|
|
fields: {
|
|
amount?: boolean;
|
|
vendor?: boolean;
|
|
timeline?: boolean;
|
|
impact?: boolean;
|
|
};
|
|
}
|
|
|
|
const REQUEST_TEMPLATES: RequestTemplate[] = [
|
|
{
|
|
id: 'custom',
|
|
name: 'Custom Request',
|
|
description: 'Create a custom request for unique business needs with full flexibility to define your own workflow and requirements',
|
|
category: 'General',
|
|
icon: Lightbulb,
|
|
estimatedTime: 'Variable',
|
|
commonApprovers: [],
|
|
suggestedSLA: 3,
|
|
priority: 'medium',
|
|
fields: {}
|
|
},
|
|
{
|
|
id: 'existing-template',
|
|
name: 'Existing Template',
|
|
description: 'Use a pre-configured template with predefined approval workflows, timelines, and requirements for faster processing',
|
|
category: 'Templates',
|
|
icon: FileText,
|
|
estimatedTime: '1-2 days',
|
|
commonApprovers: ['Department Head', 'Manager'],
|
|
suggestedSLA: 2,
|
|
priority: 'medium',
|
|
fields: { timeline: true }
|
|
}
|
|
];
|
|
|
|
const MOCK_USERS = [
|
|
{ id: '1', name: 'Mike Johnson', role: 'Team Lead', avatar: 'MJ', department: 'Operations', email: 'mike.johnson@royalenfield.com', level: 2, canClose: false },
|
|
{ id: '2', name: 'Lisa Wong', role: 'Finance Manager', avatar: 'LW', department: 'Finance', email: 'lisa.wong@royalenfield.com', level: 3, canClose: false },
|
|
{ id: '3', name: 'David Kumar', role: 'Department Head', avatar: 'DK', department: 'IT', email: 'david.kumar@royalenfield.com', level: 4, canClose: true },
|
|
{ id: '4', name: 'Anna Smith', role: 'Marketing Coordinator', avatar: 'AS', department: 'Marketing', email: 'anna.smith@royalenfield.com', level: 1, canClose: false },
|
|
{ id: '5', name: 'John Doe', role: 'Budget Analyst', avatar: 'JD', department: 'Finance', email: 'john.doe@royalenfield.com', level: 2, canClose: false },
|
|
{ id: '6', name: 'Sarah Chen', role: 'Marketing Manager', avatar: 'SC', department: 'Marketing', email: 'sarah.chen@royalenfield.com', level: 3, canClose: false },
|
|
{ id: '7', name: 'Emily Davis', role: 'Creative Director', avatar: 'ED', department: 'Marketing', email: 'emily.davis@royalenfield.com', level: 3, canClose: false },
|
|
{ id: '8', name: 'Robert Kim', role: 'Legal Counsel', avatar: 'RK', department: 'Legal', email: 'robert.kim@royalenfield.com', level: 4, canClose: true },
|
|
{ id: '9', name: 'Jennifer Lee', role: 'CEO', avatar: 'JL', department: 'Executive', email: 'jennifer.lee@royalenfield.com', level: 5, canClose: true },
|
|
{ id: '10', name: 'Michael Brown', role: 'CFO', avatar: 'MB', department: 'Finance', email: 'michael.brown@royalenfield.com', level: 5, canClose: true }
|
|
];
|
|
|
|
// User levels - keeping for future use
|
|
// const USER_LEVELS = [
|
|
// { level: 1, name: 'Junior Level', description: 'Junior staff and coordinators', color: 'bg-gray-100 text-gray-800' },
|
|
// { level: 2, name: 'Mid Level', description: 'Team leads and supervisors', color: 'bg-blue-100 text-blue-800' },
|
|
// { level: 3, name: 'Senior Level', description: 'Managers and senior staff', color: 'bg-green-100 text-green-800' },
|
|
// { level: 4, name: 'Executive Level', description: 'Department heads and directors', color: 'bg-orange-100 text-orange-800' },
|
|
// { level: 5, name: 'C-Suite Level', description: 'Executive leadership', color: 'bg-purple-100 text-purple-800' }
|
|
// ];
|
|
|
|
// SLA Templates - keeping for future use
|
|
// const SLA_TEMPLATES = [
|
|
// { id: 'urgent', name: 'Urgent', hours: 4, description: 'Critical business impact', color: 'bg-red-100 text-red-800' },
|
|
// { id: 'high', name: 'High Priority', hours: 24, description: 'High business impact', color: 'bg-orange-100 text-orange-800' },
|
|
// { id: 'medium', name: 'Medium Priority', hours: 72, description: 'Moderate business impact', color: 'bg-yellow-100 text-yellow-800' },
|
|
// { id: 'low', name: 'Low Priority', hours: 120, description: 'Low business impact', color: 'bg-green-100 text-green-800' },
|
|
// { id: 'custom', name: 'Custom SLA', hours: 0, description: 'Define your own timeline', color: 'bg-blue-100 text-blue-800' }
|
|
// ];
|
|
|
|
const STEP_NAMES = [
|
|
'Template Selection',
|
|
'Basic Information',
|
|
'Approval Workflow',
|
|
'Participants & Access',
|
|
'Documents & Attachments',
|
|
'Review & Submit'
|
|
];
|
|
|
|
export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEditMode = false }: CreateRequestProps) {
|
|
const params = useParams<{ requestId: string }>();
|
|
const editRequestId = params.requestId || propRequestId || '';
|
|
const isEditing = isEditMode && !!editRequestId;
|
|
const { user } = useAuth();
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
|
|
const [emailInput, setEmailInput] = useState('');
|
|
const [showTemplateModal, setShowTemplateModal] = useState(false);
|
|
const [newUserData, setNewUserData] = useState({
|
|
name: '',
|
|
email: '',
|
|
role: '',
|
|
department: '',
|
|
level: 1
|
|
});
|
|
|
|
// Approver email search state
|
|
const [userSearchResults, setUserSearchResults] = useState<Record<number, UserSummary[]>>({});
|
|
const [userSearchLoading, setUserSearchLoading] = useState<Record<number, boolean>>({});
|
|
const searchTimers = useRef<Record<number, any>>({});
|
|
|
|
// Participants search state (spectators & CC)
|
|
const [spectatorSearchResults, setSpectatorSearchResults] = useState<UserSummary[]>([]);
|
|
const [spectatorSearchLoading, setSpectatorSearchLoading] = useState(false);
|
|
const spectatorTimer = useRef<any>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const [formData, setFormData] = useState({
|
|
// Template and basic info
|
|
template: '',
|
|
title: '',
|
|
description: '',
|
|
category: '',
|
|
|
|
// Details
|
|
priority: '',
|
|
urgency: '',
|
|
businessImpact: '',
|
|
amount: '',
|
|
currency: 'USD',
|
|
vendor: '',
|
|
timeline: '',
|
|
|
|
// SLA and dates
|
|
slaTemplate: '',
|
|
slaHours: 0,
|
|
customSlaHours: 0,
|
|
slaEndDate: undefined as Date | undefined,
|
|
expectedCompletionDate: undefined as Date | undefined,
|
|
breachEscalation: true,
|
|
reminderSchedule: '50' as '25' | '50' | '75',
|
|
|
|
// Workflow
|
|
workflowType: 'sequential' as 'sequential' | 'parallel',
|
|
requiresAllApprovals: true,
|
|
escalationEnabled: true,
|
|
reminderEnabled: true,
|
|
minimumLevel: 1,
|
|
maxLevel: 1,
|
|
|
|
// Participants
|
|
approvers: [] as any[],
|
|
approverCount: 1,
|
|
spectators: [] as any[],
|
|
ccList: [] as any[],
|
|
invitedUsers: [] as any[],
|
|
|
|
// Access settings
|
|
allowComments: true,
|
|
allowDocumentUpload: true,
|
|
|
|
// Documents
|
|
documents: [] as File[],
|
|
|
|
// Additional metadata
|
|
tags: [] as string[],
|
|
relatedRequests: [] as string[],
|
|
costCenter: '',
|
|
project: ''
|
|
});
|
|
|
|
const totalSteps = STEP_NAMES.length;
|
|
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [savingDraft, setSavingDraft] = useState(false);
|
|
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
|
|
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
|
|
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; file?: File; documentId?: string } | null>(null);
|
|
|
|
// Document policy state
|
|
const [documentPolicy, setDocumentPolicy] = useState<{
|
|
maxFileSizeMB: number;
|
|
allowedFileTypes: string[];
|
|
}>({
|
|
maxFileSizeMB: 10,
|
|
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
|
});
|
|
|
|
// Document validation error modal
|
|
const [documentErrorModal, setDocumentErrorModal] = useState<{
|
|
open: boolean;
|
|
errors: Array<{ fileName: string; reason: string }>;
|
|
}>({
|
|
open: false,
|
|
errors: []
|
|
});
|
|
|
|
// Validation modal states
|
|
const [validationModal, setValidationModal] = useState<{
|
|
open: boolean;
|
|
type: 'error' | 'self-assign' | 'not-found';
|
|
email: string;
|
|
message: string;
|
|
}>({
|
|
open: false,
|
|
type: 'error',
|
|
email: '',
|
|
message: ''
|
|
});
|
|
|
|
// Fetch document policy on mount
|
|
useEffect(() => {
|
|
const loadDocumentPolicy = async () => {
|
|
try {
|
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
|
const configMap: Record<string, string> = {};
|
|
configs.forEach((c: AdminConfiguration) => {
|
|
configMap[c.configKey] = c.configValue;
|
|
});
|
|
|
|
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
|
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
|
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
|
|
|
setDocumentPolicy({
|
|
maxFileSizeMB,
|
|
allowedFileTypes
|
|
});
|
|
} catch (error) {
|
|
console.error('Failed to load document policy:', error);
|
|
// Use defaults if loading fails
|
|
}
|
|
};
|
|
|
|
loadDocumentPolicy();
|
|
}, []);
|
|
|
|
// Fetch draft data when in edit mode
|
|
useEffect(() => {
|
|
if (!isEditing || !editRequestId) return;
|
|
|
|
let mounted = true;
|
|
(async () => {
|
|
try {
|
|
setLoadingDraft(true);
|
|
const details = await getWorkflowDetails(editRequestId);
|
|
if (!mounted || !details) return;
|
|
|
|
const wf = details.workflow || {};
|
|
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
|
const participants = Array.isArray(details.participants) ? details.participants : [];
|
|
const documents = Array.isArray(details.documents) ? details.documents.filter((d: any) => !d.isDeleted) : [];
|
|
|
|
// Store existing documents for tracking
|
|
setExistingDocuments(documents);
|
|
|
|
// Map priority
|
|
const priority = (wf.priority || '').toString().toLowerCase();
|
|
const priorityMap: Record<string, string> = {
|
|
'standard': 'standard',
|
|
'express': 'express'
|
|
};
|
|
|
|
// Map template type
|
|
const templateType = wf.templateType === 'TEMPLATE' ? 'existing-template' : 'custom';
|
|
const template = REQUEST_TEMPLATES.find(t => t.id === templateType) || REQUEST_TEMPLATES[0] || null;
|
|
setSelectedTemplate(template);
|
|
|
|
// Map approvers
|
|
const mappedApprovers = approvals
|
|
.sort((a: any, b: any) => (a.levelNumber || 0) - (b.levelNumber || 0))
|
|
.map((approval: any) => {
|
|
const tatHours = Number(approval.tatHours || 24);
|
|
const tatDays = Math.floor(tatHours / 24);
|
|
const tatHoursRemainder = tatHours % 24;
|
|
return {
|
|
id: approval.approverId || `temp-${approval.levelNumber}`,
|
|
name: approval.approverName || approval.approverEmail || '',
|
|
email: approval.approverEmail || '',
|
|
role: approval.levelName || `Level ${approval.levelNumber}`,
|
|
department: '',
|
|
avatar: (approval.approverName || approval.approverEmail || 'XX').substring(0, 2).toUpperCase(),
|
|
level: approval.levelNumber || 1,
|
|
canClose: false,
|
|
tat: tatDays > 0 ? tatDays : tatHoursRemainder,
|
|
tatType: tatDays > 0 ? 'days' as const : 'hours' as const,
|
|
userId: approval.approverId
|
|
};
|
|
});
|
|
|
|
// Map spectators - check both participantType and participant_type fields
|
|
// Debug: log participants to understand the structure
|
|
console.log('Loading draft - participants:', participants);
|
|
|
|
const mappedSpectators = participants
|
|
.filter((p: any) => {
|
|
// Check multiple possible field names for participantType
|
|
const pt = (p.participantType || p.participant_type || '').toString().toUpperCase().trim();
|
|
const isSpectator = pt === 'SPECTATOR';
|
|
if (!isSpectator) {
|
|
return false;
|
|
}
|
|
// Also ensure we have at least an email
|
|
const hasEmail = !!(p.userEmail || p.user_email || p.email);
|
|
if (!hasEmail) {
|
|
console.warn('Skipping spectator without email:', p);
|
|
}
|
|
return hasEmail;
|
|
})
|
|
.map((p: any, index: number) => {
|
|
// Use userId if available, otherwise generate a stable unique ID
|
|
const userId = p.userId || p.user_id || p.id;
|
|
const userName = p.userName || p.user_name || p.name || '';
|
|
const userEmail = p.userEmail || p.user_email || p.email || '';
|
|
|
|
// Generate avatar from name or email
|
|
const avatarText = userName || userEmail || 'XX';
|
|
const avatar = avatarText
|
|
.split(' ')
|
|
.map((s: string) => s[0])
|
|
.filter(Boolean)
|
|
.join('')
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
|
|
return {
|
|
id: userId || `spectator-${editRequestId}-${index}-${Date.now()}`,
|
|
userId: userId, // Keep userId separate for reference
|
|
name: userName || userEmail || 'Spectator',
|
|
email: userEmail,
|
|
role: 'Spectator',
|
|
department: p.department || '',
|
|
avatar: avatar,
|
|
level: 1,
|
|
canClose: false
|
|
};
|
|
});
|
|
|
|
// Debug: log mapped spectators
|
|
console.log('Mapped spectators:', mappedSpectators);
|
|
|
|
// Update form data
|
|
setFormData(prev => ({
|
|
...prev,
|
|
template: templateType,
|
|
title: wf.title || '',
|
|
description: wf.description || '',
|
|
priority: priorityMap[priority] || 'standard',
|
|
approvers: mappedApprovers,
|
|
approverCount: mappedApprovers.length || 1,
|
|
spectators: mappedSpectators,
|
|
maxLevel: Math.max(...mappedApprovers.map((a: any) => a.level || 1), 1)
|
|
}));
|
|
|
|
// Skip template selection step if editing
|
|
setCurrentStep(2);
|
|
} catch (error) {
|
|
console.error('Failed to load draft:', error);
|
|
} finally {
|
|
if (mounted) setLoadingDraft(false);
|
|
}
|
|
})();
|
|
|
|
return () => { mounted = false; };
|
|
}, [isEditing, editRequestId]);
|
|
|
|
const updateFormData = (field: string, value: any) => {
|
|
setFormData(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const validateEmail = (email: string) => {
|
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
};
|
|
|
|
const createUserFromEmail = (email: string, name: string = '', role: string = '', department: string = '', level: number = 1) => {
|
|
const avatar = name ? name.split(' ').map(n => (n?.[0] || '')).join('').toUpperCase() : email.split('@')[0]?.substring(0, 2).toUpperCase() || 'XX';
|
|
return {
|
|
id: `temp-${Date.now()}-${Math.random()}`,
|
|
name: name || email.split('@')[0],
|
|
email,
|
|
role: role || 'External User',
|
|
department: department || 'External',
|
|
avatar,
|
|
level,
|
|
canClose: level >= 4,
|
|
isInvited: true
|
|
};
|
|
};
|
|
|
|
const getPriorityIcon = (priority: string) => {
|
|
switch (priority) {
|
|
case 'high': return <Flame className="w-4 h-4 text-red-600" />;
|
|
case 'medium': return <Target className="w-4 h-4 text-orange-600" />;
|
|
case 'low': return <TrendingUp className="w-4 h-4 text-green-600" />;
|
|
default: return <Target className="w-4 h-4 text-gray-600" />;
|
|
}
|
|
};
|
|
|
|
const isStepValid = () => {
|
|
switch (currentStep) {
|
|
case 1: return selectedTemplate !== null;
|
|
case 2: return formData.title.trim() !== '' && formData.description.trim() !== '' && formData.priority !== '';
|
|
case 3: return (formData.approverCount || 1) > 0 &&
|
|
formData.approvers.length === (formData.approverCount || 1) &&
|
|
formData.approvers.every(approver => {
|
|
if (!approver || !approver.email) return false;
|
|
|
|
// Check if email is valid
|
|
if (!validateEmail(approver.email)) return false;
|
|
|
|
// Check if approver has a userId (selected via @ search)
|
|
// If no userId, it means they manually typed the email
|
|
if (!approver.userId) {
|
|
// Will be validated and ensured when moving to next step
|
|
return true; // Allow for now, will validate on next step
|
|
}
|
|
|
|
// Check TAT validation based on type
|
|
const tatType = approver.tatType || 'hours';
|
|
if (tatType === 'hours') {
|
|
return approver.tat && approver.tat > 0 && approver.tat <= 720;
|
|
} else if (tatType === 'days') {
|
|
return approver.tat && approver.tat > 0 && approver.tat <= 30;
|
|
}
|
|
return false;
|
|
});
|
|
case 4: return true; // Participants are optional except approvers
|
|
case 5: return true; // Documents are optional
|
|
case 6: return true; // Review & Submit
|
|
default: return false;
|
|
}
|
|
};
|
|
|
|
const nextStep = async () => {
|
|
if (!isStepValid()) return;
|
|
|
|
// Scroll to top on mobile to ensure buttons are visible
|
|
if (window.innerWidth < 640) {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
|
|
// Special validation when leaving step 3 (Approval Workflow)
|
|
if (currentStep === 3) {
|
|
// Validate and ensure all approvers with manually entered emails exist
|
|
const approversToValidate = formData.approvers.filter(a => a && a.email && !a.userId);
|
|
|
|
if (approversToValidate.length > 0) {
|
|
try {
|
|
// Show loading state (optional - can be added later)
|
|
const updatedApprovers = [...formData.approvers];
|
|
|
|
for (let i = 0; i < updatedApprovers.length; i++) {
|
|
const approver = updatedApprovers[i];
|
|
|
|
// Skip if already has userId (selected via @ search)
|
|
if (approver.userId) continue;
|
|
|
|
// Skip if no email
|
|
if (!approver.email) continue;
|
|
|
|
// Check if this email is the initiator's email
|
|
const initiatorEmail = (user as any)?.email?.toLowerCase();
|
|
if (approver.email.toLowerCase() === initiatorEmail) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'self-assign',
|
|
email: approver.email,
|
|
message: ''
|
|
});
|
|
return; // Stop navigation
|
|
}
|
|
|
|
// Search for the user by email in Okta directory
|
|
try {
|
|
const response = await searchUsers(approver.email, 1);
|
|
// Backend returns { success: true, data: [...users] }
|
|
const searchResults = response.data?.data || [];
|
|
|
|
if (searchResults.length === 0) {
|
|
// User NOT found in Okta directory
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'not-found',
|
|
email: approver.email,
|
|
message: ''
|
|
});
|
|
return; // Stop navigation - user must fix the email
|
|
} else {
|
|
// User found in Okta - ensure they exist in our DB and get userId
|
|
const foundUser = searchResults[0];
|
|
|
|
if (!foundUser) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: approver.email,
|
|
message: 'Could not retrieve user details. Please try again.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Ensure user exists in our database (creates if needed)
|
|
const dbUser = await ensureUserExists({
|
|
userId: foundUser.userId,
|
|
email: foundUser.email,
|
|
displayName: foundUser.displayName,
|
|
firstName: foundUser.firstName,
|
|
lastName: foundUser.lastName,
|
|
department: foundUser.department
|
|
});
|
|
|
|
// Update approver with DB userId and full details
|
|
updatedApprovers[i] = {
|
|
...approver,
|
|
userId: dbUser.userId,
|
|
name: dbUser.displayName || approver.name,
|
|
department: dbUser.department || approver.department,
|
|
avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase()
|
|
};
|
|
|
|
console.log(`✅ Validated approver ${i + 1}: ${dbUser.displayName} (${dbUser.email})`);
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to validate approver ${approver.email}:`, error);
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: approver.email,
|
|
message: `Failed to validate user. Please try again or select a different user.`
|
|
});
|
|
return; // Stop navigation
|
|
}
|
|
}
|
|
|
|
// Update form data with validated approvers
|
|
updateFormData('approvers', updatedApprovers);
|
|
} catch (error) {
|
|
console.error('Failed to validate approvers:', error);
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: '',
|
|
message: 'Failed to validate approvers. Please try again.'
|
|
});
|
|
return; // Stop navigation
|
|
}
|
|
}
|
|
}
|
|
|
|
// Proceed to next step
|
|
if (currentStep < totalSteps) {
|
|
setCurrentStep(currentStep + 1);
|
|
}
|
|
};
|
|
|
|
const prevStep = () => {
|
|
if (currentStep > 1) {
|
|
setCurrentStep(currentStep - 1);
|
|
// Scroll to top on mobile to ensure content is visible
|
|
if (window.innerWidth < 640) {
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}
|
|
}
|
|
};
|
|
|
|
const selectTemplate = (template: RequestTemplate) => {
|
|
setSelectedTemplate(template);
|
|
updateFormData('template', template.id);
|
|
updateFormData('category', template.category);
|
|
updateFormData('priority', template.priority);
|
|
|
|
// Auto-suggest SLA
|
|
const suggestedDate = new Date();
|
|
suggestedDate.setDate(suggestedDate.getDate() + template.suggestedSLA);
|
|
updateFormData('slaEndDate', suggestedDate);
|
|
|
|
// If user selected "Existing Template", show the template selection modal
|
|
if (template.id === 'existing-template') {
|
|
setShowTemplateModal(true);
|
|
}
|
|
};
|
|
|
|
const handleTemplateSelection = (templateId: string) => {
|
|
// This will be handled by routing to the specific template wizard in App.tsx
|
|
if (onSubmit) {
|
|
onSubmit({ templateType: templateId });
|
|
}
|
|
};
|
|
|
|
const addUser = (user: any, type: 'approvers' | 'spectators' | 'ccList' | 'invitedUsers') => {
|
|
const userEmail = (user.email || '').toLowerCase();
|
|
const currentList = formData[type];
|
|
|
|
// Check if user is already in the target list
|
|
if (currentList.find((u: any) => u.id === user.id || (u.email || '').toLowerCase() === userEmail)) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: user.email,
|
|
message: `${user.name || user.email} is already added as ${type.slice(0, -1)}.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Prevent adding same user in different roles
|
|
if (type === 'spectators') {
|
|
// Check if user is already an approver
|
|
const isApprover = formData.approvers.find((a: any) =>
|
|
a.id === user.id || (a.email || '').toLowerCase() === userEmail
|
|
);
|
|
if (isApprover) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: user.email,
|
|
message: `${user.name || user.email} is already an approver and cannot be added as a spectator.`
|
|
});
|
|
return;
|
|
}
|
|
} else if (type === 'approvers') {
|
|
// Check if user is already a spectator
|
|
const isSpectator = formData.spectators.find((s: any) =>
|
|
s.id === user.id || (s.email || '').toLowerCase() === userEmail
|
|
);
|
|
if (isSpectator) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: user.email,
|
|
message: `${user.name || user.email} is already a spectator and cannot be added as an approver.`
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Add user to the list
|
|
const updatedList = [...currentList, user];
|
|
updateFormData(type, updatedList);
|
|
|
|
// Update max level if adding approver
|
|
if (type === 'approvers') {
|
|
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level), 0);
|
|
updateFormData('maxLevel', maxApproverLevel);
|
|
}
|
|
};
|
|
|
|
const removeUser = (userId: string, type: 'approvers' | 'spectators' | 'ccList' | 'invitedUsers') => {
|
|
const currentList = formData[type];
|
|
const updatedList = currentList.filter((u: any) => u.id !== userId);
|
|
updateFormData(type, updatedList);
|
|
|
|
// Update max level if removing approver
|
|
if (type === 'approvers') {
|
|
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level), 0);
|
|
updateFormData('maxLevel', maxApproverLevel);
|
|
}
|
|
};
|
|
|
|
const addUserByEmail = () => {
|
|
if (!validateEmail(emailInput)) return;
|
|
|
|
// Check if user already exists
|
|
const existingUser = [...MOCK_USERS, ...formData.invitedUsers].find(u => u.email === emailInput);
|
|
if (existingUser) {
|
|
setEmailInput('');
|
|
return existingUser;
|
|
}
|
|
|
|
// Create new user
|
|
const newUser = createUserFromEmail(
|
|
emailInput,
|
|
newUserData.name,
|
|
newUserData.role,
|
|
newUserData.department,
|
|
newUserData.level
|
|
);
|
|
|
|
addUser(newUser, 'invitedUsers');
|
|
setEmailInput('');
|
|
setNewUserData({ name: '', email: '', role: '', department: '', level: 1 });
|
|
|
|
return newUser;
|
|
};
|
|
|
|
const inviteAndAddUser = async (type: 'approvers' | 'spectators' | 'ccList') => {
|
|
// For spectators, validate against Okta before adding
|
|
if (type === 'spectators' && emailInput) {
|
|
// Check if this email is the initiator's email
|
|
const initiatorEmail = (user as any)?.email?.toLowerCase();
|
|
if (emailInput.toLowerCase() === initiatorEmail) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'self-assign',
|
|
email: emailInput,
|
|
message: 'You cannot add yourself as a spectator.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Search for user in Okta directory
|
|
try {
|
|
const response = await searchUsers(emailInput, 1);
|
|
// Backend returns { success: true, data: [...users] }
|
|
const searchResults = response.data?.data || [];
|
|
|
|
if (searchResults.length === 0) {
|
|
// User NOT found in Okta directory
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'not-found',
|
|
email: emailInput,
|
|
message: ''
|
|
});
|
|
return;
|
|
}
|
|
|
|
// User found in Okta - ensure they exist in DB
|
|
const foundUser = searchResults[0];
|
|
|
|
if (!foundUser) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: emailInput,
|
|
message: 'Could not retrieve user details. Please try again.'
|
|
});
|
|
return;
|
|
}
|
|
|
|
const dbUser = await ensureUserExists({
|
|
userId: foundUser.userId,
|
|
email: foundUser.email,
|
|
displayName: foundUser.displayName,
|
|
firstName: foundUser.firstName,
|
|
lastName: foundUser.lastName,
|
|
department: foundUser.department
|
|
});
|
|
|
|
// Create spectator object with verified data
|
|
const spectator = {
|
|
id: dbUser.userId,
|
|
userId: dbUser.userId,
|
|
name: dbUser.displayName || dbUser.email.split('@')[0],
|
|
email: dbUser.email,
|
|
avatar: (dbUser.displayName || dbUser.email).substring(0, 2).toUpperCase(),
|
|
role: 'Spectator',
|
|
department: dbUser.department || '',
|
|
level: 1,
|
|
canClose: false
|
|
};
|
|
|
|
// Add spectator
|
|
addUser(spectator, 'spectators');
|
|
setEmailInput('');
|
|
return;
|
|
} catch (error) {
|
|
console.error('Failed to validate spectator:', error);
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: emailInput,
|
|
message: 'Failed to validate user. Please try again.'
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// For other types (deprecated flow)
|
|
const userObj = addUserByEmail();
|
|
if (userObj) {
|
|
addUser(userObj, type);
|
|
}
|
|
};
|
|
|
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
|
// Check file size
|
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
|
if (file.size > maxSizeBytes) {
|
|
return {
|
|
valid: false,
|
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
|
};
|
|
}
|
|
|
|
// Check file extension
|
|
const fileName = file.name.toLowerCase();
|
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
|
|
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
|
return {
|
|
valid: false,
|
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
};
|
|
|
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(event.target.files || []);
|
|
|
|
if (files.length === 0) return;
|
|
|
|
// Validate all files
|
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
|
const validFiles: File[] = [];
|
|
|
|
files.forEach(file => {
|
|
const validation = validateFile(file);
|
|
if (!validation.valid) {
|
|
validationErrors.push({
|
|
fileName: file.name,
|
|
reason: validation.reason || 'Unknown validation error'
|
|
});
|
|
} else {
|
|
validFiles.push(file);
|
|
}
|
|
});
|
|
|
|
// If there are validation errors, show modal
|
|
if (validationErrors.length > 0) {
|
|
setDocumentErrorModal({
|
|
open: true,
|
|
errors: validationErrors
|
|
});
|
|
}
|
|
|
|
// Add only valid files
|
|
if (validFiles.length > 0) {
|
|
updateFormData('documents', [...formData.documents, ...validFiles]);
|
|
if (validFiles.length < files.length) {
|
|
toast.warning(`${validFiles.length} of ${files.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
|
|
} else {
|
|
toast.success(`${validFiles.length} file(s) added successfully`);
|
|
}
|
|
}
|
|
|
|
// Reset file input
|
|
if (event.target) {
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!isStepValid() || submitting || savingDraft) return;
|
|
|
|
setSubmitting(true);
|
|
|
|
// Participants mapping
|
|
const initiatorId = user?.userId || '';
|
|
const initiatorEmail = (user as any)?.email || '';
|
|
const initiatorName = (user as any)?.displayName || (user as any)?.name || initiatorEmail.split('@')[0] || 'Initiator';
|
|
|
|
const participants = [
|
|
{
|
|
userId: initiatorId,
|
|
userEmail: initiatorEmail,
|
|
userName: initiatorName,
|
|
participantType: 'INITIATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
},
|
|
// Approvers -> map to participants (APPROVER)
|
|
...((formData.approvers || []).filter((a: any) => a?.email).map((a: any) => ({
|
|
userId: a.userId || undefined,
|
|
userEmail: a.email,
|
|
userName: a.name || a.email.split('@')[0],
|
|
participantType: 'APPROVER' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
})) as any[]),
|
|
// Spectators -> map to participants (SPECTATOR)
|
|
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
|
|
userId: s.id || undefined,
|
|
userEmail: s.email,
|
|
userName: s.name || s.email.split('@')[0],
|
|
participantType: 'SPECTATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
})) as any[]),
|
|
];
|
|
|
|
// Ensure approver userIds are present (selected via @ lookup)
|
|
const hasMissingApproverIds = (formData.approvers || []).slice(0, formData.approverCount || 1)
|
|
.some((a: any) => !a?.userId || !a?.email);
|
|
if (hasMissingApproverIds) {
|
|
alert('Please select approvers using @ search so we can capture their user IDs.');
|
|
return;
|
|
}
|
|
|
|
const payload = {
|
|
templateId: selectedTemplate?.id || null,
|
|
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' as const : 'TEMPLATE' as const,
|
|
title: formData.title,
|
|
description: formData.description,
|
|
priorityUi: (formData.priority === 'express' ? 'express' : 'standard') as 'express' | 'standard',
|
|
approverCount: formData.approverCount || 1,
|
|
approvers: (formData.approvers || []).map((a: any) => ({
|
|
userId: a?.userId || '',
|
|
email: a?.email || '',
|
|
name: a?.name,
|
|
tat: a?.tat || '',
|
|
tatType: a?.tatType || 'hours',
|
|
})),
|
|
spectators: (formData.spectators || [])
|
|
.filter((s: any) => {
|
|
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
|
|
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
|
|
return !approverIds.includes(s?.id) && !approverEmails.includes((s?.email || '').toLowerCase());
|
|
})
|
|
.map((s: any) => ({
|
|
userId: s?.id || '',
|
|
name: s?.name || s?.email?.split('@')?.[0] || 'Spectator',
|
|
email: s?.email || '',
|
|
})),
|
|
ccList: (formData.ccList || []).map((c: any) => ({
|
|
id: c?.id,
|
|
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
|
|
email: c?.email || '',
|
|
})),
|
|
// Backend service supports participants field (optional)
|
|
participants,
|
|
};
|
|
|
|
// Handle edit mode - update existing draft with full structure
|
|
if (isEditing && editRequestId) {
|
|
// Build approval levels
|
|
const approvalLevels = (formData.approvers || []).slice(0, formData.approverCount || 1).map((a: any, index: number) => {
|
|
const tat = typeof a.tat === 'number' ? a.tat : 0;
|
|
const tatHours = a.tatType === 'days' ? tat * 24 : tat || 24;
|
|
return {
|
|
levelNumber: index + 1,
|
|
levelName: `Level ${index + 1}`,
|
|
approverId: a.userId || '',
|
|
approverEmail: a.email || '',
|
|
approverName: a.name || a.email?.split('@')[0] || `Approver ${index + 1}`,
|
|
tatHours: tatHours,
|
|
isFinalApprover: index + 1 === (formData.approverCount || 1)
|
|
};
|
|
});
|
|
|
|
// Build participants
|
|
const participants = [
|
|
{
|
|
userId: user?.userId || '',
|
|
userEmail: (user as any)?.email || '',
|
|
userName: (user as any)?.displayName || (user as any)?.name || (user as any)?.email?.split('@')[0] || 'Initiator',
|
|
participantType: 'INITIATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
},
|
|
...((formData.approvers || []).filter((a: any) => a?.email && a?.userId).map((a: any) => ({
|
|
userId: a.userId,
|
|
userEmail: a.email,
|
|
userName: a.name || a.email.split('@')[0],
|
|
participantType: 'APPROVER' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
})) as any[]),
|
|
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
|
|
userId: s.id || s.userId || undefined,
|
|
userEmail: s.email,
|
|
userName: s.name || s.email.split('@')[0],
|
|
participantType: 'SPECTATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
})) as any[]),
|
|
];
|
|
|
|
// Build update payload
|
|
const updatePayload = {
|
|
title: formData.title,
|
|
description: formData.description,
|
|
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
|
|
approvalLevels: approvalLevels,
|
|
participants: participants,
|
|
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
|
};
|
|
|
|
// Determine if we need multipart (new files or deletions)
|
|
const hasNewFiles = formData.documents && formData.documents.length > 0;
|
|
const hasDeletions = documentsToDelete.length > 0;
|
|
|
|
if (hasNewFiles || hasDeletions) {
|
|
// Use multipart update
|
|
try {
|
|
await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
|
|
// Submit the updated workflow
|
|
try {
|
|
await submitWorkflow(editRequestId);
|
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
|
} catch (err) {
|
|
console.error('Failed to submit workflow:', err);
|
|
setSubmitting(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to update workflow:', err);
|
|
setSubmitting(false);
|
|
}
|
|
} else {
|
|
// Use regular update
|
|
try {
|
|
await updateWorkflow(editRequestId, updatePayload);
|
|
// Submit the updated workflow
|
|
try {
|
|
await submitWorkflow(editRequestId);
|
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
|
} catch (err) {
|
|
console.error('Failed to submit workflow:', err);
|
|
setSubmitting(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to update workflow:', err);
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Create new workflow
|
|
try {
|
|
const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
|
|
const id = (res as any).id;
|
|
try {
|
|
await submitWorkflow(id);
|
|
onSubmit?.({ ...formData, backendId: id, template: selectedTemplate });
|
|
} catch (err) {
|
|
console.error('Failed to submit workflow:', err);
|
|
setSubmitting(false);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to create workflow:', err);
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleSaveDraft = async () => {
|
|
// Same payload as submit, but do NOT call submit endpoint
|
|
if (!selectedTemplate || !formData.title.trim() || !formData.description.trim() || !formData.priority) {
|
|
// allow minimal validation for draft: require title/description/priority/template
|
|
return;
|
|
}
|
|
|
|
if (submitting || savingDraft) return;
|
|
|
|
setSavingDraft(true);
|
|
|
|
// Handle edit mode - update existing draft with full structure
|
|
if (isEditing && editRequestId) {
|
|
// Build approval levels
|
|
const approvalLevels = (formData.approvers || []).slice(0, formData.approverCount || 1).map((a: any, index: number) => {
|
|
const tat = typeof a.tat === 'number' ? a.tat : 0;
|
|
const tatHours = a.tatType === 'days' ? tat * 24 : tat || 24;
|
|
return {
|
|
levelNumber: index + 1,
|
|
levelName: `Level ${index + 1}`,
|
|
approverId: a.userId || '',
|
|
approverEmail: a.email || '',
|
|
approverName: a.name || a.email?.split('@')[0] || `Approver ${index + 1}`,
|
|
tatHours: tatHours,
|
|
isFinalApprover: index + 1 === (formData.approverCount || 1)
|
|
};
|
|
});
|
|
|
|
// Build participants
|
|
const participants = [
|
|
{
|
|
userId: user?.userId || '',
|
|
userEmail: (user as any)?.email || '',
|
|
userName: (user as any)?.displayName || (user as any)?.name || (user as any)?.email?.split('@')[0] || 'Initiator',
|
|
participantType: 'INITIATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
},
|
|
...((formData.approvers || []).filter((a: any) => a?.email && a?.userId).map((a: any) => ({
|
|
userId: a.userId,
|
|
userEmail: a.email,
|
|
userName: a.name || a.email.split('@')[0],
|
|
participantType: 'APPROVER' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
})) as any[]),
|
|
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
|
|
userId: s.id || s.userId || undefined,
|
|
userEmail: s.email,
|
|
userName: s.name || s.email.split('@')[0],
|
|
participantType: 'SPECTATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
})) as any[]),
|
|
];
|
|
|
|
// Build update payload
|
|
const updatePayload = {
|
|
title: formData.title,
|
|
description: formData.description,
|
|
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
|
|
approvalLevels: approvalLevels,
|
|
participants: participants,
|
|
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
|
};
|
|
|
|
// Determine if we need multipart (new files or deletions)
|
|
const hasNewFiles = formData.documents && formData.documents.length > 0;
|
|
const hasDeletions = documentsToDelete.length > 0;
|
|
|
|
if (hasNewFiles || hasDeletions) {
|
|
// Use multipart update
|
|
try {
|
|
await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
|
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
|
} catch (err) {
|
|
console.error('Failed to update draft:', err);
|
|
setSavingDraft(false);
|
|
}
|
|
} else {
|
|
// Use regular update
|
|
try {
|
|
await updateWorkflow(editRequestId, updatePayload);
|
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
|
} catch (err) {
|
|
console.error('Failed to update draft:', err);
|
|
setSavingDraft(false);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Build participants array for draft (same as handleSubmit)
|
|
const initiatorId = user?.userId || '';
|
|
const initiatorEmail = (user as any)?.email || '';
|
|
const initiatorName = (user as any)?.displayName || (user as any)?.name || initiatorEmail.split('@')[0] || 'Initiator';
|
|
|
|
const participants = [
|
|
{
|
|
userId: initiatorId,
|
|
userEmail: initiatorEmail,
|
|
userName: initiatorName,
|
|
participantType: 'INITIATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
},
|
|
// Approvers -> map to participants (APPROVER)
|
|
...((formData.approvers || []).filter((a: any) => a?.email).map((a: any) => ({
|
|
userId: a.userId || undefined,
|
|
userEmail: a.email,
|
|
userName: a.name || a.email.split('@')[0],
|
|
participantType: 'APPROVER' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
})) as any[]),
|
|
// Spectators -> map to participants (SPECTATOR)
|
|
...((formData.spectators || []).filter((s: any) => s?.email).map((s: any) => ({
|
|
userId: s.id || undefined,
|
|
userEmail: s.email,
|
|
userName: s.name || s.email.split('@')[0],
|
|
participantType: 'SPECTATOR' as const,
|
|
canComment: true,
|
|
canViewDocuments: true,
|
|
canDownloadDocuments: true,
|
|
notificationEnabled: true,
|
|
addedBy: initiatorId,
|
|
})) as any[]),
|
|
];
|
|
|
|
// Create new draft
|
|
const payload = {
|
|
templateId: selectedTemplate?.id || null,
|
|
templateType: selectedTemplate?.id === 'custom' ? 'CUSTOM' as const : 'TEMPLATE' as const,
|
|
title: formData.title,
|
|
description: formData.description,
|
|
priorityUi: (formData.priority === 'express' ? 'express' : 'standard') as 'express' | 'standard',
|
|
approverCount: formData.approverCount || 1,
|
|
approvers: (formData.approvers || []).map((a: any) => ({
|
|
userId: a?.userId || '',
|
|
email: a?.email || '',
|
|
name: a?.name,
|
|
tat: a?.tat || '',
|
|
tatType: a?.tatType || 'hours',
|
|
})),
|
|
spectators: (formData.spectators || [])
|
|
.filter((s: any) => {
|
|
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
|
|
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
|
|
return !approverIds.includes(s?.id) && !approverEmails.includes((s?.email || '').toLowerCase());
|
|
})
|
|
.map((s: any) => ({
|
|
userId: s?.id || '',
|
|
name: s?.name || s?.email?.split('@')?.[0] || 'Spectator',
|
|
email: s?.email || '',
|
|
})),
|
|
ccList: (formData.ccList || []).map((c: any) => ({
|
|
id: c?.id,
|
|
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
|
|
email: c?.email || '',
|
|
})),
|
|
participants: participants, // Include participants array for draft
|
|
};
|
|
try {
|
|
const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
|
|
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
|
|
} catch (err) {
|
|
console.error('Failed to save draft:', err);
|
|
setSavingDraft(false);
|
|
}
|
|
};
|
|
|
|
// Show loading state while fetching draft data
|
|
if (loadingDraft) {
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Loading draft...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const renderStepContent = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="min-h-full flex flex-col items-center justify-center py-8"
|
|
>
|
|
{/* Header Section */}
|
|
<div className="text-center mb-12 max-w-3xl">
|
|
<h1 className="text-4xl lg:text-5xl font-bold text-gray-900 mb-4">
|
|
Choose Your Request Type
|
|
</h1>
|
|
<p className="text-lg text-gray-600">
|
|
Start with a pre-built template for faster approvals, or create a custom request tailored to your needs.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Template Cards Grid */}
|
|
<div className="w-full max-w-6xl grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
|
{REQUEST_TEMPLATES.map((template) => (
|
|
<motion.div
|
|
key={template.id}
|
|
whileHover={{ scale: 1.03 }}
|
|
whileTap={{ scale: 0.98 }}
|
|
transition={{ type: "spring", stiffness: 300, damping: 20 }}
|
|
>
|
|
<Card
|
|
className={`cursor-pointer h-full transition-all duration-300 border-2 ${
|
|
selectedTemplate?.id === template.id
|
|
? 'border-blue-500 shadow-xl bg-blue-50/50 ring-2 ring-blue-200'
|
|
: 'border-gray-200 hover:border-blue-300 hover:shadow-lg'
|
|
}`}
|
|
onClick={() => selectTemplate(template)}
|
|
>
|
|
<CardHeader className="space-y-4 pb-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className={`w-14 h-14 rounded-xl flex items-center justify-center ${
|
|
selectedTemplate?.id === template.id
|
|
? 'bg-blue-100'
|
|
: 'bg-gray-100'
|
|
}`}>
|
|
<template.icon className={`w-7 h-7 ${
|
|
selectedTemplate?.id === template.id
|
|
? 'text-blue-600'
|
|
: 'text-gray-600'
|
|
}`} />
|
|
</div>
|
|
{selectedTemplate?.id === template.id && (
|
|
<motion.div
|
|
initial={{ scale: 0 }}
|
|
animate={{ scale: 1 }}
|
|
transition={{ type: "spring", stiffness: 500, damping: 15 }}
|
|
>
|
|
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center">
|
|
<Check className="w-5 h-5 text-white" />
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
<div className="text-left">
|
|
<CardTitle className="text-xl mb-2">{template.name}</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{template.category}
|
|
</Badge>
|
|
{getPriorityIcon(template.priority)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0 space-y-4">
|
|
<p className="text-sm text-gray-600 leading-relaxed line-clamp-2">
|
|
{template.description}
|
|
</p>
|
|
<Separator />
|
|
<div className="grid grid-cols-2 gap-3 text-xs text-gray-500">
|
|
<div className="flex items-center gap-1.5">
|
|
<Clock className="w-3.5 h-3.5" />
|
|
<span>{template.estimatedTime}</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
<Users className="w-3.5 h-3.5" />
|
|
<span>{template.commonApprovers.length} approvers</span>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Template Details Card - Only show when template is selected */}
|
|
<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"
|
|
>
|
|
<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">
|
|
<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">
|
|
<Label className="text-blue-900 font-semibold">Suggested SLA</Label>
|
|
<p className="text-blue-700 mt-1">{selectedTemplate.suggestedSLA} days</p>
|
|
</div>
|
|
<div className="bg-white/60 p-3 rounded-lg">
|
|
<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">
|
|
<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">
|
|
<Label className="text-blue-900 font-semibold">Common Approvers</Label>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{selectedTemplate.commonApprovers.map((approver, index) => (
|
|
<Badge key={`${selectedTemplate.id}-approver-${index}-${approver}`} variant="outline" className="border-blue-300 text-blue-700 bg-white">
|
|
{approver}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
|
|
case 2:
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<div className="text-center mb-8">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-blue-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<FileText className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Basic Information</h2>
|
|
<p className="text-gray-600">
|
|
Provide the essential details for your {selectedTemplate?.name || 'request'}.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
<div>
|
|
<Label htmlFor="title" className="text-base font-semibold">Request Title *</Label>
|
|
<p className="text-sm text-gray-600 mb-3">
|
|
Be specific and descriptive. This will be visible to all participants.
|
|
</p>
|
|
<Input
|
|
id="title"
|
|
placeholder="e.g., Approval on new office location"
|
|
value={formData.title}
|
|
onChange={(e) => updateFormData('title', e.target.value)}
|
|
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="description" className="text-base font-semibold">Detailed Description *</Label>
|
|
<p className="text-sm text-gray-600 mb-3">
|
|
Explain what you need approval for, why it's needed, and any relevant background information.
|
|
</p>
|
|
<Textarea
|
|
id="description"
|
|
placeholder="Provide comprehensive details about your request including scope, objectives, expected outcomes, and any supporting context that will help approvers make an informed decision."
|
|
className="min-h-[120px] text-base border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm resize-none"
|
|
value={formData.description}
|
|
onChange={(e) => updateFormData('description', e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<Label className="text-base font-semibold">Priority Level *</Label>
|
|
<p className="text-sm text-gray-600 mb-2">
|
|
select priority for your request
|
|
</p>
|
|
<RadioGroup
|
|
value={formData.priority || ''}
|
|
onValueChange={(value) => {
|
|
console.log('Priority changed to:', value);
|
|
updateFormData('priority', value);
|
|
}}
|
|
>
|
|
<div
|
|
className={`flex items-center space-x-3 p-3 rounded-lg border-2 cursor-pointer transition-all ${
|
|
formData.priority === 'express'
|
|
? 'border-red-500 bg-red-100'
|
|
: 'border-red-200 bg-red-50 hover:bg-red-100'
|
|
}`}
|
|
onClick={() => updateFormData('priority', 'express')}
|
|
>
|
|
<RadioGroupItem value="express" id="express" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Zap className="w-4 h-4 text-red-600" />
|
|
<Label htmlFor="express" className="font-medium text-red-900 cursor-pointer">Express</Label>
|
|
<Badge variant="destructive" className="text-xs">URGENT</Badge>
|
|
</div>
|
|
<p className="text-xs text-red-700">
|
|
Includes calendar days in TAT - faster processing timeline
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`flex items-center space-x-3 p-3 rounded-lg border cursor-pointer transition-all ${
|
|
formData.priority === 'standard'
|
|
? 'border-blue-500 bg-blue-50'
|
|
: 'border-gray-200 hover:bg-gray-50'
|
|
}`}
|
|
onClick={() => updateFormData('priority', 'standard')}
|
|
>
|
|
<RadioGroupItem value="standard" id="standard" />
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Clock className="w-4 h-4 text-blue-600" />
|
|
<Label htmlFor="standard" className="font-medium text-blue-900 cursor-pointer">Standard</Label>
|
|
<Badge variant="secondary" className="text-xs">DEFAULT</Badge>
|
|
</div>
|
|
<p className="text-xs text-gray-600">
|
|
Includes working days in TAT - regular processing timeline
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
{/* SLA Configuration */}
|
|
<div className="space-y-4">
|
|
|
|
|
|
|
|
</div>
|
|
|
|
{/* Template-specific fields */}
|
|
{(selectedTemplate?.fields.amount || selectedTemplate?.fields.vendor || selectedTemplate?.fields.timeline || selectedTemplate?.fields.impact) && (
|
|
<div className="border-t pt-6">
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Additional Details</h3>
|
|
<div className="space-y-6">
|
|
{selectedTemplate?.fields.amount && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div className="md:col-span-2">
|
|
<Label htmlFor="amount" className="text-base font-semibold">Budget Amount</Label>
|
|
<Input
|
|
id="amount"
|
|
placeholder="Enter amount"
|
|
value={formData.amount}
|
|
onChange={(e) => updateFormData('amount', e.target.value)}
|
|
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-base font-semibold">Currency</Label>
|
|
<Select value={formData.currency} onValueChange={(value) => updateFormData('currency', value)}>
|
|
<SelectTrigger className="h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="USD">USD ($)</SelectItem>
|
|
<SelectItem value="EUR">EUR (€)</SelectItem>
|
|
<SelectItem value="GBP">GBP (£)</SelectItem>
|
|
<SelectItem value="INR">INR (₹)</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{selectedTemplate?.fields.vendor && (
|
|
<div>
|
|
<Label htmlFor="vendor" className="text-base font-semibold">Vendor/Supplier</Label>
|
|
<Input
|
|
id="vendor"
|
|
placeholder="Enter vendor or supplier name"
|
|
value={formData.vendor}
|
|
onChange={(e) => updateFormData('vendor', e.target.value)}
|
|
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<Label htmlFor="costCenter" className="text-base font-semibold">Cost Center</Label>
|
|
<Input
|
|
id="costCenter"
|
|
placeholder="e.g., Marketing, IT, Operations"
|
|
value={formData.costCenter}
|
|
onChange={(e) => updateFormData('costCenter', e.target.value)}
|
|
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label htmlFor="project" className="text-base font-semibold">Related Project</Label>
|
|
<Input
|
|
id="project"
|
|
placeholder="Associated project name or code"
|
|
value={formData.project}
|
|
onChange={(e) => updateFormData('project', e.target.value)}
|
|
className="text-base h-12 border-2 border-gray-300 focus:border-blue-500 bg-white shadow-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
|
|
case 3:
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<div className="text-center mb-8">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-orange-500 to-red-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<Users className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Approval Workflow</h2>
|
|
<p className="text-gray-600">
|
|
Define the approval hierarchy and assign approvers by email ID.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-4xl mx-auto space-y-8">
|
|
{/* Number of Approvers */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Settings className="w-5 h-5" />
|
|
Approval Configuration
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Configure how many approvers you need and define the approval sequence.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div>
|
|
<Label className="text-base font-semibold mb-4 block">Number of Approvers *</Label>
|
|
<div className="flex items-center gap-4">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const currentCount = formData.approverCount || 1;
|
|
const newCount = Math.max(1, currentCount - 1);
|
|
updateFormData('approverCount', newCount);
|
|
// Trim approvers array if reducing count
|
|
if (formData.approvers.length > newCount) {
|
|
updateFormData('approvers', formData.approvers.slice(0, newCount));
|
|
}
|
|
}}
|
|
disabled={(formData.approverCount || 1) <= 1}
|
|
>
|
|
<Minus className="w-4 h-4" />
|
|
</Button>
|
|
<span className="text-2xl font-semibold w-12 text-center">{formData.approverCount || 1}</span>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const currentCount = formData.approverCount || 1;
|
|
const newCount = Math.min(10, currentCount + 1);
|
|
updateFormData('approverCount', newCount);
|
|
}}
|
|
disabled={(formData.approverCount || 1) >= 10}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<p className="text-sm text-gray-600 mt-2">
|
|
Maximum 10 approvers allowed. Each approver will review sequentially.
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Approval Hierarchy */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Shield className="w-5 h-5" />
|
|
Approval Hierarchy *
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Define the approval sequence. Each approver will review the request in order from Level 1 to Level {formData.approverCount || 1}.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Initiator Card */}
|
|
<div className="p-4 rounded-lg border-2 border-blue-200 bg-blue-50">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center">
|
|
<User className="w-5 h-5 text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-semibold text-blue-900">Request Initiator</span>
|
|
<Badge variant="secondary" className="text-xs">YOU</Badge>
|
|
</div>
|
|
<p className="text-sm text-blue-700">Creates and submits the request</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dynamic Approver Cards */}
|
|
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
|
|
const level = index + 1;
|
|
const isLast = level === (formData.approverCount || 1);
|
|
|
|
// Ensure approvers array has enough items
|
|
if (!formData.approvers[index]) {
|
|
const newApprovers = [...formData.approvers];
|
|
newApprovers[index] = { email: '', name: '', level: level, tat: '' as any };
|
|
updateFormData('approvers', newApprovers);
|
|
}
|
|
|
|
return (
|
|
<div key={level} className="space-y-3">
|
|
{/* Connection Line */}
|
|
<div className="flex justify-center">
|
|
<div className="w-px h-6 bg-gray-300"></div>
|
|
</div>
|
|
|
|
{/* Approver Card */}
|
|
<div className={`p-4 rounded-lg border-2 transition-all ${
|
|
formData.approvers[index]?.email
|
|
? 'border-green-200 bg-green-50'
|
|
: 'border-gray-200 bg-gray-50'
|
|
}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
|
formData.approvers[index]?.email
|
|
? 'bg-green-600'
|
|
: 'bg-gray-400'
|
|
}`}>
|
|
<span className="text-white font-semibold">{level}</span>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<span className="font-semibold text-gray-900">
|
|
Approver Level {level}
|
|
</span>
|
|
{isLast && (
|
|
<Badge variant="destructive" className="text-xs">FINAL APPROVER</Badge>
|
|
)}
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
|
Email Address *
|
|
</Label>
|
|
{formData.approvers[index]?.email && formData.approvers[index]?.userId && (
|
|
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
|
|
<CheckCircle className="w-3 h-3 mr-1" />
|
|
Verified
|
|
</Badge>
|
|
)}
|
|
{formData.approvers[index]?.email && !formData.approvers[index]?.userId && (
|
|
<Badge variant="outline" className="text-xs bg-amber-50 text-amber-700 border-amber-300">
|
|
<AlertCircle className="w-3 h-3 mr-1" />
|
|
Needs Validation
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
id={`approver-${level}`}
|
|
type="email"
|
|
placeholder="approver@royalenfield.com"
|
|
value={formData.approvers[index]?.email || ''}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
const newApprovers = [...formData.approvers];
|
|
|
|
// If email changed, clear userId to force revalidation
|
|
const previousEmail = newApprovers[index]?.email;
|
|
const emailChanged = previousEmail !== value;
|
|
|
|
newApprovers[index] = {
|
|
...newApprovers[index],
|
|
email: value,
|
|
level: level,
|
|
// Clear userId if email was changed (requires revalidation)
|
|
userId: emailChanged ? undefined : newApprovers[index]?.userId,
|
|
name: emailChanged ? undefined : newApprovers[index]?.name,
|
|
department: emailChanged ? undefined : newApprovers[index]?.department,
|
|
avatar: emailChanged ? undefined : newApprovers[index]?.avatar
|
|
};
|
|
updateFormData('approvers', newApprovers);
|
|
|
|
// Debounced search for users by email/name
|
|
if (searchTimers.current[index]) {
|
|
clearTimeout(searchTimers.current[index]);
|
|
}
|
|
// Only trigger search when tagging with '@' as first character
|
|
if (!value || !value.startsWith('@') || value.length < 2) {
|
|
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
|
|
setUserSearchLoading(prev => ({ ...prev, [index]: false }));
|
|
return;
|
|
}
|
|
setUserSearchLoading(prev => ({ ...prev, [index]: true }));
|
|
searchTimers.current[index] = setTimeout(async () => {
|
|
try {
|
|
const term = value.slice(1); // remove leading '@'
|
|
const response = await searchUsers(term, 10);
|
|
// Backend returns { success: true, data: [...users] }
|
|
const results = response.data?.data || [];
|
|
setUserSearchResults(prev => ({ ...prev, [index]: results }));
|
|
} catch (err) {
|
|
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
|
|
} finally {
|
|
setUserSearchLoading(prev => ({ ...prev, [index]: false }));
|
|
}
|
|
}, 300);
|
|
}}
|
|
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
|
|
/>
|
|
{/* Search suggestions (absolute dropdown) */}
|
|
{(userSearchLoading[index] || (userSearchResults[index]?.length || 0) > 0) && (
|
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
|
{userSearchLoading[index] ? (
|
|
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
|
) : (
|
|
<ul className="max-h-56 overflow-auto divide-y">
|
|
{userSearchResults[index]?.map((u) => (
|
|
<li
|
|
key={u.userId}
|
|
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
|
onClick={async () => {
|
|
// Check if user is already a spectator
|
|
const spectatorIds = (formData.spectators || []).map((s: any) => s?.id).filter(Boolean);
|
|
const spectatorEmails = (formData.spectators || []).map((s: any) => s?.email?.toLowerCase?.()).filter(Boolean);
|
|
if (spectatorIds.includes(u.userId) || spectatorEmails.includes((u.email || '').toLowerCase())) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: u.email,
|
|
message: `${u.displayName || u.email} is already a spectator and cannot be added as an approver.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Check if user is already another approver
|
|
const approverEmails = (formData.approvers || [])
|
|
.filter((_, i) => i !== index) // Exclude current position
|
|
.map((a: any) => a?.email?.toLowerCase?.())
|
|
.filter(Boolean);
|
|
if (approverEmails.includes((u.email || '').toLowerCase())) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: u.email,
|
|
message: `${u.displayName || u.email} is already an approver at another level.`
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Ensure user exists in database and get the DB userId
|
|
let dbUserId = u.userId;
|
|
try {
|
|
const dbUser = await ensureUserExists({
|
|
userId: u.userId,
|
|
email: u.email,
|
|
displayName: u.displayName,
|
|
firstName: u.firstName,
|
|
lastName: u.lastName,
|
|
department: u.department
|
|
});
|
|
// Use the database userId (UUID) instead of Okta ID
|
|
dbUserId = dbUser.userId;
|
|
} catch (err) {
|
|
console.error('Failed to ensure user exists:', err);
|
|
// Continue with Okta ID if ensure fails
|
|
}
|
|
|
|
const updated = [...formData.approvers];
|
|
updated[index] = {
|
|
...updated[index],
|
|
email: u.email,
|
|
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' '),
|
|
userId: dbUserId,
|
|
level: level,
|
|
};
|
|
updateFormData('approvers', updated);
|
|
setUserSearchResults(prev => ({ ...prev, [index]: [] }));
|
|
}}
|
|
>
|
|
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
|
<div className="text-xs text-gray-600">{u.email}</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{formData.approvers[index]?.userId ? (
|
|
<p className="text-xs text-green-600 mt-1 flex items-center gap-1">
|
|
<CheckCircle className="w-3 h-3" />
|
|
User verified in organization directory
|
|
</p>
|
|
) : formData.approvers[index]?.email ? (
|
|
<div className="mt-1 p-2 bg-amber-50 border border-amber-200 rounded">
|
|
<p className="text-xs text-amber-700 flex items-center gap-1 font-medium">
|
|
<AlertCircle className="w-3 h-3" />
|
|
This email will be validated against Okta directory
|
|
</p>
|
|
<p className="text-xs text-amber-600 mt-1">
|
|
If user doesn't exist in Okta, you won't be able to proceed. Use @ search for guaranteed results.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
|
<span className="font-medium">@</span>
|
|
Use @ sign to search and select a user (recommended)
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Peer Approver Section */}
|
|
<div className="border-t border-gray-200 pt-3">
|
|
|
|
|
|
{(formData.approvers[index]?.showPeerInput || formData.approvers[index]?.peerEmail) && (
|
|
<div className="space-y-2">
|
|
<Input
|
|
type="email"
|
|
placeholder="peer.approver@royalenfield.com"
|
|
value={formData.approvers[index]?.peerEmail || ''}
|
|
onChange={(e) => {
|
|
const newApprovers = [...formData.approvers];
|
|
newApprovers[index] = {
|
|
...newApprovers[index],
|
|
peerEmail: e.target.value,
|
|
level: level
|
|
};
|
|
updateFormData('approvers', newApprovers);
|
|
}}
|
|
className="h-9 border-2 border-gray-300 focus:border-blue-500 text-sm"
|
|
/>
|
|
<p className="text-xs text-gray-500">
|
|
This user can approve the request if the main approver is unavailable.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{formData.approvers[index]?.peerEmail && (
|
|
<div className="mt-2 p-2 bg-blue-50 rounded-lg border border-blue-200">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-6 h-6 bg-blue-600 rounded-full flex items-center justify-center">
|
|
<Users className="w-3 h-3 text-white" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-medium text-blue-900">
|
|
{formData.approvers[index]?.peerEmail}
|
|
</p>
|
|
<p className="text-xs text-blue-700">Peer Approver</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor={`tat-${level}`} className="text-sm font-medium">
|
|
TAT (Turn Around Time) *
|
|
</Label>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<Input
|
|
id={`tat-${level}`}
|
|
type="number"
|
|
placeholder={formData.approvers[index]?.tatType === 'days' ? '7' : '24'}
|
|
min="1"
|
|
max={formData.approvers[index]?.tatType === 'days' ? '30' : '720'}
|
|
value={formData.approvers[index]?.tat || ''}
|
|
onChange={(e) => {
|
|
const newApprovers = [...formData.approvers];
|
|
newApprovers[index] = {
|
|
...newApprovers[index],
|
|
tat: parseInt(e.target.value) || '',
|
|
level: level,
|
|
tatType: formData.approvers[index]?.tatType || 'hours'
|
|
};
|
|
updateFormData('approvers', newApprovers);
|
|
}}
|
|
className="h-10 border-2 border-gray-300 focus:border-blue-500 flex-1"
|
|
/>
|
|
|
|
<Select
|
|
value={formData.approvers[index]?.tatType || 'hours'}
|
|
onValueChange={(value) => {
|
|
const newApprovers = [...formData.approvers];
|
|
newApprovers[index] = {
|
|
...newApprovers[index],
|
|
tatType: value as 'hours' | 'days',
|
|
level: level,
|
|
// Clear the value when switching to set appropriate defaults
|
|
tat: ''
|
|
};
|
|
updateFormData('approvers', newApprovers);
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-20 h-10 border-2 border-gray-300">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="hours">Hours</SelectItem>
|
|
<SelectItem value="days">Days</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<p className="text-xs text-gray-600 mt-1">
|
|
{formData.approvers[index]?.tatType === 'days'
|
|
? 'Maximum days for this approver to review and respond (1-30 days)'
|
|
: 'Maximum time for this approver to review and respond (1-720 hours)'
|
|
}
|
|
</p>
|
|
</div>
|
|
|
|
<p className="text-xs text-gray-600">
|
|
{isLast
|
|
? 'This approver can close the request upon approval'
|
|
: 'Request will proceed to next level upon approval'
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
|
|
{/* Enhanced Summary with TAT */}
|
|
<div className="mt-6 space-y-4">
|
|
{/* Approval Flow Summary */}
|
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
|
|
<div className="flex items-start gap-3">
|
|
<Info className="w-5 h-5 text-blue-600 mt-0.5" />
|
|
<div>
|
|
<h4 className="font-semibold text-blue-900 mb-1">Approval Flow Summary</h4>
|
|
<p className="text-sm text-blue-700">
|
|
Your request will follow this sequence: <strong>You (Initiator)</strong> →
|
|
{Array.from({ length: formData.approverCount || 1 }, (_, i) =>
|
|
` Level ${i + 1} Approver`
|
|
).join(' → ')}.
|
|
The final approver can close the request.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* TAT Summary */}
|
|
{(() => {
|
|
// Calculate total TAT
|
|
let totalHours = 0;
|
|
let tatBreakdown = [];
|
|
let hasIncompleteData = false;
|
|
|
|
for (let i = 0; i < (formData.approverCount || 1); i++) {
|
|
const approver = formData.approvers[i];
|
|
if (approver?.tat && approver?.tatType) {
|
|
const tatValue = parseInt(approver.tat);
|
|
const hours = approver.tatType === 'days' ? tatValue * 24 : tatValue;
|
|
totalHours += hours;
|
|
tatBreakdown.push({
|
|
level: i + 1,
|
|
tat: approver.tat,
|
|
tatType: approver.tatType,
|
|
hours: hours
|
|
});
|
|
} else {
|
|
hasIncompleteData = true;
|
|
}
|
|
}
|
|
|
|
const totalDays = Math.ceil(totalHours / 24);
|
|
const workingDays = Math.ceil(totalHours / 8); // Assuming 8-hour working days
|
|
|
|
return (
|
|
<div className="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>
|
|
{!hasIncompleteData && (
|
|
<div className="text-right">
|
|
<div className="text-lg font-bold text-emerald-800">{totalDays} {totalDays === 1 ? 'Day' : 'Days'}</div>
|
|
<div className="text-xs text-emerald-600">Total Duration</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{hasIncompleteData ? (
|
|
<p className="text-sm text-amber-700 bg-amber-50 p-2 rounded border border-amber-200">
|
|
<AlertCircle className="w-4 h-4 inline mr-1" />
|
|
Complete all approver TAT information to see total duration summary
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{/* TAT Breakdown */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
{tatBreakdown.map((item, index) => (
|
|
<div key={index} 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 {item.level}</span>
|
|
<span className="text-sm text-emerald-700">{item.tat} {item.tatType}</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Total Summary */}
|
|
<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>
|
|
);
|
|
})()}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
|
|
case 4:
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<div className="text-center mb-8">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-teal-500 to-green-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<Eye className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Participants & Access</h2>
|
|
<p className="text-gray-600">
|
|
Configure additional participants and visibility settings for your request.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-3xl mx-auto space-y-8">
|
|
|
|
|
|
{/* Additional Participants */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
{/* Spectators */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between text-base">
|
|
<div className="flex items-center gap-2">
|
|
<Eye className="w-4 h-4" />
|
|
Spectators
|
|
</div>
|
|
<Badge variant="outline" className="text-xs">
|
|
{formData.spectators.length}
|
|
</Badge>
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Users who can view and comment but cannot approve
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div className="relative w-full">
|
|
<Input
|
|
placeholder="Use @ sign to add a user"
|
|
value={emailInput}
|
|
onChange={(e) => {
|
|
const value = e.target.value;
|
|
setEmailInput(value);
|
|
// Trigger search only when tagging with '@'
|
|
if (spectatorTimer.current) clearTimeout(spectatorTimer.current);
|
|
if (!value || !value.startsWith('@') || value.length < 2) {
|
|
setSpectatorSearchResults([]);
|
|
setSpectatorSearchLoading(false);
|
|
return;
|
|
}
|
|
setSpectatorSearchLoading(true);
|
|
spectatorTimer.current = setTimeout(async () => {
|
|
try {
|
|
const term = value.slice(1);
|
|
const response = await searchUsers(term, 10);
|
|
// Backend returns { success: true, data: [...users] }
|
|
const results = response.data?.data || [];
|
|
setSpectatorSearchResults(results);
|
|
} catch {
|
|
setSpectatorSearchResults([]);
|
|
} finally {
|
|
setSpectatorSearchLoading(false);
|
|
}
|
|
}, 300);
|
|
}}
|
|
onKeyPress={async (e) => {
|
|
// Allow Enter key to add spectator (will validate)
|
|
if (e.key === 'Enter' && validateEmail(emailInput)) {
|
|
e.preventDefault();
|
|
await inviteAndAddUser('spectators');
|
|
}
|
|
}}
|
|
className="text-sm w-full"
|
|
/>
|
|
{(spectatorSearchLoading || spectatorSearchResults.length > 0) && (
|
|
<div className="absolute left-0 right-0 top-full mt-1 z-50 border rounded-md bg-white shadow-lg">
|
|
{spectatorSearchLoading ? (
|
|
<div className="p-2 text-xs text-gray-500">Searching...</div>
|
|
) : (
|
|
<ul className="max-h-56 overflow-auto divide-y">
|
|
{spectatorSearchResults.map(u => (
|
|
<li
|
|
key={u.userId}
|
|
className="p-2 text-sm cursor-pointer hover:bg-gray-50"
|
|
onClick={async () => {
|
|
// Check if user is already an approver
|
|
const approverIds = (formData.approvers || []).map((a: any) => a?.userId).filter(Boolean);
|
|
const approverEmails = (formData.approvers || []).map((a: any) => a?.email?.toLowerCase?.()).filter(Boolean);
|
|
if (approverIds.includes(u.userId) || approverEmails.includes((u.email || '').toLowerCase())) {
|
|
setValidationModal({
|
|
open: true,
|
|
type: 'error',
|
|
email: u.email,
|
|
message: `${u.displayName || u.email} is already an approver and cannot be added as a spectator.`
|
|
});
|
|
setEmailInput('');
|
|
setSpectatorSearchResults([]);
|
|
return;
|
|
}
|
|
|
|
// Ensure user exists in database and get the DB userId
|
|
let dbUserId = u.userId;
|
|
try {
|
|
const dbUser = await ensureUserExists({
|
|
userId: u.userId,
|
|
email: u.email,
|
|
displayName: u.displayName,
|
|
firstName: u.firstName,
|
|
lastName: u.lastName,
|
|
department: u.department
|
|
});
|
|
// Use the database userId (UUID) instead of Okta ID
|
|
dbUserId = dbUser.userId;
|
|
} catch (err) {
|
|
console.error('Failed to ensure user exists:', err);
|
|
// Continue with Okta ID if ensure fails
|
|
}
|
|
|
|
// Add selected spectator directly with precise id/name/email
|
|
const spectator = {
|
|
id: dbUserId,
|
|
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email.split('@')[0],
|
|
email: u.email,
|
|
avatar: (u.displayName || u.email).split(' ').map((s: string) => s[0]).join('').slice(0, 2).toUpperCase(),
|
|
role: 'Spectator',
|
|
department: u.department || '',
|
|
level: 1,
|
|
canClose: false
|
|
} as any;
|
|
addUser(spectator, 'spectators');
|
|
setEmailInput('');
|
|
setSpectatorSearchResults([]);
|
|
}}
|
|
>
|
|
<div className="font-medium text-gray-900">{u.displayName || u.email}</div>
|
|
<div className="text-xs text-gray-600">{u.email}</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={async () => {
|
|
await inviteAndAddUser('spectators');
|
|
}}
|
|
disabled={!validateEmail(emailInput)}
|
|
>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-blue-600 bg-blue-50 border border-blue-200 rounded p-2 flex items-center gap-1">
|
|
<Info className="w-3 h-3 flex-shrink-0" />
|
|
<span>
|
|
Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users, or type email directly (will be validated against organization directory)
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
{formData.spectators.map((spectator) => (
|
|
<div key={spectator.id} className="flex items-center justify-between p-2 bg-teal-50 rounded-lg">
|
|
<div className="flex items-center gap-2">
|
|
<Avatar className="h-6 w-6">
|
|
<AvatarFallback className="bg-teal-600 text-white text-xs">
|
|
{spectator.avatar}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span className="text-sm font-medium">{spectator.name}</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeUser(spectator.id, 'spectators')}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* CC List */}
|
|
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
|
|
case 5:
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<div className="text-center mb-8">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<Upload className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Documents & Attachments</h2>
|
|
<p className="text-gray-600">
|
|
Upload supporting documents, files, and any additional materials for your request.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-2xl mx-auto space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FileText className="w-5 h-5" />
|
|
File Upload
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
|
|
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
|
<p className="text-gray-600 mb-4">
|
|
Drag and drop files here, or click to browse
|
|
</p>
|
|
<input
|
|
type="file"
|
|
multiple
|
|
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
|
onChange={handleFileUpload}
|
|
className="hidden"
|
|
id="file-upload"
|
|
ref={fileInputRef}
|
|
/>
|
|
<Button variant="outline" size="lg" type="button" onClick={() => fileInputRef.current?.click()}>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Browse Files
|
|
</Button>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
Supported formats: {documentPolicy.allowedFileTypes.map(ext => ext.toUpperCase()).join(', ')} (Max {documentPolicy.maxFileSizeMB}MB per file)
|
|
</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Existing Documents (from backend) */}
|
|
{isEditing && existingDocuments.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>Existing Documents</span>
|
|
<Badge variant="secondary">
|
|
{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length} file{existingDocuments.filter(d => !documentsToDelete.includes(d.documentId || d.document_id || '')).length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{existingDocuments.map((doc: any) => {
|
|
const docId = doc.documentId || doc.document_id || '';
|
|
const isDeleted = documentsToDelete.includes(docId);
|
|
if (isDeleted) return null;
|
|
|
|
return (
|
|
<div key={docId} className={`flex items-center justify-between p-4 rounded-lg border ${isDeleted ? 'bg-red-50 border-red-200 opacity-50' : 'bg-gray-50'}`}>
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<FileText className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{doc.originalFileName || doc.fileName || 'Document'}</p>
|
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
<span>{doc.fileSize ? (Number(doc.fileSize) / (1024 * 1024)).toFixed(2) + ' MB' : 'Unknown size'}</span>
|
|
<span>•</span>
|
|
<span>Uploaded {doc.uploadedAt ? new Date(doc.uploadedAt).toLocaleDateString() : 'previously'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Preview button - only for images and PDFs */}
|
|
{(() => {
|
|
const type = (doc.fileType || doc.file_type || '').toLowerCase();
|
|
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
|
|
return type.includes('image') || type.includes('pdf') ||
|
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
|
name.endsWith('.pdf');
|
|
})() && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Preview document"
|
|
onClick={() => {
|
|
setPreviewDocument({
|
|
fileName: doc.originalFileName || doc.fileName || 'Document',
|
|
fileType: doc.fileType || doc.file_type || 'application/octet-stream',
|
|
fileUrl: getDocumentPreviewUrl(docId),
|
|
fileSize: Number(doc.fileSize || doc.file_size || 0),
|
|
documentId: docId
|
|
} as any);
|
|
}}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setDocumentsToDelete(prev => [...prev, docId]);
|
|
}}
|
|
title="Delete document"
|
|
>
|
|
<X className="h-4 w-4 text-red-600" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* New Documents (being uploaded) */}
|
|
{formData.documents.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center justify-between">
|
|
<span>New Files to Upload</span>
|
|
<Badge variant="secondary">
|
|
{formData.documents.length} file{formData.documents.length !== 1 ? 's' : ''}
|
|
</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{formData.documents.map((file, index) => (
|
|
<div key={index} className="flex items-center justify-between p-4 bg-gray-50 rounded-lg border">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
|
<FileText className="h-5 w-5 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-900">{file.name}</p>
|
|
<div className="flex items-center gap-3 text-sm text-gray-600">
|
|
<span>{(file.size / (1024 * 1024)).toFixed(2)} MB</span>
|
|
<span>•</span>
|
|
<span>{file.type || 'Unknown type'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{/* Preview button - only for images and PDFs */}
|
|
{(() => {
|
|
const type = (file.type || '').toLowerCase();
|
|
const name = (file.name || '').toLowerCase();
|
|
return type.includes('image') || type.includes('pdf') ||
|
|
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
|
|
name.endsWith('.png') || name.endsWith('.gif') ||
|
|
name.endsWith('.pdf');
|
|
})() && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
title="Preview file"
|
|
onClick={() => {
|
|
// Create object URL for the file
|
|
const fileUrl = URL.createObjectURL(file);
|
|
setPreviewDocument({
|
|
fileName: file.name,
|
|
fileType: file.type || 'application/octet-stream',
|
|
fileUrl: fileUrl,
|
|
fileSize: file.size,
|
|
file: file
|
|
});
|
|
}}
|
|
>
|
|
<Eye className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
const newDocs = formData.documents.filter((_, i) => i !== index);
|
|
updateFormData('documents', newDocs);
|
|
}}
|
|
title="Remove file"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Documents marked for deletion */}
|
|
{isEditing && documentsToDelete.length > 0 && (
|
|
<Card className="border-red-200 bg-red-50">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-red-900">
|
|
<X className="w-5 h-5" />
|
|
Documents to be Deleted ({documentsToDelete.length})
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2">
|
|
{documentsToDelete.map((docId) => {
|
|
const doc = existingDocuments.find(d => (d.documentId || d.document_id) === docId);
|
|
return (
|
|
<div key={docId} className="flex items-center justify-between p-2 bg-red-100 rounded">
|
|
<span className="text-sm text-red-900">{doc?.originalFileName || doc?.fileName || 'Document'}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setDocumentsToDelete(prev => prev.filter(id => id !== docId));
|
|
}}
|
|
className="text-red-600 hover:text-red-800"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
|
|
case 6:
|
|
return (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -20 }}
|
|
className="space-y-6"
|
|
>
|
|
<div className="text-center mb-8">
|
|
<div className="w-16 h-16 bg-gradient-to-br from-green-500 to-teal-600 rounded-2xl flex items-center justify-center mx-auto mb-4">
|
|
<CheckCircle className="w-8 h-8 text-white" />
|
|
</div>
|
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">Review & Submit</h2>
|
|
<p className="text-gray-600">
|
|
Please review all details before submitting your request for approval.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="max-w-5xl mx-auto space-y-8">
|
|
{/* Request Overview */}
|
|
<Card className="border-2 border-green-200 bg-green-50/50">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-green-900">
|
|
<Rocket className="w-5 h-5" />
|
|
Request Overview
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<div>
|
|
<Label className="text-green-900 font-semibold">Request Type</Label>
|
|
<p className="text-green-800 mt-1">{selectedTemplate?.name}</p>
|
|
<Badge variant="outline" className="mt-2 text-xs border-green-300 text-green-700">
|
|
{selectedTemplate?.category}
|
|
</Badge>
|
|
</div>
|
|
<div>
|
|
<Label className="text-green-900 font-semibold">Priority</Label>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{getPriorityIcon(formData.priority)}
|
|
<span className="text-green-800 capitalize">{formData.priority}</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-green-900 font-semibold">Workflow Type</Label>
|
|
<p className="text-green-800 mt-1 capitalize">{formData.workflowType}</p>
|
|
<p className="text-sm text-green-700">{formData.approverCount || 1} Level{(formData.approverCount || 1) > 1 ? 's' : ''}</p>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
<Label className="text-green-900 font-semibold">Request Title</Label>
|
|
<p className="text-green-800 font-medium mt-1 text-lg">{formData.title}</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Basic Information */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<FileText className="w-5 h-5" />
|
|
Basic Information
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label className="font-semibold">Description</Label>
|
|
<p className="text-sm text-gray-700 mt-1 p-3 bg-gray-50 rounded-lg border">
|
|
{formData.description}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<Label className="font-semibold">Priority Level</Label>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
{getPriorityIcon(formData.priority)}
|
|
<span className="capitalize font-medium">{formData.priority}</span>
|
|
</div>
|
|
</div>
|
|
{formData.urgency && (
|
|
<div>
|
|
<Label className="font-semibold">Urgency</Label>
|
|
<p className="text-sm text-gray-700 mt-1 capitalize">{formData.urgency}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{formData.businessImpact && (
|
|
<div>
|
|
<Label className="font-semibold">Business Impact</Label>
|
|
<p className="text-sm text-gray-700 mt-1 p-3 bg-gray-50 rounded-lg border">
|
|
{formData.businessImpact}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Financial Information */}
|
|
{formData.amount && (
|
|
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
|
<div className="flex items-center gap-2 mb-3">
|
|
<DollarSign className="w-4 h-4 text-blue-600" />
|
|
<Label className="font-semibold text-blue-900">Financial Details</Label>
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<span className="text-sm text-blue-700">Amount</span>
|
|
<p className="font-semibold text-blue-900">{formData.amount} {formData.currency}</p>
|
|
</div>
|
|
{formData.costCenter && (
|
|
<div>
|
|
<span className="text-sm text-blue-700">Cost Center</span>
|
|
<p className="font-medium text-blue-900">{formData.costCenter}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Additional Fields */}
|
|
{(formData.vendor || formData.timeline || formData.project) && (
|
|
<div className="space-y-3">
|
|
<Label className="font-semibold">Additional Information</Label>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{formData.vendor && (
|
|
<div>
|
|
<span className="text-sm text-gray-600">Vendor</span>
|
|
<p className="text-sm font-medium">{formData.vendor}</p>
|
|
</div>
|
|
)}
|
|
{formData.timeline && (
|
|
<div>
|
|
<span className="text-sm text-gray-600">Timeline</span>
|
|
<p className="text-sm font-medium">{formData.timeline}</p>
|
|
</div>
|
|
)}
|
|
{formData.project && (
|
|
<div>
|
|
<span className="text-sm text-gray-600">Related Project</span>
|
|
<p className="text-sm font-medium">{formData.project}</p>
|
|
</div>
|
|
)}
|
|
{formData.expectedCompletionDate && (
|
|
<div>
|
|
<span className="text-sm text-gray-600">Expected Completion</span>
|
|
<p className="text-sm font-medium">{format(formData.expectedCompletionDate, 'PPP')}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Approval Workflow */}
|
|
<Card className="border-2 border-orange-200 bg-orange-50/50">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-orange-900">
|
|
<Users className="w-5 h-5" />
|
|
Approval Workflow
|
|
</CardTitle>
|
|
<CardDescription className="text-orange-700">
|
|
Sequential approval hierarchy with TAT (Turn Around Time) for each level
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-3 bg-orange-100 rounded-lg">
|
|
<div>
|
|
<Label className="text-orange-900 font-semibold">Workflow Type</Label>
|
|
<p className="text-orange-800 mt-1 capitalize">{formData.workflowType}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-orange-900 font-semibold">Approval Levels</Label>
|
|
<p className="text-orange-800 mt-1">{formData.approverCount || 1} Level{(formData.approverCount || 1) > 1 ? 's' : ''}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="text-orange-900 font-semibold">Escalation</Label>
|
|
<p className="text-orange-800 mt-1">{formData.escalationEnabled ? 'Enabled' : 'Disabled'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Approver Details */}
|
|
<div className="space-y-4">
|
|
{Array.from({ length: formData.approverCount || 1 }, (_, index) => {
|
|
const level = index + 1;
|
|
const isLast = level === (formData.approverCount || 1);
|
|
const approver = formData.approvers[index];
|
|
|
|
return (
|
|
<div key={level} className="p-4 bg-white rounded-lg border border-orange-200">
|
|
<div className="flex items-center gap-4">
|
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
|
approver?.email ? 'bg-green-600' : 'bg-gray-400'
|
|
}`}>
|
|
<span className="text-white font-semibold">{level}</span>
|
|
</div>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="font-semibold text-gray-900">
|
|
Approver Level {level}
|
|
</span>
|
|
{isLast && (
|
|
<Badge variant="destructive" className="text-xs">FINAL APPROVER</Badge>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<span className="text-sm text-gray-600">Email Address</span>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{approver?.email || 'Not assigned'}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-sm text-gray-600">TAT (Turn Around Time)</span>
|
|
<p className="text-sm font-medium text-gray-900">
|
|
{approver?.tat ?
|
|
`${approver.tat} ${approver.tatType === 'days' ? 'day' : 'hour'}${approver.tat !== 1 ? 's' : ''}`
|
|
: 'Not set'
|
|
}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1">
|
|
{approver?.email && (
|
|
<Badge variant="outline" className="text-xs border-green-300 text-green-700">
|
|
✓ Assigned
|
|
</Badge>
|
|
)}
|
|
{isLast && (
|
|
<Badge className="text-xs bg-red-100 text-red-800 border border-red-200">
|
|
Can Close
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Participants & Access */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Eye className="w-5 h-5" />
|
|
Participants & Access Control
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 p-3 bg-gray-50 rounded-lg">
|
|
|
|
<div>
|
|
<Label className="font-semibold">Comments</Label>
|
|
<p className="text-sm text-gray-700 mt-1">{formData.allowComments ? 'Allowed' : 'Disabled'}</p>
|
|
</div>
|
|
<div>
|
|
<Label className="font-semibold">Document Upload</Label>
|
|
<p className="text-sm text-gray-700 mt-1">{formData.allowDocumentUpload ? 'Allowed' : 'Disabled'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spectators */}
|
|
{formData.spectators.length > 0 && (
|
|
<div>
|
|
<Label className="font-semibold text-sm">Spectators ({formData.spectators.length})</Label>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{formData.spectators.map((spectator) => (
|
|
<Badge key={spectator.id} variant="outline" className="text-xs">
|
|
{spectator.name} ({spectator.email})
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-gray-600 mt-2">View-only access with commenting privileges</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* CC List */}
|
|
{formData.ccList.length > 0 && (
|
|
<div>
|
|
<Label className="font-semibold text-sm">CC List ({formData.ccList.length})</Label>
|
|
<div className="flex flex-wrap gap-2 mt-2">
|
|
{formData.ccList.map((cc) => (
|
|
<Badge key={cc.id} variant="secondary" className="text-xs">
|
|
{cc.name} ({cc.email})
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<p className="text-xs text-gray-600 mt-2">Will receive email notifications about request updates</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Documents & Attachments */}
|
|
{formData.documents.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Upload className="w-5 h-5" />
|
|
Documents & Attachments
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{formData.documents.length} document{formData.documents.length !== 1 ? 's' : ''} attached to this request
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-3">
|
|
{formData.documents.map((doc, index) => (
|
|
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 rounded-lg border">
|
|
<FileText className="w-5 h-5 text-gray-500 flex-shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium text-sm truncate">{doc.name}</p>
|
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-1">
|
|
<span>{(doc.size / (1024 * 1024)).toFixed(2)} MB</span>
|
|
<span>•</span>
|
|
<span>{doc.type || 'Unknown type'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
|
|
|
|
{/* Final Confirmation */}
|
|
<Card className="border-2 border-blue-200 bg-blue-50/50">
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-start gap-4">
|
|
<CheckCircle className="w-6 h-6 text-blue-600 mt-1 flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<h3 className="font-semibold text-blue-900 mb-2">Ready to Submit Request</h3>
|
|
<p className="text-sm text-blue-700 mb-4">
|
|
Once submitted, your request will enter the approval workflow and notifications will be sent to all relevant participants.
|
|
</p>
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
|
<div>
|
|
<span className="text-blue-700">Request Type:</span>
|
|
<p className="font-medium text-blue-900">{selectedTemplate?.name}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-blue-700">Approval Levels:</span>
|
|
<p className="font-medium text-blue-900">{formData.approverCount || 1}</p>
|
|
</div>
|
|
<div>
|
|
<span className="text-blue-700">Documents:</span>
|
|
<p className="font-medium text-blue-900">{formData.documents.length} attached</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</motion.div>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-gradient-to-br from-gray-50 to-white">
|
|
{/* Header - Compact on Mobile */}
|
|
<div className="bg-white border-b border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0">
|
|
<div className="flex items-center justify-between max-w-6xl mx-auto gap-2 sm:gap-4">
|
|
<div className="flex items-center gap-2 sm:gap-4 flex-1 min-w-0">
|
|
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 h-8 w-8 sm:h-10 sm:w-10">
|
|
<ArrowLeft className="h-4 w-4 sm:h-5 sm:w-5" />
|
|
</Button>
|
|
<div className="min-w-0">
|
|
<h1 className="text-base sm:text-xl md:text-2xl font-bold text-gray-900 truncate">
|
|
{isEditing ? 'Edit Draft' : 'New Request'}
|
|
</h1>
|
|
<p className="text-xs sm:text-sm text-gray-600 hidden sm:block">
|
|
Step {currentStep} of {totalSteps}: {STEP_NAMES[currentStep - 1]}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="hidden md:flex items-center gap-4">
|
|
<div className="text-right">
|
|
<p className="text-sm font-medium text-gray-900">{Math.round((currentStep / totalSteps) * 100)}% Complete</p>
|
|
<p className="text-xs text-gray-600">{totalSteps - currentStep} steps remaining</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar - Mobile Optimized */}
|
|
<div className="bg-white border-b border-gray-200 px-3 sm:px-6 py-2 sm:py-3 flex-shrink-0">
|
|
<div className="max-w-6xl mx-auto">
|
|
{/* Mobile: Current step indicator only */}
|
|
<div className="block sm:hidden">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-8 h-8 rounded-full bg-green-600 text-white flex items-center justify-center text-xs font-semibold">
|
|
{currentStep}
|
|
</div>
|
|
<div>
|
|
<p className="text-xs font-semibold text-gray-900">{STEP_NAMES[currentStep - 1]}</p>
|
|
<p className="text-xs text-gray-600">Step {currentStep} of {totalSteps}</p>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="text-xs font-medium text-green-600">{Math.round((currentStep / totalSteps) * 100)}%</p>
|
|
</div>
|
|
</div>
|
|
{/* Progress bar */}
|
|
<div className="w-full bg-gray-200 h-1.5 rounded-full overflow-hidden">
|
|
<div
|
|
className="bg-green-600 h-full transition-all duration-300"
|
|
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop: Full step indicator */}
|
|
<div className="hidden sm:block">
|
|
<div className="flex items-center justify-between mb-2">
|
|
{STEP_NAMES.map((_, index) => (
|
|
<div key={index} className="flex items-center">
|
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold ${
|
|
index + 1 < currentStep
|
|
? 'bg-green-600 text-white'
|
|
: index + 1 === currentStep
|
|
? 'bg-blue-600 text-white'
|
|
: 'bg-gray-200 text-gray-600'
|
|
}`}>
|
|
{index + 1 < currentStep ? (
|
|
<Check className="w-4 h-4" />
|
|
) : (
|
|
index + 1
|
|
)}
|
|
</div>
|
|
{index < STEP_NAMES.length - 1 && (
|
|
<div 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'
|
|
}`} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="hidden lg:flex justify-between text-xs text-gray-600 mt-2">
|
|
{STEP_NAMES.map((step, index) => (
|
|
<span key={index} className={`${
|
|
index + 1 === currentStep ? 'font-semibold text-blue-600' : ''
|
|
}`}>
|
|
{step}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content - with extra bottom padding for mobile keyboards */}
|
|
<div className="flex-1 overflow-y-auto pb-24 sm:pb-4">
|
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 pb-6 sm:pb-6">
|
|
<AnimatePresence mode="wait">
|
|
{renderStepContent()}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer - Fixed on mobile for better keyboard handling */}
|
|
<div className="fixed sm:relative bottom-0 left-0 right-0 bg-white border-t border-gray-200 px-3 sm:px-6 py-3 sm:py-4 flex-shrink-0 shadow-lg sm:shadow-none z-50">
|
|
<div className="flex flex-col sm:flex-row justify-between items-stretch sm:items-center gap-2 sm:gap-4 max-w-6xl mx-auto">
|
|
{/* Previous Button */}
|
|
<Button
|
|
variant="outline"
|
|
onClick={prevStep}
|
|
disabled={currentStep === 1}
|
|
size="sm"
|
|
className="sm:size-lg order-2 sm:order-1"
|
|
>
|
|
<ArrowLeft className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
|
<span className="text-xs sm:text-sm">Previous</span>
|
|
</Button>
|
|
|
|
{/* Action Buttons */}
|
|
<div className="flex gap-2 sm:gap-3 order-1 sm:order-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleSaveDraft}
|
|
size="sm"
|
|
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
|
|
disabled={loadingDraft || submitting || savingDraft}
|
|
>
|
|
{savingDraft ? (
|
|
<>
|
|
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
|
|
{isEditing ? 'Updating...' : 'Saving...'}
|
|
</>
|
|
) : (
|
|
isEditing ? 'Update Draft' : 'Save Draft'
|
|
)}
|
|
</Button>
|
|
{currentStep === totalSteps ? (
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={!isStepValid() || loadingDraft || submitting || savingDraft}
|
|
size="sm"
|
|
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
|
>
|
|
{submitting ? (
|
|
<>
|
|
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
|
|
Submitting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
|
Submit
|
|
</>
|
|
)}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={async () => {
|
|
console.log('Next button clicked!');
|
|
console.log('Current step:', currentStep);
|
|
console.log('Is step valid:', isStepValid());
|
|
console.log('Form data:', formData);
|
|
await nextStep();
|
|
}}
|
|
disabled={!isStepValid()}
|
|
size="sm"
|
|
className="sm:size-lg flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
|
>
|
|
<span className="hidden sm:inline">Next Step</span>
|
|
<span className="sm:hidden">Next</span>
|
|
<ArrowRight className="h-3 w-3 sm:h-4 sm:w-4 ml-1 sm:ml-2" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Template Selection Modal */}
|
|
<TemplateSelectionModal
|
|
open={showTemplateModal}
|
|
onClose={() => setShowTemplateModal(false)}
|
|
onSelectTemplate={handleTemplateSelection}
|
|
/>
|
|
|
|
{/* File Preview Modal */}
|
|
{previewDocument && (
|
|
<FilePreview
|
|
fileName={previewDocument.fileName}
|
|
fileType={previewDocument.fileType}
|
|
fileUrl={previewDocument.fileUrl}
|
|
fileSize={previewDocument.fileSize}
|
|
open={!!previewDocument}
|
|
onClose={() => {
|
|
// Clean up object URL to prevent memory leaks
|
|
if (previewDocument.fileUrl) {
|
|
URL.revokeObjectURL(previewDocument.fileUrl);
|
|
}
|
|
setPreviewDocument(null);
|
|
}}
|
|
onDownload={async () => {
|
|
// For new uploads (File object), download using browser download
|
|
if (previewDocument.file) {
|
|
const link = document.createElement('a');
|
|
link.href = previewDocument.fileUrl;
|
|
link.download = previewDocument.fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} else if (previewDocument.documentId) {
|
|
// For existing documents from draft, use the API download function
|
|
await downloadDocument(previewDocument.documentId);
|
|
}
|
|
}}
|
|
attachmentId={previewDocument.documentId}
|
|
/>
|
|
)}
|
|
|
|
{/* Document Validation Error Modal */}
|
|
<Dialog open={documentErrorModal.open} onOpenChange={(open) => setDocumentErrorModal(prev => ({ ...prev, open }))}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
|
Document Upload Policy Violation
|
|
</DialogTitle>
|
|
<DialogDescription asChild>
|
|
<div className="space-y-3">
|
|
<p className="text-gray-700">
|
|
The following file(s) could not be uploaded due to policy violations:
|
|
</p>
|
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
|
{documentErrorModal.errors.map((error, index) => (
|
|
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
|
|
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
|
|
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
|
|
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
|
|
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
|
|
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => setDocumentErrorModal({ open: false, errors: [] })}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
OK
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Validation Error Modal */}
|
|
<Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-2">
|
|
{validationModal.type === 'self-assign' && (
|
|
<>
|
|
<AlertCircle className="w-5 h-5 text-amber-600" />
|
|
Cannot Add Yourself
|
|
</>
|
|
)}
|
|
{validationModal.type === 'not-found' && (
|
|
<>
|
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
|
User Not Found
|
|
</>
|
|
)}
|
|
{validationModal.type === 'error' && (
|
|
<>
|
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
|
Validation Error
|
|
</>
|
|
)}
|
|
</DialogTitle>
|
|
<DialogDescription asChild>
|
|
<div className="space-y-3">
|
|
{validationModal.type === 'self-assign' && (
|
|
<>
|
|
<p className="text-gray-700">
|
|
You cannot add yourself (<strong>{validationModal.email}</strong>) as an approver.
|
|
</p>
|
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
|
<p className="text-sm text-amber-800">
|
|
<strong>Why?</strong> The initiator creates the request and cannot approve their own request. Please select a different user.
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{validationModal.type === 'not-found' && (
|
|
<>
|
|
<p className="text-gray-700">
|
|
User <strong>{validationModal.email}</strong> was not found in the organization directory.
|
|
</p>
|
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
|
|
<p className="text-sm text-red-800 font-semibold">Please verify:</p>
|
|
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
|
|
<li>Email address is spelled correctly</li>
|
|
<li>User exists in Okta/SSO system</li>
|
|
<li>User has an active account</li>
|
|
</ul>
|
|
</div>
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
|
<p className="text-sm text-blue-800 flex items-center gap-1">
|
|
<Lightbulb className="w-4 h-4" />
|
|
<strong>Tip:</strong> Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search and select users from the directory for guaranteed results.
|
|
</p>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{validationModal.type === 'error' && (
|
|
<>
|
|
<p className="text-gray-700">
|
|
{validationModal.email && (
|
|
<>Failed to validate <strong>{validationModal.email}</strong>.</>
|
|
)}
|
|
{!validationModal.email && <>An error occurred during validation.</>}
|
|
</p>
|
|
{validationModal.message && (
|
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
|
<p className="text-sm text-gray-700">{validationModal.message}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button
|
|
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
|
|
className="w-full sm:w-auto"
|
|
>
|
|
{validationModal.type === 'not-found' ? 'Fix Email' : 'OK'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
} |