384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
/**
|
|
* Main CreateRequest component - Clean composition only
|
|
*
|
|
* This component orchestrates the CreateRequest flow by:
|
|
* - Composing layout structure
|
|
* - Wiring up hooks and components
|
|
* - Handling top-level callbacks
|
|
*
|
|
* All business logic, API calls, and utilities are extracted to:
|
|
* - hooks/ - Business logic and state management
|
|
* - services/ - API operations
|
|
* - utils/ - Pure utility functions
|
|
* - components/ - UI components
|
|
*/
|
|
|
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import { TemplateSelectionModal } from '@/components/modals/TemplateSelectionModal';
|
|
import { FilePreview } from '@/components/common/FilePreview';
|
|
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
|
import { downloadDocument } from '@/services/workflowApi';
|
|
|
|
// Custom Hooks
|
|
import { useCreateRequestForm } from '@/hooks/useCreateRequestForm';
|
|
import { useWizardNavigation } from '@/hooks/useWizardNavigation';
|
|
import { useRequestModals } from './hooks/useRequestModals';
|
|
import { useCreateRequestSubmission } from './hooks/useCreateRequestSubmission';
|
|
import { useCreateRequestHandlers } from './hooks/useCreateRequestHandlers';
|
|
|
|
// Constants
|
|
import { REQUEST_TEMPLATES } from './constants/requestTemplates';
|
|
|
|
// Components
|
|
import { WizardStepper } from '@/components/workflow/CreateRequest/WizardStepper';
|
|
import { WizardFooter } from '@/components/workflow/CreateRequest/WizardFooter';
|
|
import { TemplateSelectionStep } from '@/components/workflow/CreateRequest/TemplateSelectionStep';
|
|
import { BasicInformationStep } from '@/components/workflow/CreateRequest/BasicInformationStep';
|
|
import { ApprovalWorkflowStep } from '@/components/workflow/CreateRequest/ApprovalWorkflowStep';
|
|
import { ParticipantsStep } from '@/components/workflow/CreateRequest/ParticipantsStep';
|
|
import { DocumentsStep } from '@/components/workflow/CreateRequest/DocumentsStep';
|
|
import { ReviewSubmitStep } from '@/components/workflow/CreateRequest/ReviewSubmitStep';
|
|
import { CreateRequestHeader } from './components/CreateRequestHeader';
|
|
import { CreateRequestContent } from './components/CreateRequestContent';
|
|
import { ValidationErrorModal } from './components/modals/ValidationErrorModal';
|
|
import { DocumentErrorModal } from './components/modals/DocumentErrorModal';
|
|
|
|
interface CreateRequestProps {
|
|
onBack?: () => void;
|
|
onSubmit?: (requestData: any) => void;
|
|
requestId?: string;
|
|
isEditMode?: boolean;
|
|
}
|
|
|
|
export function CreateRequest({
|
|
onBack,
|
|
onSubmit,
|
|
requestId: propRequestId,
|
|
isEditMode = false,
|
|
}: CreateRequestProps) {
|
|
const params = useParams<{ requestId: string }>();
|
|
const navigate = useNavigate();
|
|
const editRequestId = params.requestId || propRequestId || '';
|
|
const isEditing = isEditMode && !!editRequestId;
|
|
const { user } = useAuth();
|
|
|
|
// Form and state management hooks
|
|
const {
|
|
formData,
|
|
updateFormData,
|
|
selectedTemplate,
|
|
setSelectedTemplate,
|
|
loadingDraft,
|
|
systemPolicy,
|
|
documentPolicy,
|
|
existingDocuments,
|
|
setExistingDocuments,
|
|
} = useCreateRequestForm(isEditing, editRequestId, REQUEST_TEMPLATES);
|
|
|
|
const {
|
|
currentStep,
|
|
totalSteps,
|
|
stepNames,
|
|
isStepValid,
|
|
nextStep: wizardNextStep,
|
|
prevStep: wizardPrevStep,
|
|
} = useWizardNavigation(isEditing, selectedTemplate, formData);
|
|
|
|
// Document management state
|
|
const [documents, setDocuments] = useState<File[]>([]);
|
|
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Modal management
|
|
const {
|
|
validationModal,
|
|
policyViolationModal,
|
|
documentErrorModal,
|
|
openValidationModal,
|
|
closeValidationModal,
|
|
closePolicyViolationModal,
|
|
openDocumentErrorModal,
|
|
closeDocumentErrorModal,
|
|
} = useRequestModals();
|
|
|
|
// Submission logic
|
|
const { submitting, savingDraft, handleSubmit, handleSaveDraft } =
|
|
useCreateRequestSubmission({
|
|
formData,
|
|
selectedTemplate,
|
|
documents,
|
|
documentsToDelete,
|
|
user: user!,
|
|
isEditing,
|
|
editRequestId,
|
|
onSubmit,
|
|
});
|
|
|
|
// Event handlers
|
|
const {
|
|
showTemplateModal,
|
|
setShowTemplateModal,
|
|
previewDocument,
|
|
selectTemplate,
|
|
handleTemplateSelection,
|
|
nextStep,
|
|
prevStep,
|
|
handlePreviewDocument,
|
|
closePreview,
|
|
} = useCreateRequestHandlers({
|
|
selectedTemplate,
|
|
setSelectedTemplate,
|
|
updateFormData,
|
|
formData,
|
|
currentStep,
|
|
isStepValid,
|
|
wizardNextStep,
|
|
wizardPrevStep,
|
|
user: user!,
|
|
openValidationModal,
|
|
onSubmit,
|
|
});
|
|
|
|
// Handle back button:
|
|
// - Steps 1, 3, or 4: Navigate back to previous screen (browser history)
|
|
// - Other steps: Go to previous step in wizard
|
|
const handleBackButton = useCallback(() => {
|
|
if (currentStep === 1 || currentStep === 3 || currentStep === 4) {
|
|
// On steps 1, 3, or 4, navigate back to previous screen using browser history
|
|
if (onBack) {
|
|
onBack();
|
|
} else {
|
|
// Use window.history.back() as fallback for more reliable navigation
|
|
if (window.history.length > 1) {
|
|
window.history.back();
|
|
} else {
|
|
// If no history, navigate to dashboard
|
|
navigate('/dashboard', { replace: true });
|
|
}
|
|
}
|
|
} else {
|
|
// On other steps (2, 5, 6), go to previous step in wizard
|
|
prevStep();
|
|
}
|
|
}, [currentStep, onBack, navigate, prevStep]);
|
|
|
|
// Sync documents from formData only on initial mount (when loading draft)
|
|
const isInitialMount = useRef(true);
|
|
const documentsSyncedRef = useRef(false);
|
|
|
|
useEffect(() => {
|
|
// Only sync from formData on initial mount or when loading a draft
|
|
if (isInitialMount.current && formData.documents && formData.documents.length > 0 && !documentsSyncedRef.current) {
|
|
setDocuments(formData.documents);
|
|
documentsSyncedRef.current = true;
|
|
}
|
|
isInitialMount.current = false;
|
|
}, [formData.documents]);
|
|
|
|
// Update formData.documents when documents change (one-way sync from local state to formData)
|
|
// Use a ref to prevent circular updates
|
|
const isUpdatingFromFormData = useRef(false);
|
|
const prevDocumentsRef = useRef(documents);
|
|
|
|
useEffect(() => {
|
|
// Skip if we're currently syncing from formData
|
|
if (isUpdatingFromFormData.current) {
|
|
isUpdatingFromFormData.current = false;
|
|
prevDocumentsRef.current = documents;
|
|
return;
|
|
}
|
|
|
|
// Only update if documents actually changed
|
|
if (prevDocumentsRef.current !== documents) {
|
|
updateFormData('documents', documents);
|
|
prevDocumentsRef.current = documents;
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [documents]);
|
|
|
|
// Render step content
|
|
const renderStepContent = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return (
|
|
<TemplateSelectionStep
|
|
templates={REQUEST_TEMPLATES}
|
|
selectedTemplate={selectedTemplate}
|
|
onSelectTemplate={selectTemplate}
|
|
/>
|
|
);
|
|
case 2:
|
|
return (
|
|
<BasicInformationStep
|
|
formData={formData}
|
|
selectedTemplate={selectedTemplate}
|
|
updateFormData={updateFormData}
|
|
/>
|
|
);
|
|
case 3:
|
|
return (
|
|
<ApprovalWorkflowStep
|
|
formData={formData}
|
|
updateFormData={updateFormData}
|
|
onValidationError={(error) =>
|
|
openValidationModal(
|
|
error.type as 'error' | 'self-assign' | 'not-found',
|
|
error.email,
|
|
error.message
|
|
)
|
|
}
|
|
/>
|
|
);
|
|
case 4:
|
|
return (
|
|
<ParticipantsStep
|
|
formData={formData}
|
|
updateFormData={updateFormData}
|
|
onValidationError={(error) =>
|
|
openValidationModal(
|
|
error.type as 'error' | 'self-assign' | 'not-found',
|
|
error.email,
|
|
error.message
|
|
)
|
|
}
|
|
initiatorEmail={(user as any)?.email || ''}
|
|
/>
|
|
);
|
|
case 5:
|
|
return (
|
|
<DocumentsStep
|
|
documentPolicy={documentPolicy}
|
|
isEditing={isEditing}
|
|
documents={documents}
|
|
existingDocuments={existingDocuments}
|
|
documentsToDelete={documentsToDelete}
|
|
onDocumentsChange={setDocuments}
|
|
onExistingDocumentsChange={setExistingDocuments}
|
|
onDocumentsToDeleteChange={setDocumentsToDelete}
|
|
onPreviewDocument={handlePreviewDocument}
|
|
onDocumentErrors={(errors) => openDocumentErrorModal(errors)}
|
|
fileInputRef={fileInputRef}
|
|
/>
|
|
);
|
|
case 6:
|
|
return (
|
|
<ReviewSubmitStep
|
|
formData={formData}
|
|
selectedTemplate={selectedTemplate}
|
|
/>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
// Loading state
|
|
if (loadingDraft) {
|
|
return (
|
|
<div
|
|
className="min-h-screen bg-gray-50 flex items-center justify-center"
|
|
data-testid="create-request-loading"
|
|
>
|
|
<div className="text-center">
|
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
|
<p className="text-gray-600">Loading draft...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Main render
|
|
return (
|
|
<div
|
|
className="h-full flex flex-col bg-gradient-to-br from-gray-50 to-white"
|
|
data-testid="create-request"
|
|
>
|
|
<CreateRequestHeader
|
|
isEditing={isEditing}
|
|
currentStep={currentStep}
|
|
totalSteps={totalSteps}
|
|
stepNames={stepNames}
|
|
onBack={handleBackButton}
|
|
/>
|
|
|
|
<WizardStepper
|
|
currentStep={currentStep}
|
|
totalSteps={totalSteps}
|
|
stepNames={stepNames}
|
|
/>
|
|
|
|
<CreateRequestContent>{renderStepContent()}</CreateRequestContent>
|
|
|
|
<WizardFooter
|
|
currentStep={currentStep}
|
|
totalSteps={totalSteps}
|
|
isStepValid={isStepValid()}
|
|
onPrev={prevStep}
|
|
onNext={nextStep}
|
|
onSubmit={handleSubmit}
|
|
onSaveDraft={handleSaveDraft}
|
|
submitting={submitting}
|
|
savingDraft={savingDraft}
|
|
loadingDraft={loadingDraft}
|
|
isEditing={isEditing}
|
|
/>
|
|
|
|
{/* Modals */}
|
|
<TemplateSelectionModal
|
|
open={showTemplateModal}
|
|
onClose={() => setShowTemplateModal(false)}
|
|
onSelectTemplate={handleTemplateSelection}
|
|
/>
|
|
|
|
{previewDocument && (
|
|
<FilePreview
|
|
fileName={previewDocument.fileName}
|
|
fileType={previewDocument.fileType}
|
|
fileUrl={previewDocument.fileUrl}
|
|
fileSize={previewDocument.fileSize}
|
|
open={!!previewDocument}
|
|
onClose={closePreview}
|
|
onDownload={async () => {
|
|
if (previewDocument.file) {
|
|
const link = document.createElement('a');
|
|
link.href = previewDocument.fileUrl;
|
|
link.download = previewDocument.fileName;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
} else if (previewDocument.documentId) {
|
|
await downloadDocument(previewDocument.documentId);
|
|
}
|
|
}}
|
|
attachmentId={previewDocument.documentId}
|
|
/>
|
|
)}
|
|
|
|
<ValidationErrorModal
|
|
modal={validationModal}
|
|
onClose={closeValidationModal}
|
|
/>
|
|
|
|
<DocumentErrorModal
|
|
modal={documentErrorModal}
|
|
documentPolicy={documentPolicy}
|
|
onClose={closeDocumentErrorModal}
|
|
/>
|
|
|
|
<PolicyViolationModal
|
|
open={policyViolationModal.open}
|
|
onClose={closePolicyViolationModal}
|
|
violations={policyViolationModal.violations}
|
|
policyDetails={{
|
|
maxApprovalLevels: systemPolicy.maxApprovalLevels,
|
|
maxParticipants: systemPolicy.maxParticipants,
|
|
allowSpectators: systemPolicy.allowSpectators,
|
|
maxSpectators: systemPolicy.maxSpectators,
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|