diff --git a/src/App.tsx b/src/App.tsx index 9a5d69b..25ccca0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -506,10 +506,10 @@ export default function App() { } /> navigate(`/constitutional-change/${id}`)} />} /> - navigate('/constitutional-change')} currentUser={currentUser} />} /> + navigate(hasRole(['Dealer']) ? '/dealer-constitutional' : '/constitutional-change')} currentUser={currentUser} />} /> navigate(`/relocation-requests/${id}`)} />} /> - navigate('/relocation-requests')} currentUser={currentUser} />} /> + navigate(hasRole(['Dealer']) ? '/dealer-relocation' : '/relocation-requests')} currentUser={currentUser} />} /> {/* Dealer Routes */} navigate(`/dealer-resignation/${id}`)} />} /> diff --git a/src/api/API.ts b/src/api/API.ts index 1dcabca..40023aa 100644 --- a/src/api/API.ts +++ b/src/api/API.ts @@ -54,6 +54,10 @@ export const API = { assignArchitectureTeam: (applicationId: string, assignedTo: string) => client.post(`/onboarding/applications/${applicationId}/assign-architecture`, { assignedTo }), updateArchitectureStatus: (applicationId: string, status: string, remarks?: string) => client.post(`/onboarding/applications/${applicationId}/architecture-status`, { status, remarks }), generateDealerCodes: (applicationId: string) => client.post(`/onboarding/applications/${applicationId}/generate-codes`), + requestProspectDocuments: ( + applicationId: string, + data: { documentTypes: string[]; dueDays?: number; customMessage?: string } + ) => client.post(`/onboarding/applications/${applicationId}/request-documents`, data), updateApplicationStatus: (id: string, data: any) => client.put(`/onboarding/applications/${id}/status`, data), convertToOpportunity: (id: string, data?: any) => client.post(`/onboarding/applications/${id}/convert-to-opportunity`, data), bulkConvertToOpportunity: (data: any) => client.post('/onboarding/applications/bulk-convert-to-opportunity', data), diff --git a/src/features/auth/pages/ProspectiveLoginPage.tsx b/src/features/auth/pages/ProspectiveLoginPage.tsx index 5ef75be..46dbb74 100644 --- a/src/features/auth/pages/ProspectiveLoginPage.tsx +++ b/src/features/auth/pages/ProspectiveLoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; @@ -11,6 +11,7 @@ import { setCredentials } from '@/store/slices/authSlice'; export function ProspectiveLoginPage() { const navigate = useNavigate(); + const routerLocation = useLocation(); const dispatch = useDispatch(); const [step, setStep] = useState<'PHONE' | 'OTP'>('PHONE'); const [phone, setPhone] = useState(''); @@ -18,6 +19,20 @@ export function ProspectiveLoginPage() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); + // Honour an optional `?next=...` deep-link sent by applicant emails. We allow only + // internal paths under `/prospective-dashboard/...` to prevent open-redirect abuse. + const resolveRedirectTarget = (): string => { + const raw = new URLSearchParams(routerLocation.search).get('next'); + if (!raw) return '/prospective-dashboard'; + try { + const decoded = decodeURIComponent(raw); + if (decoded.startsWith('/prospective-dashboard')) return decoded; + } catch { + // fall through to default + } + return '/prospective-dashboard'; + }; + const handleSendOtp = async (e: React.FormEvent) => { e.preventDefault(); if (!phone || phone.length < 10) { @@ -76,7 +91,7 @@ export function ProspectiveLoginPage() { localStorage.setItem('token', token); toast.success('Logged in successfully!'); - navigate('/prospective-dashboard'); + navigate(resolveRedirectTarget()); } else { const errorMessage = response.data?.message || 'Invalid OTP'; setError(errorMessage); diff --git a/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx b/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx index cdec13d..db4a11c 100644 --- a/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx +++ b/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx @@ -141,10 +141,6 @@ const normalizeConstitutionType = (value: string) => { }; export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) { - const { get: getSla } = useSlaBatchStatus( - requestId ? [{ entityType: 'constitutional', entityId: requestId }] : [], - Boolean(requestId) - ); const navigate = useNavigate(); const [isActionDialogOpen, setIsActionDialogOpen] = useState(false); const [actionType, setActionType] = useState<'approve' | 'reject' | 'sendBack' | 'revoke'>('approve'); @@ -158,6 +154,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: const [activeMainTab, setActiveMainTab] = useState('workflow'); const [activeDocumentTab, setActiveDocumentTab] = useState('required'); const [request, setRequest] = useState(null); + // The URL slug (`requestId` prop) is the human-readable code such as + // `CC-2026-MAY-00002`, but `sla_tracking.entityId` is a UUID column. + // Wait until the request has loaded and feed its UUID `id` to the SLA hook. + const slaEntityId: string = request?.id || ''; + const { get: getSla } = useSlaBatchStatus( + slaEntityId ? [{ entityType: 'constitutional', entityId: slaEntityId }] : [], + Boolean(slaEntityId) + ); const [auditLogs, setAuditLogs] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isActionLoading, setIsActionLoading] = useState(false); @@ -620,7 +624,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: {request.status} - + {/* Request Overview */} @@ -856,7 +860,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: DD-ZM: {ddZmApproval?.approvedByUserId ? 'Approved' : 'Pending'} {currentRoleApproval?.approvedByUserId && ( - + Approved by you )} @@ -1341,7 +1345,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
@@ -262,9 +262,9 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu )} {/* Document Requirements */} -
-

