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 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
|
||||||
@ -82,10 +81,8 @@ 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 = useCallback(async () => {
|
const refreshDetails = 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
|
||||||
@ -312,7 +309,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
|
||||||
@ -623,47 +620,6 @@ 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,
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* 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';
|
||||||
@ -8,23 +7,29 @@ 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 {
|
||||||
// Filter out spectators who are also approvers (backend will handle validation)
|
const participants = buildParticipantsArray(
|
||||||
const approverEmails = new Set(
|
user,
|
||||||
(formData.approvers || []).map((a: any) => a?.email?.toLowerCase()).filter(Boolean)
|
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 {
|
return {
|
||||||
@ -42,16 +47,21 @@ 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 || '',
|
||||||
})),
|
})),
|
||||||
// Note: participants array is auto-generated by backend
|
ccList: (formData.ccList || []).map((c: any) => ({
|
||||||
// No need to send it from frontend
|
id: c?.id,
|
||||||
|
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,
|
||||||
@ -63,19 +73,24 @@ 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,
|
||||||
// Note: participants array is auto-generated by backend
|
participants,
|
||||||
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[],
|
||||||
@ -83,28 +98,14 @@ export function validateApproversForSubmission(
|
|||||||
): { valid: boolean; message?: string } {
|
): { valid: boolean; message?: string } {
|
||||||
const approversToCheck = approvers.slice(0, approverCount);
|
const approversToCheck = approvers.slice(0, approverCount);
|
||||||
|
|
||||||
// Check if all approvers have valid emails
|
const hasMissingIds = approversToCheck.some(
|
||||||
const hasMissingEmails = approversToCheck.some(
|
(a: any) => !a?.userId || !a?.email
|
||||||
(a: any) => !a?.email || !a.email.trim()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (hasMissingEmails) {
|
if (hasMissingIds) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
message: 'Please provide email addresses for all approvers.',
|
message: 'Please select approvers using @ search so we can capture their user IDs.',
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 { getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
|
import { createSummary, 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,7 +112,6 @@ 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();
|
||||||
|
|
||||||
@ -199,13 +198,24 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
setShowPauseModal(true);
|
setShowPauseModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResume = () => {
|
const [resuming, setResuming] = useState(false);
|
||||||
setShowResumeModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResumeSuccess = async () => {
|
const handleResume = async () => {
|
||||||
// Wait for refresh to complete to show updated status
|
if (!apiRequest?.requestId) {
|
||||||
await refreshDetails();
|
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 = () => {
|
const handleRetrigger = () => {
|
||||||
@ -228,14 +238,31 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!summaryId) {
|
try {
|
||||||
toast.error('Summary not available. Please ensure the request is closed and the summary has been generated.');
|
// Get or create summary (backend returns existing summary if it exists - idempotent)
|
||||||
return;
|
// 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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;
|
||||||
@ -244,7 +271,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 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(() => {
|
useEffect(() => {
|
||||||
const fetchSummaryDetails = async () => {
|
const fetchSummaryDetails = async () => {
|
||||||
if (!isClosed || !apiRequest?.requestId) {
|
if (!isClosed || !apiRequest?.requestId) {
|
||||||
@ -255,11 +282,9 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setLoadingSummary(true);
|
setLoadingSummary(true);
|
||||||
// Just fetch the summary by requestId - don't try to create it
|
// Use createSummary which is idempotent - returns existing summary if it exists, creates if missing
|
||||||
// Summary is auto-created by backend on final approval/rejection
|
// This handles cases where summary creation failed during closure or was not created yet
|
||||||
const { getSummaryByRequestId } = await import('@/services/summaryApi');
|
const summary = await createSummary(apiRequest.requestId);
|
||||||
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
|
||||||
@ -267,17 +292,18 @@ 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) {
|
||||||
// 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);
|
setSummaryDetails(null);
|
||||||
setSummaryId(null);
|
setSummaryId(null);
|
||||||
} finally {
|
} finally {
|
||||||
@ -567,6 +593,7 @@ 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>
|
||||||
@ -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 && (
|
{showRetriggerModal && apiRequest?.requestId && (
|
||||||
<RetriggerPauseModal
|
<RetriggerPauseModal
|
||||||
isOpen={showRetriggerModal}
|
isOpen={showRetriggerModal}
|
||||||
|
|||||||
@ -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 } from 'lucide-react';
|
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||||
|
|
||||||
interface QuickActionsSidebarProps {
|
interface QuickActionsSidebarProps {
|
||||||
@ -23,8 +23,9 @@ 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 (kept for backwards compatibility)
|
pausedByUserId?: string; // User ID of the approver who paused
|
||||||
currentUserId?: string; // Current user's ID (kept for backwards compatibility)
|
currentUserId?: string; // Current user's ID
|
||||||
|
resuming?: boolean; // Loading state for resume action
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickActionsSidebar({
|
export function QuickActionsSidebar({
|
||||||
@ -41,17 +42,19 @@ 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;
|
||||||
// Both approver AND initiator can pause (when not already paused and not closed)
|
const canPause = !isPaused && !isClosed && currentApprovalLevel; // Only approver can pause
|
||||||
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
// Only the approver who paused can resume directly (not initiators)
|
||||||
// Both approver AND initiator can resume directly
|
const canResume = isPaused && onResume && !isInitiator && pausedByUserId === currentUserId;
|
||||||
const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
|
// Initiators can request resume (retrigger) when workflow is paused
|
||||||
// Retrigger is no longer needed since initiator can resume directly
|
const canRetrigger = isPaused && isInitiator && onRetrigger;
|
||||||
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(() => {
|
||||||
@ -129,10 +132,20 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -52,14 +52,13 @@ 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;
|
||||||
// Both approver AND initiator can resume directly
|
// Only the approver who paused can resume directly
|
||||||
const canResume = isPaused && (currentUserIsApprover || isInitiator);
|
// Initiators can only request resume via retrigger
|
||||||
// Retrigger is no longer needed since initiator can resume directly
|
const canResume = isPaused && (currentUserIsApprover && pausedByUserId === currentUserId);
|
||||||
const canRetrigger = false; // Disabled - kept for backwards compatibility
|
// Initiators can request resume (retrigger) when workflow is paused
|
||||||
|
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 */}
|
||||||
|
|||||||
@ -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') {
|
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 = {
|
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',
|
||||||
// Simplified approvers format - only email and tatHours required
|
approvalLevels: Array.from({ length: form.approverCount || 1 }, (_, i) => {
|
||||||
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 (!a.email || !a.email.trim()) {
|
if (!isUuid(approverId)) {
|
||||||
throw new Error(`Email is required for approver at level ${i + 1}.`);
|
throw new Error(`Invalid approverId for level ${i + 1}. Please pick an approver via @ search.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
email: a.email,
|
levelNumber: i + 1,
|
||||||
tat: tat,
|
levelName: `Level ${i + 1}`,
|
||||||
tatType: a.tatType || 'hours',
|
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)
|
||||||
// Add spectators if any (simplified - only email required)
|
const incomingParticipants = (form as any).participants;
|
||||||
if (form.spectators && form.spectators.length > 0) {
|
if (Array.isArray(incomingParticipants) && incomingParticipants.length) {
|
||||||
payload.spectators = form.spectators
|
payload.participants = incomingParticipants;
|
||||||
.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));
|
||||||
formData.append('category', category);
|
formData.append('category', category);
|
||||||
@ -340,8 +338,8 @@ export async function pauseWorkflow(
|
|||||||
return res.data?.data || res.data;
|
return res.data?.data || res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resumeWorkflow(requestId: string, notes?: string) {
|
export async function resumeWorkflow(requestId: string) {
|
||||||
const res = await apiClient.post(`/workflows/${requestId}/resume`, { notes });
|
const res = await apiClient.post(`/workflows/${requestId}/resume`);
|
||||||
return res.data?.data || res.data;
|
return res.data?.data || res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user