Compare commits
2 Commits
8c2aa60195
...
1bebf3a46a
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bebf3a46a | |||
| 8e3e138ef8 |
114
src/components/workflow/ResumeModal.tsx
Normal file
114
src/components/workflow/ResumeModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setResuming(true);
|
|
||||||
await resumeWorkflow(apiRequest.requestId);
|
|
||||||
toast.success('Workflow resumed successfully');
|
|
||||||
// Wait for refresh to complete before clearing loading state
|
|
||||||
await refreshDetails();
|
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)
|
// Open share modal with the existing summary ID
|
||||||
if (currentSummaryId) {
|
// Summary should already exist from closure (auto-created by backend)
|
||||||
setShowShareSummaryModal(true);
|
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;
|
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}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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') {
|
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));
|
||||||
formData.append('category', category);
|
formData.append('category', category);
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user