create request flow created
This commit is contained in:
parent
02b009194c
commit
f9c8364b61
@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import {
|
import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, XCircle } from 'lucide-react';
|
||||||
Calendar,
|
import workflowApi from '@/services/workflowApi';
|
||||||
Filter,
|
|
||||||
Search,
|
|
||||||
FileText,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle,
|
|
||||||
ArrowRight,
|
|
||||||
SortAsc,
|
|
||||||
SortDesc,
|
|
||||||
Flame,
|
|
||||||
Target,
|
|
||||||
RefreshCw,
|
|
||||||
Settings2,
|
|
||||||
X,
|
|
||||||
XCircle
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
id: string;
|
id: string;
|
||||||
@ -29,12 +14,9 @@ interface Request {
|
|||||||
description: string;
|
description: string;
|
||||||
status: 'approved' | 'rejected';
|
status: 'approved' | 'rejected';
|
||||||
priority: 'express' | 'standard';
|
priority: 'express' | 'standard';
|
||||||
initiator: {
|
initiator: { name: string; avatar: string };
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
dueDate: string;
|
dueDate?: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
}
|
}
|
||||||
@ -43,66 +25,7 @@ interface ClosedRequestsProps {
|
|||||||
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static data for closed requests
|
// Removed static data; will load from API
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
@ -158,9 +81,48 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
|
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
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(() => {
|
const filteredAndSortedRequests = useMemo(() => {
|
||||||
let filtered = CLOSED_REQUESTS.filter(request => {
|
let filtered = items.filter(request => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@ -202,7 +164,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
@ -234,7 +196,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<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">
|
<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>
|
</Badge>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<RefreshCw className="w-4 h-4" />
|
<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-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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">
|
<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>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -38,6 +40,8 @@ import {
|
|||||||
Settings
|
Settings
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import documentApi from '@/services/documentApi';
|
||||||
|
|
||||||
interface CreateRequestProps {
|
interface CreateRequestProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@ -130,6 +134,7 @@ const STEP_NAMES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<RequestTemplate | null>(null);
|
||||||
const [emailInput, setEmailInput] = useState('');
|
const [emailInput, setEmailInput] = useState('');
|
||||||
@ -142,6 +147,17 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
|||||||
level: 1
|
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({
|
const [formData, setFormData] = useState({
|
||||||
// Template and basic info
|
// Template and basic info
|
||||||
template: '',
|
template: '',
|
||||||
@ -356,16 +372,149 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (isStepValid()) {
|
if (!isStepValid()) return;
|
||||||
const requestData = {
|
|
||||||
...formData,
|
// Participants mapping
|
||||||
template: selectedTemplate,
|
const initiatorId = user?.userId || '';
|
||||||
id: `RE-REQ-${Date.now()}`,
|
const initiatorEmail = (user as any)?.email || '';
|
||||||
createdAt: new Date().toISOString(),
|
const initiatorName = (user as any)?.displayName || (user as any)?.name || initiatorEmail.split('@')[0] || 'Initiator';
|
||||||
status: 'pending'
|
|
||||||
};
|
const participants = [
|
||||||
onSubmit?.(requestData);
|
{
|
||||||
|
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 = () => {
|
const renderStepContent = () => {
|
||||||
@ -851,22 +1000,87 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
|||||||
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
|
||||||
Email Address *
|
Email Address *
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<div className="relative">
|
||||||
|
<Input
|
||||||
id={`approver-${level}`}
|
id={`approver-${level}`}
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="approver@royalenfield.com"
|
placeholder="approver@royalenfield.com"
|
||||||
value={formData.approvers[index]?.email || ''}
|
value={formData.approvers[index]?.email || ''}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newApprovers = [...formData.approvers];
|
const value = e.target.value;
|
||||||
newApprovers[index] = {
|
const newApprovers = [...formData.approvers];
|
||||||
...newApprovers[index],
|
newApprovers[index] = {
|
||||||
email: e.target.value,
|
...newApprovers[index],
|
||||||
level: level
|
email: value,
|
||||||
};
|
level: level
|
||||||
updateFormData('approvers', newApprovers);
|
};
|
||||||
}}
|
updateFormData('approvers', newApprovers);
|
||||||
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1"
|
|
||||||
/>
|
// 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">
|
<p className="text-xs text-gray-500 mt-1 flex items-center gap-1">
|
||||||
<span className="font-medium">@</span>
|
<span className="font-medium">@</span>
|
||||||
Use @ sign to tag a user
|
Use @ sign to tag a user
|
||||||
@ -1141,17 +1355,71 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
|||||||
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<div className="relative w-full">
|
||||||
placeholder="Use @ sign to add a user"
|
<Input
|
||||||
value={emailInput}
|
placeholder="Use @ sign to add a user"
|
||||||
onChange={(e) => setEmailInput(e.target.value)}
|
value={emailInput}
|
||||||
onKeyPress={(e) => {
|
onChange={(e) => {
|
||||||
if (e.key === 'Enter' && validateEmail(emailInput)) {
|
const value = e.target.value;
|
||||||
inviteAndAddUser('spectators');
|
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);
|
||||||
className="text-sm"
|
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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => inviteAndAddUser('spectators')}
|
onClick={() => inviteAndAddUser('spectators')}
|
||||||
@ -1236,13 +1504,12 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
|||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
|
ref={fileInputRef}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="file-upload" className="cursor-pointer">
|
<Button variant="outline" size="lg" type="button" onClick={() => fileInputRef.current?.click()}>
|
||||||
<Button variant="outline" size="lg" type="button">
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
Browse Files
|
||||||
Browse Files
|
</Button>
|
||||||
</Button>
|
|
||||||
</Label>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Supported formats: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, PPT, PPTX
|
Supported formats: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, PPT, PPTX
|
||||||
</p>
|
</p>
|
||||||
@ -1760,7 +2027,7 @@ export function CreateRequest({ onBack, onSubmit }: CreateRequestProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Button variant="outline" onClick={onBack} size="lg">
|
<Button variant="outline" onClick={handleSaveDraft} size="lg">
|
||||||
Save Draft
|
Save Draft
|
||||||
</Button>
|
</Button>
|
||||||
{currentStep === totalSteps ? (
|
{currentStep === totalSteps ? (
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -19,86 +19,14 @@ import {
|
|||||||
Target
|
Target
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
|
import workflowApi from '@/services/workflowApi';
|
||||||
|
|
||||||
interface MyRequestsProps {
|
interface MyRequestsProps {
|
||||||
onViewRequest: (requestId: string, requestTitle?: string) => void;
|
onViewRequest: (requestId: string, requestTitle?: string) => void;
|
||||||
dynamicRequests?: any[];
|
dynamicRequests?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock data for user's requests
|
// Removed mock data; list renders API data only
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
@ -169,25 +97,48 @@ const getStatusConfig = (status: string) => {
|
|||||||
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
// Convert dynamic requests to the format expected by this component
|
useEffect(() => {
|
||||||
const convertedDynamicRequests = dynamicRequests.map(req => ({
|
let mounted = true;
|
||||||
id: req.id,
|
(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 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,
|
title: req.title,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
status: req.status,
|
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'),
|
||||||
priority: req.priority,
|
priority: (req.priority || '').toString().toLowerCase(),
|
||||||
department: req.department,
|
department: req.department,
|
||||||
submittedDate: new Date(req.createdAt).toISOString().split('T')[0],
|
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
||||||
currentApprover: req.approvalFlow?.[0]?.approver || 'Current User (Initiator)',
|
currentApprover: req.currentApprover?.name || req.currentApprover?.email || '—',
|
||||||
approverLevel: `${req.currentStep} of ${req.totalSteps}`,
|
approverLevel: req.currentLevel && req.totalLevels ? `${req.currentLevel} of ${req.totalLevels}` : (req.currentStep && req.totalSteps ? `${req.currentStep} of ${req.totalSteps}` : '—'),
|
||||||
dueDate: new Date(req.dueDate).toISOString().split('T')[0],
|
dueDate: req.dueDate ? new Date(req.dueDate).toISOString().split('T')[0] : undefined,
|
||||||
templateType: req.templateType,
|
templateType: req.templateType,
|
||||||
templateName: req.templateName
|
templateName: req.templateName
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Merge static mock data with dynamic requests (dynamic requests first)
|
// Use only API/dynamic requests
|
||||||
const allRequests = [...convertedDynamicRequests, ...MY_REQUESTS_DATA];
|
const allRequests = convertedDynamicRequests;
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
|
|
||||||
// Filter requests based on search and filters
|
// Filter requests based on search and filters
|
||||||
@ -337,7 +288,11 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
<div className="space-y-4">
|
<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>
|
<Card>
|
||||||
<CardContent className="p-12 text-center">
|
<CardContent className="p-12 text-center">
|
||||||
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
<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}
|
{request.description}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-4 text-sm text-gray-500">
|
<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>
|
<span><span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import {
|
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react';
|
||||||
Calendar,
|
import workflowApi from '@/services/workflowApi';
|
||||||
Clock,
|
|
||||||
Filter,
|
|
||||||
Search,
|
|
||||||
FileText,
|
|
||||||
AlertCircle,
|
|
||||||
ArrowRight,
|
|
||||||
SortAsc,
|
|
||||||
SortDesc,
|
|
||||||
Flame,
|
|
||||||
Target,
|
|
||||||
Eye,
|
|
||||||
RefreshCw,
|
|
||||||
Settings2,
|
|
||||||
X
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface Request {
|
interface Request {
|
||||||
id: string;
|
id: string;
|
||||||
@ -30,19 +15,13 @@ interface Request {
|
|||||||
description: string;
|
description: string;
|
||||||
status: 'pending' | 'in-review';
|
status: 'pending' | 'in-review';
|
||||||
priority: 'express' | 'standard';
|
priority: 'express' | 'standard';
|
||||||
initiator: {
|
initiator: { name: string; avatar: string };
|
||||||
name: string;
|
currentApprover?: { name: string; avatar: string };
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
currentApprover?: {
|
|
||||||
name: string;
|
|
||||||
avatar: string;
|
|
||||||
};
|
|
||||||
slaProgress: number;
|
slaProgress: number;
|
||||||
slaRemaining: string;
|
slaRemaining: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
dueDate: string;
|
dueDate?: string;
|
||||||
approvalStep: string;
|
approvalStep?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -50,95 +29,7 @@ interface OpenRequestsProps {
|
|||||||
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
onViewRequest?: (requestId: string, requestTitle?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Static data for open requests
|
// Removed static data; will load from API
|
||||||
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'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
const getPriorityConfig = (priority: string) => {
|
const getPriorityConfig = (priority: string) => {
|
||||||
@ -197,12 +88,53 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
const [statusFilter, setStatusFilter] = 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 [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
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(() => {
|
const filteredAndSortedRequests = useMemo(() => {
|
||||||
let filtered = OPEN_REQUESTS.filter(request => {
|
let filtered = items.filter(request => {
|
||||||
const matchesSearch =
|
const matchesSearch =
|
||||||
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@ -224,8 +156,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
bValue = new Date(b.createdAt);
|
bValue = new Date(b.createdAt);
|
||||||
break;
|
break;
|
||||||
case 'due':
|
case 'due':
|
||||||
aValue = new Date(a.dueDate);
|
aValue = a.dueDate ? new Date(a.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
bValue = new Date(b.dueDate);
|
bValue = b.dueDate ? new Date(b.dueDate).getTime() : Number.MAX_SAFE_INTEGER;
|
||||||
break;
|
break;
|
||||||
case 'priority':
|
case 'priority':
|
||||||
const priorityOrder = { express: 2, standard: 1 };
|
const priorityOrder = { express: 2, standard: 1 };
|
||||||
@ -248,7 +180,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
@ -262,7 +194,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
statusFilter !== 'all' ? statusFilter : null
|
statusFilter !== 'all' ? statusFilter : null
|
||||||
].filter(Boolean).length;
|
].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 p-6 max-w-7xl mx-auto">
|
<div className="space-y-6 p-6 max-w-7xl mx-auto">
|
||||||
{/* Enhanced Header */}
|
{/* Enhanced Header */}
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-6">
|
<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">
|
<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">
|
<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>
|
</Badge>
|
||||||
<Button variant="outline" size="sm" className="gap-2">
|
<Button variant="outline" size="sm" className="gap-2">
|
||||||
<RefreshCw className="w-4 h-4" />
|
<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-1 min-w-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<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">
|
<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>
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@ -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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
|
import workflowApi from '@/services/workflowApi';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
@ -153,9 +155,125 @@ export function RequestDetail({
|
|||||||
dynamicRequests = []
|
dynamicRequests = []
|
||||||
}: RequestDetailProps) {
|
}: RequestDetailProps) {
|
||||||
const [activeTab, setActiveTab] = useState('overview');
|
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
|
// Get request from any database or dynamic requests
|
||||||
const request = useMemo(() => {
|
const request = useMemo(() => {
|
||||||
|
if (apiRequest) return apiRequest;
|
||||||
// First check custom request database
|
// First check custom request database
|
||||||
const customRequest = CUSTOM_REQUEST_DATABASE[requestId];
|
const customRequest = CUSTOM_REQUEST_DATABASE[requestId];
|
||||||
if (customRequest) return customRequest;
|
if (customRequest) return customRequest;
|
||||||
@ -169,7 +287,7 @@ export function RequestDetail({
|
|||||||
if (dynamicRequest) return dynamicRequest;
|
if (dynamicRequest) return dynamicRequest;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}, [requestId, dynamicRequests]);
|
}, [requestId, dynamicRequests, apiRequest]);
|
||||||
|
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return (
|
return (
|
||||||
@ -190,6 +308,10 @@ export function RequestDetail({
|
|||||||
const statusConfig = getStatusConfig(request.status);
|
const statusConfig = getStatusConfig(request.status);
|
||||||
const slaConfig = getSLAConfig(request.slaProgress);
|
const slaConfig = getSLAConfig(request.slaProgress);
|
||||||
|
|
||||||
|
const triggerClass = (val: string) => (
|
||||||
|
`gap-2 ${activeTab === val ? 'bg-gray-100 border border-gray-300 text-gray-900' : ''}`
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<div className="max-w-7xl mx-auto p-6">
|
<div className="max-w-7xl mx-auto p-6">
|
||||||
@ -257,19 +379,19 @@ export function RequestDetail({
|
|||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="bg-white border border-gray-300 shadow-sm mb-6">
|
<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" />
|
<ClipboardList className="w-4 h-4" />
|
||||||
Overview
|
Overview
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="workflow" className="gap-2">
|
<TabsTrigger value="workflow" className={triggerClass('workflow')}>
|
||||||
<TrendingUp className="w-4 h-4" />
|
<TrendingUp className="w-4 h-4" />
|
||||||
Workflow
|
Workflow
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="documents" className="gap-2">
|
<TabsTrigger value="documents" className={triggerClass('documents')}>
|
||||||
<FileText className="w-4 h-4" />
|
<FileText className="w-4 h-4" />
|
||||||
Documents
|
Documents
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="activity" className="gap-2">
|
<TabsTrigger value="activity" className={triggerClass('activity')}>
|
||||||
<Activity className="w-4 h-4" />
|
<Activity className="w-4 h-4" />
|
||||||
Activity
|
Activity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
@ -436,40 +558,48 @@ export function RequestDetail({
|
|||||||
<MessageSquare className="w-4 h-4" />
|
<MessageSquare className="w-4 h-4" />
|
||||||
Add Work Note
|
Add Work Note
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{!isSpectator && (
|
||||||
variant="outline"
|
<Button
|
||||||
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900"
|
variant="outline"
|
||||||
onClick={() => onOpenModal?.('add-approver')}
|
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
|
<UserPlus className="w-4 h-4" />
|
||||||
</Button>
|
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"
|
{!isSpectator && (
|
||||||
onClick={() => onOpenModal?.('add-spectator')}
|
<Button
|
||||||
>
|
variant="outline"
|
||||||
<Eye className="w-4 h-4" />
|
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900"
|
||||||
Add Spectator
|
onClick={() => onOpenModal?.('add-spectator')}
|
||||||
</Button>
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
Add Spectator
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
<div className="pt-4 space-y-2">
|
<div className="pt-4 space-y-2">
|
||||||
<Button
|
{!isSpectator && (
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
<>
|
||||||
onClick={() => onOpenModal?.('approve')}
|
<Button
|
||||||
>
|
className="w-full bg-green-600 hover:bg-green-700 text-white"
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
onClick={() => onOpenModal?.('approve')}
|
||||||
Approve Request
|
>
|
||||||
</Button>
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
<Button
|
Approve Request
|
||||||
variant="destructive"
|
</Button>
|
||||||
className="w-full"
|
<Button
|
||||||
onClick={() => onOpenModal?.('reject')}
|
variant="destructive"
|
||||||
>
|
className="w-full"
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
onClick={() => onOpenModal?.('reject')}
|
||||||
Reject Request
|
>
|
||||||
</Button>
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
Reject Request
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
45
src/services/documentApi.ts
Normal file
45
src/services/documentApi.ts
Normal 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
22
src/services/userApi.ts
Normal 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
204
src/services/workflowApi.ts
Normal 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
|
||||||
|
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user