create request flow created

This commit is contained in:
laxmanhalaki 2025-10-30 18:12:53 +05:30
parent 02b009194c
commit f9c8364b61
8 changed files with 893 additions and 376 deletions

View File

@ -1,27 +1,12 @@
import { useState, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Calendar,
Filter,
Search,
FileText,
AlertCircle,
CheckCircle,
ArrowRight,
SortAsc,
SortDesc,
Flame,
Target,
RefreshCw,
Settings2,
X,
XCircle
} from 'lucide-react';
import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, XCircle } from 'lucide-react';
import workflowApi from '@/services/workflowApi';
interface Request {
id: string;
@ -29,12 +14,9 @@ interface Request {
description: string;
status: 'approved' | 'rejected';
priority: 'express' | 'standard';
initiator: {
name: string;
avatar: string;
};
initiator: { name: string; avatar: string };
createdAt: string;
dueDate: string;
dueDate?: string;
reason?: string;
department?: string;
}
@ -43,66 +25,7 @@ interface ClosedRequestsProps {
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
// Static data for closed requests
const CLOSED_REQUESTS: Request[] = [
{
id: 'RE-REQ-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign using Claim Management template workflow.',
status: 'approved',
priority: 'standard',
initiator: { name: 'Sneha Patil', avatar: 'SP' },
createdAt: '2024-10-07',
dueDate: '2024-10-16',
reason: 'Budget approved with quarterly review conditions',
department: 'Marketing - West Zone'
},
{
id: 'RE-REQ-001',
title: 'Marketing Campaign Budget Approval',
description: 'Request for Q4 marketing campaign budget allocation of $50,000 for digital advertising across social media platforms and content creation.',
status: 'approved',
priority: 'express',
initiator: { name: 'Sarah Chen', avatar: 'SC' },
createdAt: '2024-10-07',
dueDate: '2024-10-09',
reason: 'All equipment approved and ordered through preferred vendor',
department: 'Marketing'
},
{
id: 'RE-REQ-002',
title: 'IT Equipment Purchase',
description: 'Purchase of 10 new laptops for the development team including software licenses and accessories for enhanced productivity.',
status: 'rejected',
priority: 'standard',
initiator: { name: 'David Kumar', avatar: 'DK' },
createdAt: '2024-10-06',
dueDate: '2024-10-12',
reason: 'Pricing not competitive, seek alternative vendors'
},
{
id: 'RE-REQ-003',
title: 'Vendor Contract Renewal',
description: 'Annual renewal of cleaning services contract with updated terms and pricing structure for office maintenance.',
status: 'approved',
priority: 'standard',
initiator: { name: 'John Doe', avatar: 'JD' },
createdAt: '2024-10-05',
dueDate: '2024-10-08',
reason: 'Lease terms negotiated and approved by legal team'
},
{
id: 'RE-REQ-004',
title: 'Office Space Expansion',
description: 'Lease additional office space for growing team, 2000 sq ft in the same building with modern amenities.',
status: 'approved',
priority: 'express',
initiator: { name: 'Lisa Wong', avatar: 'LW' },
createdAt: '2024-10-04',
dueDate: '2024-10-15',
reason: 'Program approved with budget adjustments'
}
];
// Removed static data; will load from API
// Utility functions
const getPriorityConfig = (priority: string) => {
@ -158,9 +81,48 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
(async () => {
try {
setLoading(true);
const result = await workflowApi.listClosedByMe({ page: 1, limit: 50 });
const data = Array.isArray((result as any)?.data)
? (result as any).data
: Array.isArray((result as any)?.data?.data)
? (result as any).data.data
: Array.isArray(result as any)
? (result as any)
: [];
if (!mounted) return;
const mapped: Request[] = data
.filter((r: any) => ['APPROVED', 'REJECTED'].includes((r.status || '').toString()))
.map((r: any) => ({
id: r.requestId || r.requestNumber,
displayId: r.requestNumber || r.requestId,
title: r.title,
description: r.description,
status: (r.status || '').toString().toLowerCase(),
priority: (r.priority || '').toString().toLowerCase(),
initiator: { name: r.initiator?.displayName || r.initiator?.email || '—', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() },
createdAt: r.submittedAt || '—',
dueDate: undefined,
reason: r.conclusionRemark,
department: r.department
}));
setItems(mapped);
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
const filteredAndSortedRequests = useMemo(() => {
let filtered = CLOSED_REQUESTS.filter(request => {
let filtered = items.filter(request => {
const matchesSearch =
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -202,7 +164,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
});
return filtered;
}, [searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
const clearFilters = () => {
setSearchTerm('');
@ -234,7 +196,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
<div className="flex items-center gap-3">
<Badge variant="secondary" className="text-lg px-4 py-2 bg-slate-100 text-slate-800 font-semibold">
{filteredAndSortedRequests.length} closed requests
{loading ? 'Loading…' : `${filteredAndSortedRequests.length} closed requests`}
</Badge>
<Button variant="outline" size="sm" className="gap-2">
<RefreshCw className="w-4 h-4" />
@ -400,7 +362,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
{request.id}
{(request as any).displayId || request.id}
</h3>
<Badge
variant="outline"

View File

@ -1,4 +1,6 @@
import { useState } from 'react';
import { useState, useRef } from 'react';
import { searchUsers, type UserSummary } from '@/services/userApi';
import { createWorkflowMultipart, submitWorkflow } 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';
@ -38,6 +40,8 @@ import {
Settings
} from 'lucide-react';
import { format } from 'date-fns';
import { useAuth } from '@/contexts/AuthContext';
import documentApi from '@/services/documentApi';
interface CreateRequestProps {
onBack?: () => void;
@ -130,6 +134,7 @@ const STEP_NAMES = [
];
export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
const { user } = useAuth();
const [currentStep, setCurrentStep] = useState(1);
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
const [emailInput, setEmailInput] = useState('');
@ -141,6 +146,17 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
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
@ -356,16 +372,149 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
};
const handleSubmit = () => {
if (isStepValid()) {
const requestData = {
...formData,
template: selectedTemplate,
id: `RE-REQ-${Date.now()}`,
createdAt: new Date().toISOString(),
status: 'pending'
};
onSubmit?.(requestData);
if (!isStepValid()) return;
// 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,
};
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
.then(async (res) => {
const id = (res as any).id;
try {
await submitWorkflow(id);
} catch {}
onSubmit?.({ ...formData, backendId: id, template: selectedTemplate });
})
.catch((err) => {
console.error('Failed to create workflow:', err);
});
};
const handleSaveDraft = () => {
// 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;
}
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: [],
};
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
.then((res) => {
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
})
.catch((err) => console.error('Failed to save draft:', err));
};
const renderStepContent = () => {
@ -851,22 +1000,87 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
Email Address *
</Label>
<Input
<div className="relative">
<Input
id={`approver-${level}`}
type="email"
placeholder="approver@royalenfield.com"
value={formData.approvers[index]?.email || ''}
onChange={(e) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
email: e.target.value,
level: level
};
updateFormData('approvers', newApprovers);
}}
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1"
/>
onChange={(e) => {
const value = e.target.value;
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
email: value,
level: level
};
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 results = await searchUsers(term, 10);
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={() => {
// Prevent adding an approver as spectator
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())) {
alert('This user is already an approver and cannot be added as a spectator.');
return;
}
const updated = [...formData.approvers];
updated[index] = {
...updated[index],
email: u.email,
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' '),
userId: u.userId,
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>
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
<span className="font-medium">@</span>
Use @ sign to tag a user
@ -1141,17 +1355,71 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
<div className="flex items-center gap-2">
<Input
placeholder="Use @ sign to add a user"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter' && validateEmail(emailInput)) {
inviteAndAddUser('spectators');
<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;
}
}}
className="text-sm"
/>
setSpectatorSearchLoading(true);
spectatorTimer.current = setTimeout(async () => {
try {
const term = value.slice(1);
const res = await searchUsers(term, 10);
setSpectatorSearchResults(res);
} catch {
setSpectatorSearchResults([]);
} finally {
setSpectatorSearchLoading(false);
}
}, 300);
}}
onKeyPress={(e) => {
if (e.key === 'Enter' && validateEmail(emailInput)) {
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={() => {
// Add selected spectator directly with precise id/name/email
const spectator = {
id: u.userId,
name: u.displayName || [u.firstName, u.lastName].filter(Boolean).join(' ') || u.email.split('@')[0],
email: u.email,
} 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={() => inviteAndAddUser('spectators')}
@ -1236,13 +1504,12 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
onChange={handleFileUpload}
className="hidden"
id="file-upload"
ref={fileInputRef}
/>
<Label htmlFor="file-upload" className="cursor-pointer">
<Button variant="outline" size="lg" type="button">
<Plus className="w-4 h-4 mr-2" />
Browse Files
</Button>
</Label>
<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: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, PPT, PPTX
</p>
@ -1760,7 +2027,7 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={onBack} size="lg">
<Button variant="outline" onClick={handleSaveDraft} size="lg">
Save Draft
</Button>
{currentStep === totalSteps ? (

View File

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@ -19,86 +19,14 @@ import {
Target
} from 'lucide-react';
import { motion } from 'framer-motion';
import workflowApi from '@/services/workflowApi';
interface MyRequestsProps {
onViewRequest: (requestId: string, requestTitle?: string) => void;
dynamicRequests?: any[];
}
// Mock data for user's requests
const MY_REQUESTS_DATA = [
{
id: 'RE-REQ-2024-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign',
status: 'pending',
priority: 'standard',
department: 'Marketing - West Zone',
submittedDate: '2024-10-07',
currentApprover: 'Royal Motors Mumbai (Dealer)',
approverLevel: '1 of 8',
dueDate: '2024-10-16',
templateType: 'claim-management',
templateName: 'Claim Management',
estimatedCompletion: '2024-10-16'
},
{
id: 'RE-REQ-001',
title: 'Marketing Campaign Budget Approval',
description: 'Request for Q4 marketing campaign budget allocation for new motorcycle launch',
status: 'pending',
priority: 'express',
department: 'Marketing',
submittedDate: '2024-10-05',
currentApprover: 'Sarah Johnson',
approverLevel: '2 of 3',
dueDate: '2024-10-12'
},
{
id: 'RE-REQ-002',
title: 'IT Equipment Purchase Request',
description: 'New laptops and workstations for the development team',
status: 'approved',
priority: 'standard',
submittedDate: '2024-09-28',
currentApprover: 'Completed',
approverLevel: '3 of 3',
dueDate: '2024-10-01'
},
{
id: 'RE-REQ-003',
title: 'Training Program Authorization',
description: 'Employee skill development program for technical team',
status: 'in-review',
priority: 'standard',
submittedDate: '2024-10-03',
currentApprover: 'Michael Chen',
approverLevel: '1 of 2',
estimatedCompletion: '2024-10-10'
},
{
id: 'RE-REQ-004',
title: 'Vendor Contract Renewal',
description: 'Annual renewal for supply chain vendor contracts',
status: 'rejected',
priority: 'express',
submittedDate: '2024-09-25',
currentApprover: 'Rejected by Alex Kumar',
approverLevel: '1 of 3',
estimatedCompletion: 'N/A'
},
{
id: 'RE-REQ-005',
title: 'Office Space Renovation',
description: 'Workspace renovation for improved employee experience',
status: 'draft',
priority: 'standard',
submittedDate: '2024-10-07',
currentApprover: 'Not submitted',
approverLevel: '0 of 2',
estimatedCompletion: 'Pending submission'
}
];
// Removed mock data; list renders API data only
const getPriorityConfig = (priority: string) => {
@ -169,25 +97,48 @@ const getStatusConfig = (status: string) => {
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
(async () => {
try {
setLoading(true);
const result = await workflowApi.listMyWorkflows({ page: 1, limit: 20 });
const items = Array.isArray(result?.data) ? result.data : Array.isArray(result) ? result : [];
if (!mounted) return;
setApiRequests(items);
} catch (_) {
if (!mounted) return;
setApiRequests([]);
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
// Convert dynamic requests to the format expected by this component
const convertedDynamicRequests = dynamicRequests.map(req => ({
id: req.id,
// Convert API/dynamic requests to the format expected by this component
const sourceRequests = (apiRequests.length ? apiRequests : dynamicRequests);
const convertedDynamicRequests = sourceRequests.map((req: any) => ({
id: req.requestId || req.id || req.request_id,
displayId: req.requestNumber || req.request_number || req.id,
title: req.title,
description: req.description,
status: req.status,
priority: req.priority,
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'),
priority: (req.priority || '').toString().toLowerCase(),
department: req.department,
submittedDate: new Date(req.createdAt).toISOString().split('T')[0],
currentApprover: req.approvalFlow?.[0]?.approver || 'Current User (Initiator)',
approverLevel: `${req.currentStep} of ${req.totalSteps}`,
dueDate: new Date(req.dueDate).toISOString().split('T')[0],
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
dueDate: req.dueDate ? new Date(req.dueDate).toISOString().split('T')[0] : undefined,
templateType: req.templateType,
templateName: req.templateName
}));
// Merge static mock data with dynamic requests (dynamic requests first)
const allRequests = [...convertedDynamicRequests, ...MY_REQUESTS_DATA];
// Use only API/dynamic requests
const allRequests = convertedDynamicRequests;
const [priorityFilter, setPriorityFilter] = useState('all');
// Filter requests based on search and filters
@ -337,7 +288,11 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
{/* Requests List */}
<div className="space-y-4">
{filteredRequests.length === 0 ? (
{loading ? (
<Card>
<CardContent className="p-6 text-sm text-gray-600">Loading your requests</CardContent>
</Card>
) : filteredRequests.length === 0 ? (
<Card>
<CardContent className="p-12 text-center">
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
@ -397,7 +352,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
{request.description}
</p>
<div className="flex items-center gap-4 text-sm text-gray-500">
<span><span className="font-medium">ID:</span> {request.id}</span>
<span><span className="font-medium">ID:</span> {(request as any).displayId || request.id}</span>
<span><span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}</span>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -6,23 +6,8 @@ import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import {
Calendar,
Clock,
Filter,
Search,
FileText,
AlertCircle,
ArrowRight,
SortAsc,
SortDesc,
Flame,
Target,
Eye,
RefreshCw,
Settings2,
X
} from 'lucide-react';
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react';
import workflowApi from '@/services/workflowApi';
interface Request {
id: string;
@ -30,19 +15,13 @@ interface Request {
description: string;
status: 'pending' | 'in-review';
priority: 'express' | 'standard';
initiator: {
name: string;
avatar: string;
};
currentApprover?: {
name: string;
avatar: string;
};
initiator: { name: string; avatar: string };
currentApprover?: { name: string; avatar: string };
slaProgress: number;
slaRemaining: string;
createdAt: string;
dueDate: string;
approvalStep: string;
dueDate?: string;
approvalStep?: string;
department?: string;
}
@ -50,95 +29,7 @@ interface OpenRequestsProps {
onViewRequest?: (requestId: string, requestTitle?: string) => void;
}
// Static data for open requests
const OPEN_REQUESTS: Request[] = [
{
id: 'RE-REQ-CM-001',
title: 'Dealer Marketing Activity Claim - Diwali Festival Campaign',
description: 'Claim request for dealer-led Diwali festival marketing campaign using Claim Management template workflow.',
status: 'pending',
priority: 'standard',
initiator: { name: 'Sneha Patil', avatar: 'SP' },
currentApprover: { name: 'Sneha Patil (Initiator)', avatar: 'SP' },
slaProgress: 35,
slaRemaining: '4 days 12 hours',
createdAt: '2024-10-07',
dueDate: '2024-10-16',
approvalStep: 'Initiator Review & Confirmation',
department: 'Marketing - West Zone'
},
{
id: 'RE-REQ-001',
title: 'Marketing Campaign Budget Approval',
description: 'Request for Q4 marketing campaign budget allocation of $50,000 for digital advertising across social media platforms and content creation.',
status: 'pending',
priority: 'express',
initiator: { name: 'Sarah Chen', avatar: 'SC' },
currentApprover: { name: 'Mike Johnson', avatar: 'MJ' },
slaProgress: 85,
slaRemaining: '2 hours',
createdAt: '2024-10-07',
dueDate: '2024-10-09',
approvalStep: 'Awaiting Finance Approval',
department: 'Marketing'
},
{
id: 'RE-REQ-002',
title: 'IT Equipment Purchase',
description: 'Purchase of 10 new laptops for the development team including software licenses and accessories for enhanced productivity.',
status: 'in-review',
priority: 'standard',
initiator: { name: 'David Kumar', avatar: 'DK' },
currentApprover: { name: 'Lisa Wong', avatar: 'LW' },
slaProgress: 45,
slaRemaining: '1 day',
createdAt: '2024-10-06',
dueDate: '2024-10-12',
approvalStep: 'IT Department Review'
},
{
id: 'RE-REQ-003',
title: 'Vendor Contract Renewal',
description: 'Annual renewal of cleaning services contract with updated terms and pricing structure for office maintenance.',
status: 'pending',
priority: 'standard',
initiator: { name: 'John Doe', avatar: 'JD' },
currentApprover: { name: 'Anna Smith', avatar: 'AS' },
slaProgress: 90,
slaRemaining: '30 minutes',
createdAt: '2024-10-05',
dueDate: '2024-10-08',
approvalStep: 'Final Management Approval'
},
{
id: 'RE-REQ-004',
title: 'Office Space Expansion',
description: 'Lease additional office space for growing team, 2000 sq ft in the same building with modern amenities.',
status: 'in-review',
priority: 'express',
initiator: { name: 'Lisa Wong', avatar: 'LW' },
currentApprover: { name: 'David Kumar', avatar: 'DK' },
slaProgress: 30,
slaRemaining: '3 days',
createdAt: '2024-10-04',
dueDate: '2024-10-15',
approvalStep: 'Legal Review'
},
{
id: 'RE-REQ-005',
title: 'Employee Training Program',
description: 'Approval for new employee onboarding and skill development training program with external consultants.',
status: 'pending',
priority: 'standard',
initiator: { name: 'Anna Smith', avatar: 'AS' },
currentApprover: { name: 'Sarah Chen', avatar: 'SC' },
slaProgress: 60,
slaRemaining: '12 hours',
createdAt: '2024-10-03',
dueDate: '2024-10-11',
approvalStep: 'HR Approval'
}
];
// Removed static data; will load from API
// Utility functions
const getPriorityConfig = (priority: string) => {
@ -197,12 +88,53 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const [searchTerm, setSearchTerm] = useState('');
const [priorityFilter, setPriorityFilter] = useState('all');
const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>('due');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority' | 'sla'>('created');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
let mounted = true;
(async () => {
try {
setLoading(true);
const result = await workflowApi.listOpenForMe({ page: 1, limit: 50 });
const data = Array.isArray((result as any)?.data)
? (result as any).data
: Array.isArray((result as any)?.data?.data)
? (result as any).data.data
: Array.isArray(result as any)
? (result as any)
: [];
if (!mounted) return;
const mapped: Request[] = data.map((r: any) => ({
id: r.requestId || r.requestNumber,
// keep a display id for UI
displayId: r.requestNumber || r.requestId,
title: r.title,
description: r.description,
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
priority: (r.priority || '').toString().toLowerCase(),
initiator: { name: (r.initiator?.displayName || r.initiator?.email || '—'), avatar: ((r.initiator?.displayName || r.initiator?.email || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) },
currentApprover: r.currentApprover ? { name: (r.currentApprover.name || r.currentApprover.email || '—'), avatar: ((r.currentApprover.name || r.currentApprover.email || 'CA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase()) } : undefined,
slaProgress: Number(r.sla?.percent || 0),
slaRemaining: r.sla?.remainingText || '—',
createdAt: r.submittedAt || '—',
dueDate: undefined,
approvalStep: undefined,
department: r.department
}));
setItems(mapped);
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
const filteredAndSortedRequests = useMemo(() => {
let filtered = OPEN_REQUESTS.filter(request => {
let filtered = items.filter(request => {
const matchesSearch =
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
@ -224,8 +156,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
bValue = new Date(b.createdAt);
break;
case 'due':
aValue = new Date(a.dueDate);
bValue = new Date(b.dueDate);
aValue = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
bValue = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
break;
case 'priority':
const priorityOrder = { express: 2, standard: 1 };
@ -248,7 +180,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
});
return filtered;
}, [searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
const clearFilters = () => {
setSearchTerm('');
@ -262,7 +194,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
statusFilter !== 'all' ? statusFilter : null
].filter(Boolean).length;
return (
return (
<div className="space-y-6 p-6 max-w-7xl mx-auto">
{/* Enhanced Header */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
@ -280,7 +212,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<div className="flex items-center gap-3">
<Badge variant="secondary" className="text-lg px-4 py-2 bg-slate-100 text-slate-800 font-semibold">
{filteredAndSortedRequests.length} open requests
{loading ? 'Loading…' : `${filteredAndSortedRequests.length} open requests`}
</Badge>
<Button variant="outline" size="sm" className="gap-2">
<RefreshCw className="w-4 h-4" />
@ -438,7 +370,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
{request.id}
{(request as any).displayId || request.id}
</h3>
<Badge
variant="outline"

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -7,6 +7,8 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import workflowApi from '@/services/workflowApi';
import { useAuth } from '@/contexts/AuthContext';
import {
ArrowLeft,
Clock,
@ -153,9 +155,125 @@ export function RequestDetail({
dynamicRequests = []
}: RequestDetailProps) {
const [activeTab, setActiveTab] = useState('overview');
const [apiRequest, setApiRequest] = useState<any | null>(null);
const [loading, setLoading] = useState(false);
const [isSpectator, setIsSpectator] = useState(false);
const { user } = useAuth();
useEffect(() => {
let mounted = true;
(async () => {
try {
setLoading(true);
const details = await workflowApi.getWorkflowDetails(requestId);
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 : [];
const summary = details.summary || {};
// Map to UI shape without changing UI
const toInitials = (name?: string, email?: string) => {
const base = (name || email || 'NA').toString();
return base.split(' ').map(s => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase();
};
const statusMap = (s: string) => {
const val = (s || '').toUpperCase();
if (val === 'IN_PROGRESS') return 'in-review';
if (val === 'PENDING') return 'pending';
if (val === 'APPROVED') return 'approved';
if (val === 'REJECTED') return 'rejected';
return (s || '').toLowerCase();
};
const priority = (wf.priority || '').toString().toLowerCase();
const approvalFlow = approvals.map((a: any) => ({
step: a.levelNumber,
role: a.levelName || a.approverName || 'Approver',
status: statusMap(a.status),
approver: a.approverName || a.approverEmail,
tatHours: Number(a.tatHours || 0),
elapsedHours: Number(a.elapsedHours || 0),
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
}));
const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
.map((p: any) => ({
name: p.userName || p.userEmail,
role: 'Spectator',
avatar: toInitials(p.userName, p.userEmail),
}));
const participantNameById = (uid?: string) => {
if (!uid) return undefined;
const p = participants.find((x: any) => x.userId === uid);
if (p?.userName) return p.userName;
if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email;
return uid;
};
const mappedDocuments = documents.map((d: any) => {
const sizeBytes = Number(d.fileSize || 0);
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB';
return {
name: d.originalFileName || d.fileName,
size: sizeMb,
uploadedBy: participantNameById(d.uploadedBy),
uploadedAt: d.uploadedAt,
};
});
const mapped = {
id: wf.requestNumber || wf.requestId,
title: wf.title,
description: wf.description,
priority,
status: statusMap(wf.status),
slaProgress: Number(summary?.sla?.percent || 0),
slaRemaining: summary?.sla?.remainingText || '—',
slaEndDate: undefined,
initiator: {
name: wf.initiator?.displayName || wf.initiator?.email,
role: wf.initiator?.designation || undefined,
department: wf.initiator?.department || undefined,
email: wf.initiator?.email || undefined,
phone: wf.initiator?.phone || undefined,
avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email),
},
createdAt: wf.createdAt,
updatedAt: wf.updatedAt,
totalSteps: wf.totalLevels,
currentStep: summary?.currentLevel || wf.currentLevel,
approvalFlow,
documents: mappedDocuments,
spectators,
auditTrail: Array.isArray(details.activities) ? details.activities : [],
};
setApiRequest(mapped);
// Determine viewer role (spectator only means comment-only)
const viewerId = (user as any)?.userId;
if (viewerId) {
const isSpec = participants.some((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId);
setIsSpectator(isSpec);
} else {
setIsSpectator(false);
}
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, [requestId]);
// Get request from any database or dynamic requests
const request = useMemo(() => {
if (apiRequest) return apiRequest;
// First check custom request database
const customRequest = CUSTOM_REQUEST_DATABASE[requestId];
if (customRequest) return customRequest;
@ -169,7 +287,7 @@ export function RequestDetail({
if (dynamicRequest) return dynamicRequest;
return null;
}, [requestId, dynamicRequests]);
}, [requestId, dynamicRequests, apiRequest]);
if (!request) {
return (
@ -190,6 +308,10 @@ export function RequestDetail({
const statusConfig = getStatusConfig(request.status);
const slaConfig = getSLAConfig(request.slaProgress);
const triggerClass = (val: string) => (
`gap-2 ${activeTab === val ? 'bg-gray-100 border border-gray-300 text-gray-900' : ''}`
);
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto p-6">
@ -257,19 +379,19 @@ export function RequestDetail({
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-white border border-gray-300 shadow-sm mb-6">
<TabsTrigger value="overview" className="gap-2">
<TabsTrigger value="overview" className={triggerClass('overview')}>
<ClipboardList className="w-4 h-4" />
Overview
</TabsTrigger>
<TabsTrigger value="workflow" className="gap-2">
<TabsTrigger value="workflow" className={triggerClass('workflow')}>
<TrendingUp className="w-4 h-4" />
Workflow
</TabsTrigger>
<TabsTrigger value="documents" className="gap-2">
<TabsTrigger value="documents" className={triggerClass('documents')}>
<FileText className="w-4 h-4" />
Documents
</TabsTrigger>
<TabsTrigger value="activity" className="gap-2">
<TabsTrigger value="activity" className={triggerClass('activity')}>
<Activity className="w-4 h-4" />
Activity
</TabsTrigger>
@ -436,40 +558,48 @@ export function RequestDetail({
<MessageSquare className="w-4 h-4" />
Add Work Note
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900"
onClick={() => onOpenModal?.('add-approver')}
>
<UserPlus className="w-4 h-4" />
Add Approver
</Button>
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900"
onClick={() => onOpenModal?.('add-spectator')}
>
<Eye className="w-4 h-4" />
Add Spectator
</Button>
{!isSpectator && (
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900"
onClick={() => onOpenModal?.('add-approver')}
>
<UserPlus className="w-4 h-4" />
Add Approver
</Button>
)}
{!isSpectator && (
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900"
onClick={() => onOpenModal?.('add-spectator')}
>
<Eye className="w-4 h-4" />
Add Spectator
</Button>
)}
<div className="pt-4 space-y-2">
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
onClick={() => onOpenModal?.('approve')}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Request
</Button>
<Button
variant="destructive"
className="w-full"
onClick={() => onOpenModal?.('reject')}
>
<XCircle className="w-4 h-4 mr-2" />
Reject Request
</Button>
{!isSpectator && (
<>
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white"
onClick={() => onOpenModal?.('approve')}
>
<CheckCircle className="w-4 h-4 mr-2" />
Approve Request
</Button>
<Button
variant="destructive"
className="w-full"
onClick={() => onOpenModal?.('reject')}
>
<XCircle className="w-4 h-4 mr-2" />
Reject Request
</Button>
</>
)}
</div>
</CardContent>
</Card>

View File

@ -0,0 +1,45 @@
import apiClient from './authApi';
export type DocumentCategory = 'SUPPORTING' | 'APPROVAL' | 'REFERENCE' | 'FINAL' | 'OTHER';
export interface UploadResponse {
documentId: string;
storageUrl?: string;
fileName: string;
originalFileName: string;
}
export async function uploadDocument(
file: File,
requestId: string,
category: DocumentCategory = 'SUPPORTING'
): Promise<UploadResponse> {
const formData = new FormData();
formData.append('file', file);
formData.append('requestId', requestId);
formData.append('category', category);
const res = await apiClient.post('/documents', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = res.data?.data || res.data;
return {
documentId: data?.documentId || data?.document_id || '',
storageUrl: data?.storageUrl || data?.storage_url,
fileName: data?.fileName || data?.file_name || file.name,
originalFileName: data?.originalFileName || data?.original_file_name || file.name,
};
}
export async function uploadMany(
files: File[],
requestId: string,
category: DocumentCategory = 'SUPPORTING'
): Promise<UploadResponse[]> {
const tasks = files.map(f => uploadDocument(f, requestId, category));
return Promise.all(tasks);
}
export default { uploadDocument, uploadMany };

22
src/services/userApi.ts Normal file
View File

@ -0,0 +1,22 @@
import apiClient from './authApi';
export interface UserSummary {
userId: string;
email: string;
displayName?: string;
firstName?: string;
lastName?: string;
department?: string;
designation?: string;
isActive?: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSummary[]> {
const res = await apiClient.get('/users/search', { params: { q: query, limit } });
const data = (res.data?.data || res.data) as any[];
return data as UserSummary[];
}
export default { searchUsers };

204
src/services/workflowApi.ts Normal file
View File

@ -0,0 +1,204 @@
import apiClient from './authApi';
export type PriorityUi = 'standard' | 'express';
export interface ApproverFormItem {
email: string;
name?: string;
tat?: number | '';
tatType?: 'hours' | 'days';
}
export interface ParticipantItem {
id?: string;
name: string;
email: string;
}
export interface CreateWorkflowFromFormPayload {
templateId?: string | null;
templateType: 'CUSTOM' | 'TEMPLATE';
title: string;
description: string;
priorityUi: PriorityUi;
approverCount: number;
approvers: ApproverFormItem[];
spectators?: ParticipantItem[];
ccList?: ParticipantItem[];
}
// Utility to generate a RFC4122 v4 UUID (fallback if crypto.randomUUID not available)
function generateUuid(): string {
if (typeof crypto !== 'undefined' && (crypto as any).randomUUID) {
return (crypto as any).randomUUID();
}
// Fallback
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
export interface CreateWorkflowResponse {
id: string;
}
export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload): Promise<CreateWorkflowResponse> {
// Map UI priority to API enum
const priority = form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD';
// Build approval levels to match backend schema
const approvalLevels = Array.from({ length: form.approverCount || 1 }, (_, i) => {
const idx = i;
const a = form.approvers[idx] || {} as ApproverFormItem;
const levelNumber = idx + 1;
const tatRaw = a.tat ?? '';
let tatHours = 0;
if (typeof tatRaw === 'number') {
tatHours = a.tatType === 'days' ? tatRaw * 24 : tatRaw;
}
const approverEmail = a.email || '';
const approverName = (a.name && a.name.trim()) || approverEmail.split('@')[0] || `Approver ${levelNumber}`;
return {
levelNumber,
levelName: `Level ${levelNumber}`,
approverId: generateUuid(),
approverEmail,
approverName,
tatHours: tatHours > 0 ? tatHours : 24,
isFinalApprover: levelNumber === (form.approverCount || 1),
};
});
// Participants -> spectators and ccList
const participants = [
...(form.spectators || []).map(p => ({
userId: generateUuid(),
userEmail: p.email,
userName: p.name || p.email.split('@')[0] || 'Spectator',
participantType: 'SPECTATOR' as const,
canComment: true,
canViewDocuments: true,
canDownloadDocuments: false,
notificationEnabled: true,
})),
...(form.ccList || []).map(p => ({
userId: generateUuid(),
userEmail: p.email,
userName: p.name || p.email.split('@')[0] || 'CC',
participantType: 'CONSULTATION' as const,
canComment: false,
canViewDocuments: true,
canDownloadDocuments: false,
notificationEnabled: true,
})),
];
const payload = {
templateType: form.templateType,
title: form.title,
description: form.description,
priority, // STANDARD | EXPRESS
approvalLevels,
participants: participants.length ? participants : undefined,
};
const res = await apiClient.post('/workflows', payload);
const data = (res.data?.data || res.data) as any;
return { id: data.id || data.workflowId || '' };
}
export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayload, files: File[], category: 'SUPPORTING' | 'APPROVAL' | 'REFERENCE' | 'FINAL' | 'OTHER' = 'SUPPORTING') {
const isUuid = (v: any) => typeof v === 'string' && /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/.test(v.trim());
const payload: any = {
templateType: form.templateType,
title: form.title,
description: form.description,
priority: form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
approvalLevels: Array.from({ length: form.approverCount || 1 }, (_, i) => {
const a = form.approvers[i] || ({} as any);
const tat = typeof a.tat === 'number' ? a.tat : 0;
const approverId = (a.userId || '').trim();
if (!isUuid(approverId)) {
throw new Error(`Invalid approverId for level ${i + 1}. Please pick an approver via @ search.`);
}
return {
levelNumber: i + 1,
levelName: `Level ${i + 1}`,
approverId,
approverEmail: a.email || '',
approverName: a.name || (a.email ? a.email.split('@')[0] : `Approver ${i + 1}`),
tatHours: a.tatType === 'days' ? tat * 24 : tat || 24,
isFinalApprover: i + 1 === (form.approverCount || 1),
};
}),
};
// Pass participants if provided by caller (CreateRequest builds this)
const incomingParticipants = (form as any).participants;
if (Array.isArray(incomingParticipants) && incomingParticipants.length) {
payload.participants = incomingParticipants;
}
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('category', category);
files.forEach(f => formData.append('files', f));
const res = await apiClient.post('/workflows/multipart', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
const data = res.data?.data || res.data;
return { id: data?.requestId } as any;
}
export async function listWorkflows(params: { page?: number; limit?: number } = {}) {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/workflows', { params: { page, limit } });
return res.data?.data || res.data;
}
export async function listMyWorkflows(params: { page?: number; limit?: number } = {}) {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/workflows/my', { params: { page, limit } });
return res.data?.data || res.data;
}
export async function listOpenForMe(params: { page?: number; limit?: number } = {}) {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit } });
return res.data?.data || res.data;
}
export async function listClosedByMe(params: { page?: number; limit?: number } = {}) {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit } });
return res.data?.data || res.data;
}
export async function getWorkflowDetails(requestId: string) {
const res = await apiClient.get(`/workflows/${requestId}/details`);
return res.data?.data || res.data;
}
export default {
createWorkflowFromForm,
createWorkflowMultipart,
listWorkflows,
listMyWorkflows,
listOpenForMe,
listClosedByMe,
submitWorkflow,
getWorkflowDetails,
};
export async function submitWorkflow(requestId: string) {
const res = await apiClient.patch(`/workflows/${requestId}/submit`);
return res.data?.data || res.data;
}
// Also export in default for convenience
// Note: keeping separate named export above for tree-shaking