Documents Required (to be uploaded later)

-
    +
    +

    Documents Required (to be uploaded later)

    +
    • • GST Registration Certificate
    • • Firm PAN Copy
    • • Self-attested KYC documents
    • @@ -285,9 +285,9 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu > Cancel -
-
diff --git a/src/features/fnf/pages/FinanceFnFDetailsPage.tsx b/src/features/fnf/pages/FinanceFnFDetailsPage.tsx index 3299f84..4a43d75 100644 --- a/src/features/fnf/pages/FinanceFnFDetailsPage.tsx +++ b/src/features/fnf/pages/FinanceFnFDetailsPage.tsx @@ -1050,7 +1050,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr -
+

Calculation Formula

@@ -1065,7 +1065,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr - + Department Claim vs Finance Validation @@ -1580,7 +1580,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr {/* Final Settlement Summary */} - + @@ -1603,7 +1603,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
-
+
{/* Important Notes */} - +
@@ -1944,7 +1944,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
{bankDetails.length > 0 ? ( bankDetails.map((bank: any) => ( - + {bank.isPrimary && (
Primary @@ -2217,7 +2217,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
-
- +
+

Calculation Formula

diff --git a/src/features/fnf/pages/FinancePaymentDetailsPage.tsx b/src/features/fnf/pages/FinancePaymentDetailsPage.tsx index 7fbde4a..3f7ae37 100644 --- a/src/features/fnf/pages/FinancePaymentDetailsPage.tsx +++ b/src/features/fnf/pages/FinancePaymentDetailsPage.tsx @@ -282,12 +282,12 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym

{activeDeposit?.status || 'Not Started'}

diff --git a/src/features/fnf/pages/FnFDetails.tsx b/src/features/fnf/pages/FnFDetails.tsx index d3aa132..fb3895a 100644 --- a/src/features/fnf/pages/FnFDetails.tsx +++ b/src/features/fnf/pages/FnFDetails.tsx @@ -570,7 +570,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { const getStatusColor = (status: string) => { switch (status) { case "New": - return "bg-red-50 text-blue-700 border-blue-300"; + return "bg-red-50 text-re-red-hover border-red-300"; case "In Progress": return "bg-yellow-100 text-yellow-700 border-yellow-300"; case "Under Review": @@ -702,7 +702,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { {/* {canSendToStakeholders && fnfCase.status === "New" && (
-

+

Net Amount

{fnfCase.status === "Completed" && ( - +

@@ -1333,9 +1333,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { - + - + F&F Settlement Information @@ -1504,7 +1504,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
-
-

Net Settlement Amount

+
+

Net Settlement Amount

₹{Math.abs(fnfCase.netAmount || 0).toLocaleString()}

-

+

{(fnfCase.netAmount || 0) < 0 ? "Receivable from dealer" : "Payment to dealer"} @@ -1794,7 +1794,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {

{bankDetails.length > 0 ? ( bankDetails.map((bank: any) => ( - + {bank.isPrimary && (
Primary @@ -1962,11 +1962,11 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
-
-

+

+

Notifications will be sent to:

-
    +
    • • All 16 departments
    • • Case Number: {fnfCase.caseNumber}
    • • Dealer: {fnfCase.dealerName}
    • @@ -1984,7 +1984,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) { )} + {canRequestDocuments && ( + + )} + {currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role) && ['Dealer Code Generation', 'LOA Pending', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion'].includes(application.status) && ( <> {!application.dealerCode && ( - @@ -297,7 +355,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps) {application.dealerCode && !application.architectureAssignedTo && (
); } diff --git a/src/features/onboarding/components/application-details/ApplicationDetailsTabs.tsx b/src/features/onboarding/components/application-details/ApplicationDetailsTabs.tsx index 94f040d..928c2bc 100644 --- a/src/features/onboarding/components/application-details/ApplicationDetailsTabs.tsx +++ b/src/features/onboarding/components/application-details/ApplicationDetailsTabs.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { toast } from 'sonner'; import { AlertCircle, @@ -65,6 +66,11 @@ interface ApplicationDetailsTabsProps { eorChecklist: any[]; setUploadDocType: (value: string) => void; isAdmin: boolean; + /** + * Whether the viewer may see finance/process-sensitive tabs (FDD Audit + * and Payments). Restricted to DD-Admin / Super Admin by policy. + */ + canViewFinanceTabs: boolean; fetchApplication: () => void; fetchEorData: () => void; deposits: any[]; @@ -100,6 +106,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { eorChecklist, setUploadDocType, isAdmin, + canViewFinanceTabs, fetchApplication, fetchEorData, deposits, @@ -112,6 +119,15 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { auditLogActionBadgeClass, } = props; + // If the viewer loses (or never had) access to FDD/Payments tabs but the + // active tab is one of them — e.g. via direct deep-link or stale state — + // bounce them back to the Progress tab so they don't see an empty pane. + useEffect(() => { + if (!canViewFinanceTabs && (activeTab === 'fdd' || activeTab === 'payments')) { + setActiveTab('progress'); + } + }, [canViewFinanceTabs, activeTab, setActiveTab]); + const normalizeRole = (value: unknown): string => String(value || '') .trim() @@ -138,9 +154,13 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { Progress Documents Interviews - FDD Audit + {canViewFinanceTabs && ( + FDD Audit + )} EOR Checklist - Payments + {canViewFinanceTabs && ( + Payments + )} Audit Trail
@@ -326,7 +346,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { 5: 2, 6: 2, 8: 2, - 12: 2 + 13: 2 }; const stageId = Number(stage.id); const expectedCount = expectedMap[stageId]; @@ -337,7 +357,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { 5: 2, 6: 3, 8: 'LOI_APPROVAL', - 12: 'LOA_APPROVAL', + 13: 'LOA_APPROVAL', }; const mappedStageCode = stageCodeById[stageId]; const actualCount = mappedStageCode ? getApproverStatus(mappedStageCode).length : (stage.evaluators?.length || 0); @@ -770,9 +790,11 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { )} - - {renderFddAuditContent()} - + {canViewFinanceTabs && ( + + {renderFddAuditContent()} + + )}
@@ -927,6 +949,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { )} + {canViewFinanceTabs && (

Security Deposits

@@ -1101,6 +1124,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) { })()}
+ )} diff --git a/src/features/onboarding/components/application-details/RequestDocumentsModal.tsx b/src/features/onboarding/components/application-details/RequestDocumentsModal.tsx new file mode 100644 index 0000000..00f9d0e --- /dev/null +++ b/src/features/onboarding/components/application-details/RequestDocumentsModal.tsx @@ -0,0 +1,271 @@ +import { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { CheckCircle2, FileQuestion, Loader2, Mail } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { onboardingService } from '@/services/onboarding.service'; + +/** + * Modal where an admin (DD Admin / Super Admin / DD Lead / DD Head) ticks the documents + * still missing for a prospect and dispatches one email per category. Uploaded docs + * are surfaced as disabled rows for transparency. + */ +interface RequestDocumentsModalProps { + open: boolean; + onClose: () => void; + applicationId: string; + applicantName: string; + documentConfigs: any[]; + uploadedDocuments: any[]; +} + +type CategoryKey = 'LOI' | 'Statutory' | 'Architecture' | 'FDD' | 'Other'; + +const CATEGORY_LABEL: Record = { + LOI: 'LOI Documents', + Statutory: 'Statutory & Compliance', + Architecture: 'Architecture Inputs', + FDD: 'FDD / Financial', + Other: 'Other', +}; + +function categorize(stageCode?: string | null): CategoryKey { + const s = String(stageCode || '').toLowerCase(); + if (s.startsWith('loi')) return 'LOI'; + if (s.startsWith('statutory')) return 'Statutory'; + if (s.startsWith('architecture')) return 'Architecture'; + if (s.startsWith('fdd')) return 'FDD'; + return 'Other'; +} + +export function RequestDocumentsModal({ + open, + onClose, + applicationId, + applicantName, + documentConfigs, + uploadedDocuments, +}: RequestDocumentsModalProps) { + const [selected, setSelected] = useState>(new Set()); + const [dueDays, setDueDays] = useState(14); + const [customMessage, setCustomMessage] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const uploadedSet = useMemo( + () => new Set((uploadedDocuments || []).map((d: any) => d.documentType)), + [uploadedDocuments] + ); + + const grouped = useMemo(() => { + const buckets: Record = { + LOI: [], Statutory: [], Architecture: [], FDD: [], Other: [], + }; + + // De-duplicate by documentType — the master may return multiple rows per type if the + // doc is reused across stages. We keep the first occurrence (typically the canonical one). + const seen = new Set(); + for (const cfg of documentConfigs || []) { + if (!cfg?.documentType || seen.has(cfg.documentType)) continue; + seen.add(cfg.documentType); + buckets[categorize(cfg.stageCode)].push(cfg); + } + return buckets; + }, [documentConfigs]); + + const toggle = (docType: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(docType)) next.delete(docType); + else next.add(docType); + return next; + }); + }; + + const handleSubmit = async () => { + const documentTypes = Array.from(selected); + if (documentTypes.length === 0) { + toast.warning('Pick at least one document to request'); + return; + } + setSubmitting(true); + try { + const result: any = await onboardingService.requestProspectDocuments(applicationId, { + documentTypes, + dueDays, + customMessage: customMessage.trim() || undefined, + }); + const emailsSent = (result?.emailsSent || []).filter((e: any) => e.status === 'sent'); + const failed = (result?.emailsSent || []).filter((e: any) => e.status === 'failed'); + const skipped = result?.skippedAlreadyUploaded || []; + + if (emailsSent.length > 0) { + toast.success( + `Sent ${emailsSent.length} email${emailsSent.length === 1 ? '' : 's'} to ${applicantName}` + + (skipped.length ? ` — ${skipped.length} already uploaded, skipped` : '') + ); + } else if (skipped.length) { + toast.info(`No email sent — all selected documents were already uploaded.`); + } + if (failed.length) { + toast.error(`Some email categories failed: ${failed.map((f: any) => f.category).join(', ')}`); + } + + setSelected(new Set()); + setCustomMessage(''); + onClose(); + } catch (err: any) { + toast.error(err?.message || 'Failed to send document request'); + } finally { + setSubmitting(false); + } + }; + + const totalSelectable = (Object.values(grouped) as any[][]).reduce((sum, list) => sum + list.length, 0); + + return ( + { if (!o) onClose(); }}> + + + + + Request Documents from Prospect + + + Pick the documents you want {applicantName || 'the prospect'} to upload. We'll send one email per + category. Anything already uploaded is shown for context and skipped automatically. + + + + {totalSelectable === 0 ? ( +
+ +

No document configurations available for this application.

+
+ ) : ( + +
+ {(Object.keys(CATEGORY_LABEL) as CategoryKey[]).map((cat) => { + const items = grouped[cat]; + if (items.length === 0) return null; + return ( +
+

+ {CATEGORY_LABEL[cat]} +

+
+ {items.map((cfg: any) => { + const isUploaded = uploadedSet.has(cfg.documentType); + return ( +
+ {isUploaded ? ( + + ) : ( + toggle(cfg.documentType)} + className="mt-0.5" + /> + )} + +
+ ); + })} +
+
+ ); + })} +
+
+ )} + +
+
+ + setDueDays(Math.max(1, Number(e.target.value) || 14))} + className="mt-1" + /> +
+
+ +