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 { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
import { getSocket } from '@/utils/socket';
/**
* Custom Hook: useRequestDetails
@ -81,8 +82,10 @@ 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 = async () => {
const refreshDetails = useCallback(async () => {
setRefreshing(true);
try {
// API Call: Fetch complete workflow details including approvals, documents, participants
@ -309,7 +312,7 @@ export function useRequestDetails(
} finally {
setRefreshing(false);
}
};
}, [requestIdentifier, user]); // useCallback dependencies
/**
* Effect: Initial data fetch when component mounts or requestIdentifier changes
@ -620,6 +623,47 @@ 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,

View File

@ -1,5 +1,6 @@
/**
* Utility functions for building API payloads
* Simplified: Backend auto-generates participants from approvers and spectators
*/
import { FormData, RequestTemplate } from '@/hooks/useCreateRequestForm';
@ -7,29 +8,23 @@ 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 {
const participants = buildParticipantsArray(
user,
formData.approvers || [],
formData.spectators || []
// 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 filteredSpectators = filterDuplicateParticipants(
formData.approvers || [],
formData.spectators || []
const filteredSpectators = (formData.spectators || []).filter(
(s: any) => s?.email && !approverEmails.has(s.email.toLowerCase())
);
return {
@ -47,21 +42,16 @@ 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 || '',
})),
ccList: (formData.ccList || []).map((c: any) => ({
id: c?.id,
name: c?.name || c?.email?.split('@')?.[0] || 'CC',
email: c?.email || '',
})),
participants,
// Note: participants array is auto-generated by backend
// No need to send it from frontend
};
}
/**
* Build update workflow payload
* Backend will auto-generate participants array from approvalLevels
*/
export function buildUpdatePayload(
formData: FormData,
@ -73,24 +63,19 @@ 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,
participants,
// Note: participants array is auto-generated by backend
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[],
@ -98,14 +83,28 @@ export function validateApproversForSubmission(
): { valid: boolean; message?: string } {
const approversToCheck = approvers.slice(0, approverCount);
const hasMissingIds = approversToCheck.some(
(a: any) => !a?.userId || !a?.email
// Check if all approvers have valid emails
const hasMissingEmails = approversToCheck.some(
(a: any) => !a?.email || !a.email.trim()
);
if (hasMissingIds) {
if (hasMissingEmails) {
return {
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
import { RequestDetailHeader } from './components/RequestDetailHeader';
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 { 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,6 +112,7 @@ 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();
@ -198,24 +199,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
setShowPauseModal(true);
};
const [resuming, setResuming] = useState(false);
const handleResume = () => {
setShowResumeModal(true);
};
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 handleResumeSuccess = async () => {
// Wait for refresh to complete to show updated status
await refreshDetails();
};
const handleRetrigger = () => {
@ -238,31 +228,14 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
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 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');
if (!summaryId) {
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
return;
}
// 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;
@ -271,7 +244,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 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(() => {
const fetchSummaryDetails = async () => {
if (!isClosed || !apiRequest?.requestId) {
@ -282,9 +255,11 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
try {
setLoadingSummary(true);
// 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);
// 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);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
// Fetch full summary details
@ -292,18 +267,17 @@ 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) {
// If summary creation fails, don't show tab but log error
console.error('Summary not available:', error?.message);
// Summary not found - this is OK, summary may not exist yet
setSummaryDetails(null);
setSummaryId(null);
} finally {
@ -593,7 +567,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
resuming={resuming}
/>
)}
</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 && (
<RetriggerPauseModal
isOpen={showRetriggerModal}

View File

@ -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, Loader2 } from 'lucide-react';
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
interface QuickActionsSidebarProps {
@ -23,9 +23,8 @@ interface QuickActionsSidebarProps {
onRetrigger?: () => void;
summaryId?: string | null;
refreshTrigger?: number; // Trigger to refresh shared recipients list
pausedByUserId?: string; // User ID of the approver who paused
currentUserId?: string; // Current user's ID
resuming?: boolean; // Loading state for resume action
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
currentUserId?: string; // Current user's ID (kept for backwards compatibility)
}
export function QuickActionsSidebar({
@ -42,19 +41,17 @@ 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;
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;
// 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
// Fetch shared recipients when request is closed and summaryId is available
useEffect(() => {
@ -132,20 +129,10 @@ 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>
)}

View File

@ -52,13 +52,14 @@ 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;
// 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;
// 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
return (
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
{/* Request Initiator Card */}

View File

@ -110,37 +110,39 @@ export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload
}
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 = {
templateType: form.templateType,
title: form.title,
description: form.description,
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 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.`);
if (!a.email || !a.email.trim()) {
throw new Error(`Email is required for approver at level ${i + 1}.`);
}
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),
email: a.email,
tat: tat,
tatType: a.tatType || 'hours',
};
}),
};
// Pass participants if provided by caller (CreateRequest builds this)
const incomingParticipants = (form as any).participants;
if (Array.isArray(incomingParticipants) && incomingParticipants.length) {
payload.participants = incomingParticipants;
// 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 }));
}
// 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);
@ -338,8 +340,8 @@ export async function pauseWorkflow(
return res.data?.data || res.data;
}
export async function resumeWorkflow(requestId: string) {
const res = await apiClient.post(`/workflows/${requestId}/resume`);
export async function resumeWorkflow(requestId: string, notes?: string) {
const res = await apiClient.post(`/workflows/${requestId}/resume`, { notes });
return res.data?.data || res.data;
}