Compare commits
No commits in common. "1bebf3a46a6b99d1b93084b61ae442252d06f90f" and "8c2aa60195ed615a6d4fea3cd3abf9757b7ff462" have entirely different histories.
1bebf3a46a
...
8c2aa60195
@ -1,114 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import workflowApi from '@/services/workflowApi';
|
||||
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||
import { getSocket } from '@/utils/socket';
|
||||
|
||||
/**
|
||||
* Custom Hook: useRequestDetails
|
||||
@ -82,10 +81,8 @@ export function useRequestDetails(
|
||||
* 5. Filter out TAT warning activities from audit trail
|
||||
* 6. Update all state with transformed data
|
||||
* 7. Determine current user's approval level and spectator status
|
||||
*
|
||||
* Note: Wrapped in useCallback to allow use in Socket.io listeners
|
||||
*/
|
||||
const refreshDetails = useCallback(async () => {
|
||||
const refreshDetails = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// API Call: Fetch complete workflow details including approvals, documents, participants
|
||||
@ -312,7 +309,7 @@ export function useRequestDetails(
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [requestIdentifier, user]); // useCallback dependencies
|
||||
};
|
||||
|
||||
/**
|
||||
* Effect: Initial data fetch when component mounts or requestIdentifier changes
|
||||
@ -623,47 +620,6 @@ export function useRequestDetails(
|
||||
return participants;
|
||||
}, [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 {
|
||||
request,
|
||||
apiRequest,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
/**
|
||||
* Utility functions for building API payloads
|
||||
* Simplified: Backend auto-generates participants from approvers and spectators
|
||||
*/
|
||||
|
||||
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
|
||||
@ -8,23 +7,29 @@ import {
|
||||
CreateWorkflowPayload,
|
||||
UpdateWorkflowPayload,
|
||||
} from '../types/createRequest.types';
|
||||
import {
|
||||
buildParticipantsArray,
|
||||
filterDuplicateParticipants,
|
||||
} from './participantMappers';
|
||||
import { buildApprovalLevels } from './approvalLevelBuilders';
|
||||
|
||||
/**
|
||||
* Build create workflow payload
|
||||
* Backend will auto-generate participants array from approvers and spectators
|
||||
*/
|
||||
export function buildCreatePayload(
|
||||
formData: FormData,
|
||||
selectedTemplate: RequestTemplate | null,
|
||||
user: any
|
||||
): CreateWorkflowPayload {
|
||||
// Filter out spectators who are also approvers (backend will handle validation)
|
||||
const approverEmails = new Set(
|
||||
(formData.approvers || []).map((a: any) => a?.email?.toLowerCase()).filter(Boolean)
|
||||
const participants = buildParticipantsArray(
|
||||
user,
|
||||
formData.approvers || [],
|
||||
formData.spectators || []
|
||||
);
|
||||
const filteredSpectators = (formData.spectators || []).filter(
|
||||
(s: any) => s?.email && !approverEmails.has(s.email.toLowerCase())
|
||||
|
||||
const filteredSpectators = filterDuplicateParticipants(
|
||||
formData.approvers || [],
|
||||
formData.spectators || []
|
||||
);
|
||||
|
||||
return {
|
||||
@ -42,16 +47,21 @@ export function buildCreatePayload(
|
||||
tatType: a?.tatType || 'hours',
|
||||
})),
|
||||
spectators: filteredSpectators.map((s) => ({
|
||||
userId: s?.id || '',
|
||||
name: s?.name || s?.email?.split('@')?.[0] || 'Spectator',
|
||||
email: s?.email || '',
|
||||
})),
|
||||
// Note: participants array is auto-generated by backend
|
||||
// No need to send it from frontend
|
||||
ccList: (formData.ccList || []).map((c: any) => ({
|
||||
id: c?.id,
|
||||
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
|
||||
email: c?.email || '',
|
||||
})),
|
||||
participants,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build update workflow payload
|
||||
* Backend will auto-generate participants array from approvalLevels
|
||||
*/
|
||||
export function buildUpdatePayload(
|
||||
formData: FormData,
|
||||
@ -63,19 +73,24 @@ export function buildUpdatePayload(
|
||||
formData.approverCount || 1
|
||||
);
|
||||
|
||||
const participants = buildParticipantsArray(
|
||||
user,
|
||||
(formData.approvers || []).filter((a: any) => a?.email && a?.userId),
|
||||
(formData.spectators || []).filter((s: any) => s?.email)
|
||||
);
|
||||
|
||||
return {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
priority: formData.priority === 'express' ? 'EXPRESS' : 'STANDARD',
|
||||
approvalLevels,
|
||||
// Note: participants array is auto-generated by backend
|
||||
participants,
|
||||
deleteDocumentIds: documentsToDelete.length > 0 ? documentsToDelete : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate approvers before submission
|
||||
* Simplified: Only check for valid emails (backend handles user lookup)
|
||||
*/
|
||||
export function validateApproversForSubmission(
|
||||
approvers: any[],
|
||||
@ -83,28 +98,14 @@ export function validateApproversForSubmission(
|
||||
): { valid: boolean; message?: string } {
|
||||
const approversToCheck = approvers.slice(0, approverCount);
|
||||
|
||||
// Check if all approvers have valid emails
|
||||
const hasMissingEmails = approversToCheck.some(
|
||||
(a: any) => !a?.email || !a.email.trim()
|
||||
const hasMissingIds = approversToCheck.some(
|
||||
(a: any) => !a?.userId || !a?.email
|
||||
);
|
||||
|
||||
if (hasMissingEmails) {
|
||||
if (hasMissingIds) {
|
||||
return {
|
||||
valid: false,
|
||||
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.',
|
||||
message: 'Please select approvers using @ search so we can capture their user IDs.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ import { downloadDocument } from '@/services/workflowApi';
|
||||
// Components
|
||||
import { RequestDetailHeader } from './components/RequestDetailHeader';
|
||||
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
||||
import { getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
|
||||
import { createSummary, getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
|
||||
import { toast } from 'sonner';
|
||||
import { OverviewTab } from './components/tabs/OverviewTab';
|
||||
import { WorkflowTab } from './components/tabs/WorkflowTab';
|
||||
@ -53,8 +53,8 @@ import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
||||
import { RequestDetailModals } from './components/RequestDetailModals';
|
||||
import { RequestDetailProps } from './types/requestDetail.types';
|
||||
import { PauseModal } from '@/components/workflow/PauseModal';
|
||||
import { ResumeModal } from '@/components/workflow/ResumeModal';
|
||||
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
|
||||
import { resumeWorkflow } from '@/services/workflowApi';
|
||||
|
||||
/**
|
||||
* Error Boundary Component
|
||||
@ -112,7 +112,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
|
||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||
const [showResumeModal, setShowResumeModal] = useState(false);
|
||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
@ -199,13 +198,24 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
setShowPauseModal(true);
|
||||
};
|
||||
|
||||
const handleResume = () => {
|
||||
setShowResumeModal(true);
|
||||
};
|
||||
const [resuming, setResuming] = useState(false);
|
||||
|
||||
const handleResumeSuccess = async () => {
|
||||
// Wait for refresh to complete to show updated status
|
||||
const handleResume = async () => {
|
||||
if (!apiRequest?.requestId) {
|
||||
toast.error('Request ID not found');
|
||||
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 = () => {
|
||||
@ -228,14 +238,31 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
return;
|
||||
}
|
||||
|
||||
if (!summaryId) {
|
||||
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
|
||||
return;
|
||||
try {
|
||||
// Get or create summary (backend returns existing summary if it exists - idempotent)
|
||||
// Summary should already exist from closure, but create if missing (handles edge cases)
|
||||
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 existing summary ID
|
||||
// Summary should already exist from closure (auto-created by backend)
|
||||
}
|
||||
// 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');
|
||||
}
|
||||
};
|
||||
|
||||
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
||||
@ -244,7 +271,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
|
||||
|
||||
// Fetch summary details if request is closed
|
||||
// Summary is automatically created by backend when request is closed (on final approval)
|
||||
// Summary should be automatically created when request is closed, but we'll create it if missing (idempotent)
|
||||
useEffect(() => {
|
||||
const fetchSummaryDetails = async () => {
|
||||
if (!isClosed || !apiRequest?.requestId) {
|
||||
@ -255,11 +282,9 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
|
||||
try {
|
||||
setLoadingSummary(true);
|
||||
// Just fetch the summary by requestId - don't try to create it
|
||||
// Summary is auto-created by backend on final approval/rejection
|
||||
const { getSummaryByRequestId } = await import('@/services/summaryApi');
|
||||
const summary = await getSummaryByRequestId(apiRequest.requestId);
|
||||
|
||||
// Use createSummary which is idempotent - returns existing summary if it exists, creates if missing
|
||||
// This handles cases where summary creation failed during closure or was not created yet
|
||||
const summary = await createSummary(apiRequest.requestId);
|
||||
if (summary?.summaryId) {
|
||||
setSummaryId(summary.summaryId);
|
||||
// Fetch full summary details
|
||||
@ -267,17 +292,18 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
const details = await getSummaryDetails(summary.summaryId);
|
||||
setSummaryDetails(details);
|
||||
} catch (error: any) {
|
||||
// If we can't get details, clear summary
|
||||
console.error('Failed to fetch summary details:', error);
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
}
|
||||
} else {
|
||||
// Summary doesn't exist yet - this is normal if request just closed
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Summary not found - this is OK, summary may not exist yet
|
||||
// If summary creation fails, don't show tab but log error
|
||||
console.error('Summary not available:', error?.message);
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
} finally {
|
||||
@ -567,6 +593,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
resuming={resuming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -600,15 +627,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
/>
|
||||
)}
|
||||
|
||||
{showResumeModal && apiRequest?.requestId && (
|
||||
<ResumeModal
|
||||
isOpen={showResumeModal}
|
||||
onClose={() => setShowResumeModal(false)}
|
||||
requestId={apiRequest.requestId}
|
||||
onSuccess={handleResumeSuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRetriggerModal && apiRequest?.requestId && (
|
||||
<RetriggerPauseModal
|
||||
isOpen={showRetriggerModal}
|
||||
|
||||
@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
||||
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||
|
||||
interface QuickActionsSidebarProps {
|
||||
@ -23,8 +23,9 @@ interface QuickActionsSidebarProps {
|
||||
onRetrigger?: () => void;
|
||||
summaryId?: string | null;
|
||||
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
||||
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
|
||||
currentUserId?: string; // Current user's ID (kept for backwards compatibility)
|
||||
pausedByUserId?: string; // User ID of the approver who paused
|
||||
currentUserId?: string; // Current user's ID
|
||||
resuming?: boolean; // Loading state for resume action
|
||||
}
|
||||
|
||||
export function QuickActionsSidebar({
|
||||
@ -41,17 +42,19 @@ export function QuickActionsSidebar({
|
||||
onRetrigger,
|
||||
summaryId,
|
||||
refreshTrigger,
|
||||
pausedByUserId,
|
||||
currentUserId,
|
||||
resuming = false,
|
||||
}: QuickActionsSidebarProps) {
|
||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||
const isClosed = request?.status === 'closed';
|
||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||
// Both approver AND initiator can pause (when not already paused and not closed)
|
||||
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
||||
// Both approver AND initiator can resume directly
|
||||
const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
|
||||
// Retrigger is no longer needed since initiator can resume directly
|
||||
const canRetrigger = false; // Disabled - kept for backwards compatibility
|
||||
const canPause = !isPaused && !isClosed && currentApprovalLevel; // Only approver can pause
|
||||
// Only the approver who paused can resume directly (not initiators)
|
||||
const canResume = isPaused && onResume && !isInitiator && pausedByUserId === currentUserId;
|
||||
// Initiators can request resume (retrigger) when workflow is paused
|
||||
const canRetrigger = isPaused && isInitiator && onRetrigger;
|
||||
|
||||
// Fetch shared recipients when request is closed and summaryId is available
|
||||
useEffect(() => {
|
||||
@ -129,10 +132,20 @@ export function QuickActionsSidebar({
|
||||
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"
|
||||
onClick={onResume}
|
||||
disabled={resuming}
|
||||
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" />
|
||||
Resume Workflow
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
@ -52,14 +52,13 @@ export function OverviewTab({
|
||||
currentUserId,
|
||||
}: OverviewTabProps) {
|
||||
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 isPaused = pauseInfo?.isPaused || false;
|
||||
// Both approver AND initiator can resume directly
|
||||
const canResume = isPaused && (currentUserIsApprover || isInitiator);
|
||||
// Retrigger is no longer needed since initiator can resume directly
|
||||
const canRetrigger = false; // Disabled - kept for backwards compatibility
|
||||
// Only the approver who paused can resume directly
|
||||
// Initiators can only request resume via retrigger
|
||||
const canResume = isPaused && (currentUserIsApprover && pausedByUserId === currentUserId);
|
||||
// Initiators can request resume (retrigger) when workflow is paused
|
||||
const canRetrigger = isPaused && isInitiator;
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
|
||||
{/* Request Initiator Card */}
|
||||
|
||||
@ -110,39 +110,37 @@ export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload
|
||||
}
|
||||
|
||||
export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayload, files: File[], category: 'SUPPORTING' | 'APPROVAL' | 'REFERENCE' | 'FINAL' | 'OTHER' = 'SUPPORTING') {
|
||||
// Simplified payload - backend handles user lookup and participant generation
|
||||
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',
|
||||
// Simplified approvers format - only email and tatHours required
|
||||
approvers: Array.from({ length: form.approverCount || 1 }, (_, i) => {
|
||||
approvalLevels: Array.from({ length: form.approverCount || 1 }, (_, i) => {
|
||||
const a = form.approvers[i] || ({} as any);
|
||||
const tat = typeof a.tat === 'number' ? a.tat : 0;
|
||||
|
||||
if (!a.email || !a.email.trim()) {
|
||||
throw new Error(`Email is required for approver at level ${i + 1}.`);
|
||||
const approverId = (a.userId || '').trim();
|
||||
if (!isUuid(approverId)) {
|
||||
throw new Error(`Invalid approverId for level ${i + 1}. Please pick an approver via @ search.`);
|
||||
}
|
||||
|
||||
return {
|
||||
email: a.email,
|
||||
tat: tat,
|
||||
tatType: a.tatType || 'hours',
|
||||
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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// Add spectators if any (simplified - only email required)
|
||||
if (form.spectators && form.spectators.length > 0) {
|
||||
payload.spectators = form.spectators
|
||||
.filter((s: any) => s?.email)
|
||||
.map((s: any) => ({ email: s.email }));
|
||||
// Pass participants if provided by caller (CreateRequest builds this)
|
||||
const incomingParticipants = (form as any).participants;
|
||||
if (Array.isArray(incomingParticipants) && incomingParticipants.length) {
|
||||
payload.participants = incomingParticipants;
|
||||
}
|
||||
|
||||
// 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();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('category', category);
|
||||
@ -340,8 +338,8 @@ export async function pauseWorkflow(
|
||||
return res.data?.data || res.data;
|
||||
}
|
||||
|
||||
export async function resumeWorkflow(requestId: string, notes?: string) {
|
||||
const res = await apiClient.post(`/workflows/${requestId}/resume`, { notes });
|
||||
export async function resumeWorkflow(requestId: string) {
|
||||
const res = await apiClient.post(`/workflows/${requestId}/resume`);
|
||||
return res.data?.data || res.data;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user