Compare commits

..

2 Commits

7 changed files with 260 additions and 131 deletions

View File

@ -0,0 +1,114 @@
import { useState } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2, Play } from 'lucide-react';
import { toast } from 'sonner';
import { resumeWorkflow } from '@/services/workflowApi';
interface ResumeModalProps {
isOpen: boolean;
onClose: () => void;
requestId: string;
onSuccess?: () => void | Promise<void>;
}
export function ResumeModal({ isOpen, onClose, requestId, onSuccess }: ResumeModalProps) {
const [notes, setNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
try {
setSubmitting(true);
await resumeWorkflow(requestId, notes.trim() || undefined);
toast.success('Workflow resumed successfully');
// Wait for parent to refresh data before closing modal
if (onSuccess) {
await onSuccess();
}
setNotes('');
onClose();
} catch (error: any) {
console.error('Failed to resume workflow:', error);
toast.error(error?.response?.data?.error || error?.response?.data?.message || 'Failed to resume workflow');
} finally {
setSubmitting(false);
}
};
const handleClose = () => {
if (!submitting) {
setNotes('');
onClose();
}
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Play className="w-5 h-5 text-green-600" />
Resume Workflow
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
<p className="text-sm text-green-800">
<strong>Note:</strong> Resuming will restart TAT calculations and notifications. The workflow will continue from where it was paused.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="resume-notes" className="text-sm font-medium">
Notes (Optional)
</Label>
<Textarea
id="resume-notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any notes about why you're resuming this workflow..."
className="min-h-[100px] text-sm"
disabled={submitting}
/>
<p className="text-xs text-gray-500">
{notes.length} / 1000 characters
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={handleClose}
disabled={submitting}
>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting}
className="bg-green-600 hover:bg-green-700 text-white"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Resuming...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
Resume Workflow
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,7 +1,8 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo, useCallback } from 'react';
import workflowApi from '@/services/workflowApi'; import workflowApi from '@/services/workflowApi';
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 { getSocket } from '@/utils/socket';
/** /**
* Custom Hook: useRequestDetails * Custom Hook: useRequestDetails
@ -81,8 +82,10 @@ export function useRequestDetails(
* 5. Filter out TAT warning activities from audit trail * 5. Filter out TAT warning activities from audit trail
* 6. Update all state with transformed data * 6. Update all state with transformed data
* 7. Determine current user's approval level and spectator status * 7. Determine current user's approval level and spectator status
*
* Note: Wrapped in useCallback to allow use in Socket.io listeners
*/ */
const refreshDetails = async () => { const refreshDetails = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
try { try {
// API Call: Fetch complete workflow details including approvals, documents, participants // API Call: Fetch complete workflow details including approvals, documents, participants
@ -309,7 +312,7 @@ export function useRequestDetails(
} finally { } finally {
setRefreshing(false); setRefreshing(false);
} }
}; }, [requestIdentifier, user]); // useCallback dependencies
/** /**
* Effect: Initial data fetch when component mounts or requestIdentifier changes * Effect: Initial data fetch when component mounts or requestIdentifier changes
@ -620,6 +623,47 @@ export function useRequestDetails(
return participants; return participants;
}, [request]); }, [request]);
/**
* Effect: Listen for real-time request updates via Socket.io
*
* Purpose: Auto-refresh request details when other users take actions
*
* Listens for:
* - 'request:updated' - Any action that changes the request (approve, reject, pause, resume, skip, etc.)
*
* Behavior:
* - Silently refreshes data in background
* - Doesn't interrupt user actions
* - Updates all tabs with latest data
*/
useEffect(() => {
if (!requestIdentifier || !apiRequest) return;
const socket = getSocket();
if (!socket) return;
/**
* Handler: Request updated by another user
* Silently refresh to show latest changes
*/
const handleRequestUpdated = (data: any) => {
// Verify this update is for the current request
if (data?.requestId === apiRequest.requestId || data?.requestNumber === requestIdentifier) {
console.log('[useRequestDetails] 🔄 Request updated remotely, refreshing silently...');
// Silent refresh - no loading state, no user interruption
refreshDetails();
}
};
// Register listener
socket.on('request:updated', handleRequestUpdated);
// Cleanup on unmount
return () => {
socket.off('request:updated', handleRequestUpdated);
};
}, [requestIdentifier, apiRequest, refreshDetails]);
return { return {
request, request,
apiRequest, apiRequest,

View File

@ -1,5 +1,6 @@
/** /**
* Utility functions for building API payloads * Utility functions for building API payloads
* Simplified: Backend auto-generates participants from approvers and spectators
*/ */
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm'; import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
@ -7,29 +8,23 @@ import {
CreateWorkflowPayload, CreateWorkflowPayload,
UpdateWorkflowPayload, UpdateWorkflowPayload,
} from '../types/createRequest.types'; } from '../types/createRequest.types';
import {
buildParticipantsArray,
filterDuplicateParticipants,
} from './participantMappers';
import { buildApprovalLevels } from './approvalLevelBuilders'; import { buildApprovalLevels } from './approvalLevelBuilders';
/** /**
* Build create workflow payload * Build create workflow payload
* Backend will auto-generate participants array from approvers and spectators
*/ */
export function buildCreatePayload( export function buildCreatePayload(
formData: FormData, formData: FormData,
selectedTemplate: RequestTemplate | null, selectedTemplate: RequestTemplate | null,
user: any user: any
): CreateWorkflowPayload { ): CreateWorkflowPayload {
const participants = buildParticipantsArray( // Filter out spectators who are also approvers (backend will handle validation)
user, const approverEmails = new Set(
formData.approvers || [], (formData.approvers || []).map((a: any) => a?.email?.toLowerCase()).filter(Boolean)
formData.spectators || []
); );
const filteredSpectators = (formData.spectators || []).filter(
const filteredSpectators = filterDuplicateParticipants( (s: any) => s?.email && !approverEmails.has(s.email.toLowerCase())
formData.approvers || [],
formData.spectators || []
); );
return { return {
@ -47,21 +42,16 @@ export function buildCreatePayload(
tatType: a?.tatType || 'hours', tatType: a?.tatType || 'hours',
})), })),
spectators: filteredSpectators.map((s) => ({ spectators: filteredSpectators.map((s) => ({
userId: s?.id || '',
name: s?.name || s?.email?.split('@')?.[0] || 'Spectator',
email: s?.email || '', email: s?.email || '',
})), })),
ccList: (formData.ccList || []).map((c: any) => ({ // Note: participants array is auto-generated by backend
id: c?.id, // No need to send it from frontend
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
email: c?.email || '',
})),
participants,
}; };
} }
/** /**
* Build update workflow payload * Build update workflow payload
* Backend will auto-generate participants array from approvalLevels
*/ */
export function buildUpdatePayload( export function buildUpdatePayload(
formData: FormData, formData: FormData,
@ -73,24 +63,19 @@ export function buildUpdatePayload(
formData.approverCount || 1 formData.approverCount || 1
); );
const participants = buildParticipantsArray(
user,
(formData.approvers || []).filter((a: any) => a?.email && a?.userId),
(formData.spectators || []).filter((s: any) => s?.email)
);
return { return {
title: formData.title, title: formData.title,
description: formData.description, description: formData.description,
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD', priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
approvalLevels, approvalLevels,
participants, // Note: participants array is auto-generated by backend
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined, deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
}; };
} }
/** /**
* Validate approvers before submission * Validate approvers before submission
* Simplified: Only check for valid emails (backend handles user lookup)
*/ */
export function validateApproversForSubmission( export function validateApproversForSubmission(
approvers: any[], approvers: any[],
@ -98,14 +83,28 @@ export function validateApproversForSubmission(
): { valid: boolean; message?: string } { ): { valid: boolean; message?: string } {
const approversToCheck = approvers.slice(0, approverCount); const approversToCheck = approvers.slice(0, approverCount);
const hasMissingIds = approversToCheck.some( // Check if all approvers have valid emails
(a: any) => !a?.userId || !a?.email const hasMissingEmails = approversToCheck.some(
(a: any) => !a?.email || !a.email.trim()
); );
if (hasMissingIds) { if (hasMissingEmails) {
return { return {
valid: false, valid: false,
message: 'Please select approvers using @ search so we can capture their user IDs.', message: 'Please provide email addresses for all approvers.',
};
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const hasInvalidEmails = approversToCheck.some(
(a: any) => !emailRegex.test(a?.email || '')
);
if (hasInvalidEmails) {
return {
valid: false,
message: 'Please provide valid email addresses for all approvers.',
}; };
} }

View File

@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
// Components // Components
import { RequestDetailHeader } from './components/RequestDetailHeader'; import { RequestDetailHeader } from './components/RequestDetailHeader';
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal'; import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
import { createSummary, getSummaryDetails, type SummaryDetails } from '@/services/summaryApi'; import { getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { OverviewTab } from './components/tabs/OverviewTab'; import { OverviewTab } from './components/tabs/OverviewTab';
import { WorkflowTab } from './components/tabs/WorkflowTab'; import { WorkflowTab } from './components/tabs/WorkflowTab';
@ -53,8 +53,8 @@ import { QuickActionsSidebar } from './components/QuickActionsSidebar';
import { RequestDetailModals } from './components/RequestDetailModals'; import { RequestDetailModals } from './components/RequestDetailModals';
import { RequestDetailProps } from './types/requestDetail.types'; import { RequestDetailProps } from './types/requestDetail.types';
import { PauseModal } from '@/components/workflow/PauseModal'; import { PauseModal } from '@/components/workflow/PauseModal';
import { ResumeModal } from '@/components/workflow/ResumeModal';
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal'; import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
import { resumeWorkflow } from '@/services/workflowApi';
/** /**
* Error Boundary Component * Error Boundary Component
@ -112,6 +112,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
const [loadingSummary, setLoadingSummary] = useState(false); const [loadingSummary, setLoadingSummary] = useState(false);
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0); const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
const [showPauseModal, setShowPauseModal] = useState(false); const [showPauseModal, setShowPauseModal] = useState(false);
const [showResumeModal, setShowResumeModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false); const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
@ -198,24 +199,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
setShowPauseModal(true); setShowPauseModal(true);
}; };
const [resuming, setResuming] = useState(false); const handleResume = () => {
setShowResumeModal(true);
};
const handleResume = async () => { const handleResumeSuccess = async () => {
if (!apiRequest?.requestId) { // Wait for refresh to complete to show updated status
toast.error('Request ID not found'); await refreshDetails();
return;
}
try {
setResuming(true);
await resumeWorkflow(apiRequest.requestId);
toast.success('Workflow resumed successfully');
// Wait for refresh to complete before clearing loading state
await refreshDetails();
} catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to resume workflow');
} finally {
setResuming(false);
}
}; };
const handleRetrigger = () => { const handleRetrigger = () => {
@ -238,31 +228,14 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
return; return;
} }
try { if (!summaryId) {
// Get or create summary (backend returns existing summary if it exists - idempotent) toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
// Summary should already exist from closure, but create if missing (handles edge cases) return;
let currentSummaryId = summaryId;
if (!currentSummaryId) {
const summary = await createSummary(apiRequest.requestId);
currentSummaryId = summary.summaryId;
setSummaryId(currentSummaryId);
// Refresh summary details after creating
try {
const details = await getSummaryDetails(currentSummaryId);
setSummaryDetails(details);
} catch (error) {
console.error('Failed to fetch summary details after creation:', error);
}
}
// Open share modal with the summary ID (only after we have the ID)
if (currentSummaryId) {
setShowShareSummaryModal(true);
}
} catch (error: any) {
console.error('Failed to create/get summary:', error);
const errorMessage = error?.response?.data?.error || error?.response?.data?.message || error?.message;
toast.error(errorMessage || 'Failed to prepare summary for sharing');
} }
// Open share modal with the existing summary ID
// Summary should already exist from closure (auto-created by backend)
setShowShareSummaryModal(true);
}; };
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator; const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
@ -271,7 +244,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator); const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
// Fetch summary details if request is closed // Fetch summary details if request is closed
// Summary should be automatically created when request is closed, but we'll create it if missing (idempotent) // Summary is automatically created by backend when request is closed (on final approval)
useEffect(() => { useEffect(() => {
const fetchSummaryDetails = async () => { const fetchSummaryDetails = async () => {
if (!isClosed || !apiRequest?.requestId) { if (!isClosed || !apiRequest?.requestId) {
@ -282,9 +255,11 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
try { try {
setLoadingSummary(true); setLoadingSummary(true);
// Use createSummary which is idempotent - returns existing summary if it exists, creates if missing // Just fetch the summary by requestId - don't try to create it
// This handles cases where summary creation failed during closure or was not created yet // Summary is auto-created by backend on final approval/rejection
const summary = await createSummary(apiRequest.requestId); const { getSummaryByRequestId } = await import('@/services/summaryApi');
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) { if (summary?.summaryId) {
setSummaryId(summary.summaryId); setSummaryId(summary.summaryId);
// Fetch full summary details // Fetch full summary details
@ -292,18 +267,17 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
const details = await getSummaryDetails(summary.summaryId); const details = await getSummaryDetails(summary.summaryId);
setSummaryDetails(details); setSummaryDetails(details);
} catch (error: any) { } catch (error: any) {
// If we can't get details, clear summary
console.error('Failed to fetch summary details:', error); console.error('Failed to fetch summary details:', error);
setSummaryDetails(null); setSummaryDetails(null);
setSummaryId(null); setSummaryId(null);
} }
} else { } else {
// Summary doesn't exist yet - this is normal if request just closed
setSummaryDetails(null); setSummaryDetails(null);
setSummaryId(null); setSummaryId(null);
} }
} catch (error: any) { } catch (error: any) {
// If summary creation fails, don't show tab but log error // Summary not found - this is OK, summary may not exist yet
console.error('Summary not available:', error?.message);
setSummaryDetails(null); setSummaryDetails(null);
setSummaryId(null); setSummaryId(null);
} finally { } finally {
@ -593,7 +567,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshTrigger={sharedRecipientsRefreshTrigger} refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId} pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId} currentUserId={(user as any)?.userId}
resuming={resuming}
/> />
)} )}
</div> </div>
@ -627,6 +600,15 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
/> />
)} )}
{showResumeModal && apiRequest?.requestId && (
<ResumeModal
isOpen={showResumeModal}
onClose={() => setShowResumeModal(false)}
requestId={apiRequest.requestId}
onSuccess={handleResumeSuccess}
/>
)}
{showRetriggerModal && apiRequest?.requestId && ( {showRetriggerModal && apiRequest?.requestId && (
<RetriggerPauseModal <RetriggerPauseModal
isOpen={showRetriggerModal} isOpen={showRetriggerModal}

View File

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle, Loader2 } from 'lucide-react'; import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi'; import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
interface QuickActionsSidebarProps { interface QuickActionsSidebarProps {
@ -23,9 +23,8 @@ interface QuickActionsSidebarProps {
onRetrigger?: () => void; onRetrigger?: () => void;
summaryId?: string | null; summaryId?: string | null;
refreshTrigger?: number; // Trigger to refresh shared recipients list refreshTrigger?: number; // Trigger to refresh shared recipients list
pausedByUserId?: string; // User ID of the approver who paused pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
currentUserId?: string; // Current user's ID currentUserId?: string; // Current user's ID (kept for backwards compatibility)
resuming?: boolean; // Loading state for resume action
} }
export function QuickActionsSidebar({ export function QuickActionsSidebar({
@ -42,19 +41,17 @@ export function QuickActionsSidebar({
onRetrigger, onRetrigger,
summaryId, summaryId,
refreshTrigger, refreshTrigger,
pausedByUserId,
currentUserId,
resuming = false,
}: QuickActionsSidebarProps) { }: QuickActionsSidebarProps) {
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]); const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false); const [loadingRecipients, setLoadingRecipients] = useState(false);
const isClosed = request?.status === 'closed'; const isClosed = request?.status === 'closed';
const isPaused = request?.pauseInfo?.isPaused || false; const isPaused = request?.pauseInfo?.isPaused || false;
const canPause = !isPaused && !isClosed && currentApprovalLevel; // Only approver can pause // Both approver AND initiator can pause (when not already paused and not closed)
// Only the approver who paused can resume directly (not initiators) const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
const canResume = isPaused && onResume && !isInitiator && pausedByUserId === currentUserId; // Both approver AND initiator can resume directly
// Initiators can request resume (retrigger) when workflow is paused const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
const canRetrigger = isPaused && isInitiator && onRetrigger; // Retrigger is no longer needed since initiator can resume directly
const canRetrigger = false; // Disabled - kept for backwards compatibility
// Fetch shared recipients when request is closed and summaryId is available // Fetch shared recipients when request is closed and summaryId is available
useEffect(() => { useEffect(() => {
@ -132,20 +129,10 @@ export function QuickActionsSidebar({
variant="outline" variant="outline"
className="w-full justify-start gap-2 bg-white text-green-700 border-green-300 hover:bg-green-50 hover:text-green-900 h-9 sm:h-10 text-xs sm:text-sm" className="w-full justify-start gap-2 bg-white text-green-700 border-green-300 hover:bg-green-50 hover:text-green-900 h-9 sm:h-10 text-xs sm:text-sm"
onClick={onResume} onClick={onResume}
disabled={resuming}
data-testid="resume-workflow-button" data-testid="resume-workflow-button"
> >
{resuming ? (
<>
<Loader2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 animate-spin" />
Resuming...
</>
) : (
<>
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> <Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Resume Workflow Resume Workflow
</>
)}
</Button> </Button>
)} )}

View File

@ -52,13 +52,14 @@ export function OverviewTab({
currentUserId, currentUserId,
}: OverviewTabProps) { }: OverviewTabProps) {
void _onPause; // Marked as intentionally unused - available for future use void _onPause; // Marked as intentionally unused - available for future use
void pausedByUserId; // Kept for backwards compatibility
void currentUserId; // Kept for backwards compatibility
const pauseInfo = request?.pauseInfo; const pauseInfo = request?.pauseInfo;
const isPaused = pauseInfo?.isPaused || false; const isPaused = pauseInfo?.isPaused || false;
// Only the approver who paused can resume directly // Both approver AND initiator can resume directly
// Initiators can only request resume via retrigger const canResume = isPaused && (currentUserIsApprover || isInitiator);
const canResume = isPaused && (currentUserIsApprover && pausedByUserId === currentUserId); // Retrigger is no longer needed since initiator can resume directly
// Initiators can request resume (retrigger) when workflow is paused const canRetrigger = false; // Disabled - kept for backwards compatibility
const canRetrigger = isPaused && isInitiator;
return ( return (
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content"> <div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
{/* Request Initiator Card */} {/* Request Initiator Card */}

View File

@ -110,36 +110,38 @@ export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload
} }
export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayload, files: File[], category: 'SUPPORTING' | 'APPROVAL' | 'REFERENCE' | 'FINAL' | 'OTHER' = 'SUPPORTING') { 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()); // Simplified payload - backend handles user lookup and participant generation
const payload: any = { const payload: any = {
templateType: form.templateType, templateType: form.templateType,
title: form.title, title: form.title,
description: form.description, description: form.description,
priority: form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD', priority: form.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
approvalLevels: Array.from({ length: form.approverCount || 1 }, (_, i) => { // Simplified approvers format - only email and tatHours required
approvers: Array.from({ length: form.approverCount || 1 }, (_, i) => {
const a = form.approvers[i] || ({} as any); const a = form.approvers[i] || ({} as any);
const tat = typeof a.tat === 'number' ? a.tat : 0; const tat = typeof a.tat === 'number' ? a.tat : 0;
const approverId = (a.userId || '').trim();
if (!isUuid(approverId)) { if (!a.email || !a.email.trim()) {
throw new Error(`Invalid approverId for level ${i + 1}. Please pick an approver via @ search.`); throw new Error(`Email is required for approver at level ${i + 1}.`);
} }
return { return {
levelNumber: i + 1, email: a.email,
levelName: `Level ${i + 1}`, tat: tat,
approverId, tatType: a.tatType || 'hours',
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; // Add spectators if any (simplified - only email required)
if (Array.isArray(incomingParticipants) && incomingParticipants.length) { if (form.spectators && form.spectators.length > 0) {
payload.participants = incomingParticipants; payload.spectators = form.spectators
.filter((s: any) => s?.email)
.map((s: any) => ({ email: s.email }));
} }
// Note: participants array is auto-generated by backend from approvers and spectators
// No need to build or send it from frontend
const formData = new FormData(); const formData = new FormData();
formData.append('payload', JSON.stringify(payload)); formData.append('payload', JSON.stringify(payload));
@ -338,8 +340,8 @@ export async function pauseWorkflow(
return res.data?.data || res.data; return res.data?.data || res.data;
} }
export async function resumeWorkflow(requestId: string) { export async function resumeWorkflow(requestId: string, notes?: string) {
const res = await apiClient.post(`/workflows/${requestId}/resume`); const res = await apiClient.post(`/workflows/${requestId}/resume`, { notes });
return res.data?.data || res.data; return res.data?.data || res.data;
} }