diff --git a/src/api/API.ts b/src/api/API.ts index 71be695..ac4ac99 100644 --- a/src/api/API.ts +++ b/src/api/API.ts @@ -212,6 +212,17 @@ export const API = { getSlaConfigs: () => client.get('/master/sla-configs'), saveSlaConfig: (data: any) => client.post('/master/sla-configs', data), initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'), + getSlaOperationsDashboard: (params?: { module?: string; breachedOnly?: boolean; mineOnly?: boolean }) => + client.get('/sla/operations/dashboard', params), + postSlaBatchStatus: (data: { items: Array<{ entityType: string; entityId: string }> }) => + client.post('/sla/status/batch', data), + getQuestionnaireReminderSettings: () => client.get('/sla/settings/questionnaire-reminder'), + updateQuestionnaireReminderSettings: (data: { + enabled?: boolean; + firstAfterDays?: number; + intervalDays?: number; + maxCount?: number; + }) => client.put('/sla/settings/questionnaire-reminder', data), // Interview Configs getInterviewConfigs: (params?: any) => client.get('/master/interview-configs', params), diff --git a/src/components/sla/SlaBadge.tsx b/src/components/sla/SlaBadge.tsx new file mode 100644 index 0000000..1c6f559 --- /dev/null +++ b/src/components/sla/SlaBadge.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import { SlaBucket, SlaStatusSnapshot } from '@/services/sla.service'; + +const BUCKET_CLASS: Record = { + healthy: 'bg-emerald-100 text-emerald-800 border-emerald-200', + warning: 'bg-amber-100 text-amber-800 border-amber-200', + critical: 'bg-orange-100 text-orange-800 border-orange-200', + breached: 'bg-red-100 text-red-800 border-red-200' +}; + +const BUCKET_LABEL: Record = { + healthy: 'On track', + warning: 'Due soon', + critical: 'At risk', + breached: 'Breached' +}; + +export function SlaBadge({ status, compact }: { status: SlaStatusSnapshot | null | undefined; compact?: boolean }) { + if (!status) return null; + + const bucket = status.isPaused ? 'warning' : status.bucket; + const label = status.isPaused ? 'Paused' : BUCKET_LABEL[status.bucket]; + + return ( + + {compact ? label : `${label} · ${status.remainingLabel}`} + + ); +} diff --git a/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx b/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx index c17a293..451082e 100644 --- a/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx +++ b/src/features/constitutional/pages/ConstitutionalChangeDetails.tsx @@ -12,6 +12,8 @@ import { useState, useEffect, useMemo } from 'react'; import { User as UserType } from '@/lib/mock-data'; import { toast } from 'sonner'; import { API } from '@/api/API'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; import { OFFBOARDING_ACTIONS } from '@/lib/offboarding-actions'; import { useNavigate } from 'react-router-dom'; import { formatDateTime } from '@/components/ui/utils'; @@ -138,6 +140,10 @@ 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'); @@ -614,6 +620,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: {request.status} + {/* Request Overview */} diff --git a/src/features/constitutional/pages/ConstitutionalChangePage.tsx b/src/features/constitutional/pages/ConstitutionalChangePage.tsx index 534c5b5..b4a23b7 100644 --- a/src/features/constitutional/pages/ConstitutionalChangePage.tsx +++ b/src/features/constitutional/pages/ConstitutionalChangePage.tsx @@ -12,6 +12,8 @@ import { useState, useEffect } from 'react'; import { User as UserType } from '@/lib/mock-data'; import { toast } from 'sonner'; import { API } from '@/api/API'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; import { formatDateTime } from '@/components/ui/utils'; import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change'; import { @@ -98,6 +100,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange const [paginationMeta, setPaginationMeta] = useState(null); const [activeTab, setActiveTab] = useState('all'); const itemsPerPage = 10; + + const slaItems = requests.map((r: any) => ({ + entityType: 'constitutional', + entityId: r.id || r.requestId + })); + const { get: getSla } = useSlaBatchStatus(slaItems, requests.length > 0); const [isSubmitting, setIsSubmitting] = useState(false); const [dialogDataLoading, setDialogDataLoading] = useState(false); @@ -596,9 +604,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange - - {request.currentStage} - +
+ + {request.currentStage} + + +
@@ -674,9 +685,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
- - {request.currentStage} - +
+ + {request.currentStage} + + +
@@ -760,9 +774,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange - - {request.currentStage} - +
+ + {request.currentStage} + + +
+ + + + +
+ } /> + } + highlight="red" + /> + } /> + } + /> + } + /> +
+ + {analytics && ( + + + + + Analytics (last {analytics.periodDays} days) + + Breach rate, resolution time, and top delayed stages + + +
+ + + + +
+ {analytics.topDelayedStages.length > 0 && ( +
+

Top delayed stages

+ + + + Stage + Breaches (30d) + Active breached + + + + {analytics.topDelayedStages.map((row) => ( + + {row.stageName} + {row.breachCount} + {row.currentlyBreached} + + ))} + +
+
+ )} + {Object.keys(analytics.breachesByModule).length > 0 && ( +
+ {Object.entries(analytics.breachesByModule).map(([mod, count]) => ( + + {mod}: {count} breaches + + ))} +
+ )} +
+
+ )} + + {summary && ( + + + Aging buckets + Percent of configured TAT elapsed + + + {(Object.keys(BUCKET_LABEL) as SlaBucket[]).map((b) => ( + + {BUCKET_LABEL[b]}: {summary.buckets[b] ?? 0} + + ))} + {summary.tracksWithoutConfig > 0 && ( + + No config match: {summary.tracksWithoutConfig} + + )} + + + )} + + + + Active queue ({queue.length}) + Breached ({breachedOnly.length}) + Due soon ({dueSoon.length}) + Open breaches ({data?.breaches?.length ?? 0}) + Schedulers + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {!data?.breaches?.length ? ( +

No open breach records.

+ ) : ( + + + + Case + Module + Stage + Breached at + Status + + + + + {data.breaches.map((b) => ( + + {b.caseRef} + + + {b.module} + + + {b.stageName} + {new Date(b.breachedAt).toLocaleString()} + + {b.status} + + + + + + + + ))} + +
+ )} +
+
+
+ + +
+ + + + + Prospect questionnaire reminders + + + Email/WhatsApp to applicants in Questionnaire Pending (not internal SLA) + {qSettings?.source && ( + Source: {qSettings.source} + )} + + + +
+ setQDraft((d) => ({ ...d, enabled: Boolean(v) }))} + /> + +
+
+
+ + + setQDraft((d) => ({ ...d, firstAfterDays: Number(e.target.value) })) + } + /> +
+
+ + + setQDraft((d) => ({ ...d, intervalDays: Number(e.target.value) })) + } + /> +
+
+ + + setQDraft((d) => ({ ...d, maxCount: Number(e.target.value) })) + } + /> +
+
+ +
+
+ + + + + Infrastructure + + + + + + + + + {(data?.scheduler?.queues ?? []).map((q) => ( + + + {q.name} + {q.key && {q.key}} + + + {q.error ? ( +

{q.error}

+ ) : ( + <> + {q.counts && ( +
+ {Object.entries(q.counts).map(([k, v]) => ( + + {k}: {v} + + ))} +
+ )} + {q.repeatable?.length ? ( +
    + {q.repeatable.map((r, i) => ( +
  • + {r.name || r.pattern} + {r.next ? ` · next ${new Date(r.next).toLocaleString()}` : ''} +
  • + ))} +
+ ) : ( +

No repeatable jobs registered

+ )} + + )} +
+
+ ))} +
+
+
+ + ); +}; + +function AnalyticsStat({ + label, + value, + highlight +}: { + label: string; + value: string | number; + highlight?: boolean; +}) { + return ( +
+

{label}

+

{value}

+
+ ); +} + +function SummaryCard({ + label, + value, + icon, + highlight +}: { + label: string; + value: number | string; + icon: React.ReactNode; + highlight?: 'red'; +}) { + return ( + + +
+
+

{label}

+

{value}

+
+ {icon} +
+
+
+ ); +} + +function StatusRow({ label, value, ok }: { label: string; value: string; ok?: boolean }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/features/master/constants/emailTemplateTriggers.ts b/src/features/master/constants/emailTemplateTriggers.ts index 52604c8..f2c6989 100644 --- a/src/features/master/constants/emailTemplateTriggers.ts +++ b/src/features/master/constants/emailTemplateTriggers.ts @@ -17,6 +17,7 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [ 'EOR_COMPLETED', 'FDD_DOCUMENT_REQUEST', 'FNF_INITIATED', + 'FNF_LWD_READY', 'FNF_SUMMARY_PREPARED', 'FNF_SETTLEMENT_APPROVED', 'GENERIC_NOTIFICATION', diff --git a/src/features/master/pages/SLAConfigPage.tsx b/src/features/master/pages/SLAConfigPage.tsx index 9776071..f88e822 100644 --- a/src/features/master/pages/SLAConfigPage.tsx +++ b/src/features/master/pages/SLAConfigPage.tsx @@ -4,7 +4,9 @@ import { RootState } from '@/store'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { Clock, Plus, Pen, Bell, AlertTriangle, CheckCircle, RefreshCw } from 'lucide-react'; +import { Clock, Plus, Pen, Bell, AlertTriangle, CheckCircle, RefreshCw, Activity } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { SLAMonitorPanel } from '@/features/master/components/SLAMonitorPanel'; import { toast } from 'sonner'; import { masterService } from '@/services/master.service'; import { setMasterData } from '@/store/slices/masterSlice'; @@ -15,6 +17,8 @@ export const SLAConfigPage: React.FC = () => { const { slaConfigs, loading } = useSelector((state: RootState) => state.master); const [showSLADialog, setShowSLADialog] = useState(false); const [selectedSLA, setSelectedSLA] = useState(null); + const [loadingMore, setLoadingMore] = useState(false); + const [mainTab, setMainTab] = useState('monitor'); const fetchConfigs = async () => { try { @@ -22,7 +26,7 @@ export const SLAConfigPage: React.FC = () => { if (res && res.success) { dispatch(setMasterData({ slaConfigs: res.data })); } - } catch (error) { + } catch { toast.error('Failed to fetch SLA configurations'); } }; @@ -39,7 +43,7 @@ export const SLAConfigPage: React.FC = () => { toast.success('Default SLAs initialized successfully'); fetchConfigs(); } - } catch (error) { + } catch { toast.error('Failed to initialize default SLAs'); } finally { setLoadingMore(false); @@ -56,125 +60,151 @@ export const SLAConfigPage: React.FC = () => { setShowSLADialog(true); }; - const [loadingMore, setLoadingMore] = useState(false); - return (
-
-
-

- - SLA & Escalation Matrix -

-

Configure Turn Around Time (TAT) and escalation rules for each process stage

-
-
- - - -
+
+

+ + SLA & Escalation +

+

Configure TAT rules and monitor live queue, breaches, and schedulers

-
- {slaConfigs.map((sla) => ( - - -
-
-
- -
-
- {sla.activityName} - - Target TAT: {sla.tatHours} {sla.tatUnit} - -
-
-
- - {sla.isActive ? ( - <> Active - ) : ( - 'Disabled' - )} - - -
-
-
- -
-
- - Reminders ({sla.reminders?.length || 0}) -
-
- {(sla.reminders || []).map((reminder: any, idx: number) => ( -
- - {reminder.timeValue} {reminder.timeUnit} - - before SLA -
- ))} - {(!sla.reminders || sla.reminders.length === 0) && ( -

None configured

- )} -
-
+ + + + + Operations monitor + + Configuration matrix + -
-
- - Escalations ({sla.escalationConfigs?.length || 0}) -
-
- {(sla.escalationConfigs || []).map((esc: any, idx: number) => ( -
-
- - L{esc.level} - - after {esc.timeValue} {esc.timeUnit} -
-

{esc.notifyEmail}

-
- ))} - {(!sla.escalationConfigs || sla.escalationConfigs.length === 0) && ( -

None configured

- )} -
-
-
-
- ))} + + + - {slaConfigs.length === 0 && !loading && ( -
- -

No SLA Workflows found

-

Please initialize default configurations from the admin tools

+ +
+ + +
- )} -
- setShowSLADialog(false)} - sla={selectedSLA} - onSave={fetchConfigs} - /> +
+ {slaConfigs.map((sla) => ( + + +
+
+
+ +
+
+ {sla.activityName} + + + Target TAT: {sla.tatHours} {sla.tatUnit} + + +
+
+
+ + {sla.isActive ? ( + <> + Active + + ) : ( + 'Disabled' + )} + + +
+
+
+ +
+
+ + + Reminders ({sla.reminders?.length || 0}) + +
+
+ {(sla.reminders || []).map((reminder: any, idx: number) => ( +
+ + {reminder.timeValue} {reminder.timeUnit} + + before SLA +
+ ))} + {(!sla.reminders || sla.reminders.length === 0) && ( +

None configured

+ )} +
+
+ +
+
+ + + Escalations ({sla.escalationConfigs?.length || 0}) + +
+
+ {(sla.escalationConfigs || []).map((esc: any, idx: number) => ( +
+
+ + L{esc.level} + + + after {esc.timeValue} {esc.timeUnit} + +
+

{esc.notifyEmail}

+
+ ))} + {(!sla.escalationConfigs || sla.escalationConfigs.length === 0) && ( +

None configured

+ )} +
+
+
+
+ ))} + + {slaConfigs.length === 0 && !loading && ( +
+ +

No SLA Workflows found

+

Please initialize default configurations from the admin tools

+
+ )} +
+ + + + setShowSLADialog(false)} sla={selectedSLA} onSave={fetchConfigs} />
); }; diff --git a/src/features/onboarding/components/application-details/ApplicationDetailsHeader.tsx b/src/features/onboarding/components/application-details/ApplicationDetailsHeader.tsx index 525ed17..09e4d95 100644 --- a/src/features/onboarding/components/application-details/ApplicationDetailsHeader.tsx +++ b/src/features/onboarding/components/application-details/ApplicationDetailsHeader.tsx @@ -1,9 +1,12 @@ import { ArrowLeft, MessageSquare, ShieldAlert } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Application } from '@/lib/mock-data'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { SlaStatusSnapshot } from '@/services/sla.service'; interface ApplicationDetailsHeaderProps { application: Application; + slaStatus?: SlaStatusSnapshot | null; isNonResponsive: boolean; isAdmin: boolean; onBack: () => void; @@ -12,6 +15,7 @@ interface ApplicationDetailsHeaderProps { export function ApplicationDetailsHeader({ application, + slaStatus, isNonResponsive, isAdmin, onBack, @@ -58,6 +62,11 @@ export function ApplicationDetailsHeader({

{application.name}

{application.registrationNumber}

+ {slaStatus && ( +
+ +
+ )}
diff --git a/src/features/onboarding/pages/ApplicationDetails.tsx b/src/features/onboarding/pages/ApplicationDetails.tsx index 7b10a2d..69cc555 100644 --- a/src/features/onboarding/pages/ApplicationDetails.tsx +++ b/src/features/onboarding/pages/ApplicationDetails.tsx @@ -1,4 +1,5 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; +import { slaService, SlaStatusSnapshot } from '@/services/sla.service'; import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { onboardingService } from '@/services/onboarding.service'; import { ApplicationDetailsHeader } from '@/features/onboarding/components/application-details/ApplicationDetailsHeader'; @@ -27,6 +28,19 @@ export const ApplicationDetails = () => { const { user: currentUser } = useSelector((state: RootState) => state.auth); const applicationId = id || ''; const onBack = () => navigate(-1); + const [slaStatus, setSlaStatus] = useState(null); + + useEffect(() => { + if (!applicationId) return; + slaService + .getBatchStatus([{ entityType: 'application', entityId: applicationId }]) + .then((res) => { + if (res?.success) { + setSlaStatus(res.data[`application:${applicationId}`] ?? null); + } + }) + .catch(() => setSlaStatus(null)); + }, [applicationId]); const { application, @@ -410,6 +424,7 @@ export const ApplicationDetails = () => {
([]); + const [slaByAppId, setSlaByAppId] = useState>({}); const [locations, setLocations] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [paginationMeta, setPaginationMeta] = useState(null); @@ -87,7 +90,6 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP const applicationsData = response.data || []; setPaginationMeta(response.meta); - // Map backend data to frontend Application interface const mappedApps = applicationsData.map((app: any) => ({ id: app.id, registrationNumber: app.applicationId || 'N/A', @@ -122,6 +124,23 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP address: app.address })); setApplications(mappedApps); + + if (mappedApps.length > 0) { + slaService + .getBatchStatus(mappedApps.map((app: Application) => ({ entityType: 'application', entityId: app.id }))) + .then((slaRes) => { + if (slaRes?.success) { + const map: Record = {}; + mappedApps.forEach((app: Application) => { + map[app.id] = slaRes.data[`application:${app.id}`] ?? null; + }); + setSlaByAppId(map); + } + }) + .catch(() => setSlaByAppId({})); + } else { + setSlaByAppId({}); + } // Extract unique locations for filtering - could be optimized to fetch once if (locations.length === 0) { @@ -324,6 +343,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP Name Preferred Location Status + SLA Applicant Location Progress Applied On @@ -348,6 +368,9 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP {app.status} + + + {app.residentialAddress} diff --git a/src/features/relocation/pages/RelocationRequestDetails.tsx b/src/features/relocation/pages/RelocationRequestDetails.tsx index 8c9f7bf..9124d45 100644 --- a/src/features/relocation/pages/RelocationRequestDetails.tsx +++ b/src/features/relocation/pages/RelocationRequestDetails.tsx @@ -15,6 +15,8 @@ import { useNavigate } from 'react-router-dom'; import { User as UserType } from '@/lib/mock-data'; import { toast } from 'sonner'; import { API } from '@/api/API'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; interface RelocationRequestDetailsProps { requestId: string; @@ -197,6 +199,10 @@ const getApiErrorMessage = (error: any, fallback: string) => { }; export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) { + const { get: getSla } = useSlaBatchStatus( + requestId ? [{ entityType: 'relocation', entityId: requestId }] : [], + Boolean(requestId) + ); const navigate = useNavigate(); const [request, setRequest] = useState(null); const [auditLogs, setAuditLogs] = useState([]); @@ -577,6 +583,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel

{request.outlet?.name} ({request.outlet?.code})

+
+ +
diff --git a/src/features/relocation/pages/RelocationRequestPage.tsx b/src/features/relocation/pages/RelocationRequestPage.tsx index 60424cf..6fc80d6 100644 --- a/src/features/relocation/pages/RelocationRequestPage.tsx +++ b/src/features/relocation/pages/RelocationRequestPage.tsx @@ -14,6 +14,8 @@ import { useState, useEffect } from 'react'; import { User } from '@/lib/mock-data'; import { toast } from 'sonner'; import { API } from '@/api/API'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; import { formatDateTime } from '@/components/ui/utils'; import { Pagination, @@ -74,6 +76,12 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation // Constants const isSuperAdmin = currentUser?.role === 'Super Admin' || currentUser?.roleCode === 'Super Admin'; + const slaItems = requests.map((r: any) => ({ + entityType: 'relocation', + entityId: r.id || r.requestId + })); + const { get: getSla } = useSlaBatchStatus(slaItems, requests.length > 0); + const isCompletedRequest = (request: any) => request.status === 'Completed' || request.status === 'Closed' || request.currentStage === 'Completed'; @@ -554,6 +562,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation + {request.currentStage} @@ -629,7 +638,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
- + + {request.currentStage} @@ -701,7 +711,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
- + + {request.currentStage} diff --git a/src/features/resignation/pages/ResignationDetails.tsx b/src/features/resignation/pages/ResignationDetails.tsx index daf3cb4..9e84e52 100644 --- a/src/features/resignation/pages/ResignationDetails.tsx +++ b/src/features/resignation/pages/ResignationDetails.tsx @@ -15,10 +15,16 @@ import { useNavigate } from 'react-router-dom'; import { User as UserType } from '@/lib/mock-data'; import { toast } from 'sonner'; import { resignationService } from '@/services/resignation.service'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; import { API } from '@/api/API'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal'; import { formatDateTime } from '@/components/ui/utils'; +import { + formatOffboardingStatusLabel, + LAST_WORKING_DAY_LABEL +} from '@/lib/offboardingDisplay'; import { RESIGNATION_DOCUMENT_TYPES, RESIGNATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions'; import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles'; @@ -59,6 +65,10 @@ const RESIGNATION_STAGE_ALIASES: Record = { }; export function ResignationDetails({ resignationId, onBack, currentUser }: ResignationDetailsProps) { + const { get: getSla } = useSlaBatchStatus( + resignationId ? [{ entityType: 'resignation', entityId: resignationId }] : [], + Boolean(resignationId) + ); const getDocumentsForStage = (stageName: string, stageKey?: string) => { const allDocs = [ ...(resignationData?.documents || []), @@ -374,7 +384,9 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig // When Legal approval bumps into LWD gate for F&F initiation, guide user explicitly. if (response.data?.canForce) { - toast.info('LWD restriction hit. Use "Push to F&F" and enable "Force Initiate F&F Settlement Immediately" if urgent.'); + toast.info( + `${LAST_WORKING_DAY_LABEL} restriction: use "Push to F&F" and enable "Force Initiate F&F Settlement Immediately" if urgent.` + ); } } } catch (error: any) { @@ -382,7 +394,9 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig toast.error(error.response?.data?.message || 'Failed to submit action'); if (error?.response?.data?.canForce) { - toast.info('LWD restriction hit. Use "Push to F&F" with force option if business-approved.'); + toast.info( + `${LAST_WORKING_DAY_LABEL} restriction: use "Push to F&F" with the force option if business-approved.` + ); } } finally { setIsSubmitting(false); @@ -485,8 +499,11 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig ? 'bg-red-100 text-red-700 border-red-300' : 'bg-yellow-100 text-yellow-700 border-yellow-300' }> - {resignationData?.status === 'Settled' ? 'Completed' : (resignationData?.status || 'Pending')} + {resignationData?.status === 'Settled' + ? 'Completed' + : formatOffboardingStatusLabel(resignationData?.status || 'Pending')} + @@ -1168,7 +1185,10 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig

Manual Trigger Notice

-

Normally F&F is triggered after LWD. Use manual trigger only if urgent clearance is required.

+

+ Normally F&F is triggered after the {LAST_WORKING_DAY_LABEL.toLowerCase()}. Use manual + trigger only if urgent clearance is required. +

diff --git a/src/features/resignation/pages/ResignationPage.tsx b/src/features/resignation/pages/ResignationPage.tsx index 041a066..bd60019 100644 --- a/src/features/resignation/pages/ResignationPage.tsx +++ b/src/features/resignation/pages/ResignationPage.tsx @@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useState, useEffect } from 'react'; import { API } from '@/api/API'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; import { toast } from 'sonner'; import { User as UserType } from '@/lib/mock-data'; import { formatDateTime } from '@/components/ui/utils'; @@ -71,6 +73,9 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) { const openRequests = statusTab === 'open' ? resignations : []; const completedRequests = statusTab === 'completed' ? resignations : []; + const slaItems = resignations.map((r: any) => ({ entityType: 'resignation', entityId: r.id })); + const { get: getSla } = useSlaBatchStatus(slaItems, resignations.length > 0); + return (
{/* Header Stats */} @@ -150,6 +155,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) { {request.status} +
@@ -276,6 +282,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) { {request.status} +
@@ -337,6 +344,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) { {request.status} +
diff --git a/src/features/termination/pages/TerminationDetails.tsx b/src/features/termination/pages/TerminationDetails.tsx index eb96c65..1142288 100644 --- a/src/features/termination/pages/TerminationDetails.tsx +++ b/src/features/termination/pages/TerminationDetails.tsx @@ -14,10 +14,16 @@ import { useState, useEffect } from 'react'; import { User } from '@/lib/mock-data'; import { toast } from 'sonner'; import { terminationService } from '@/services/termination.service'; +import { SlaBadge } from '@/components/sla/SlaBadge'; +import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus'; import { useNavigate } from 'react-router-dom'; import { API } from '@/api/API'; import { formatDateTime } from '@/components/ui/utils'; -import { formatTerminationStatusLabel } from '@/lib/terminationDisplay'; +import { + formatTerminationStatusLabel, + LAST_WORKING_DAY_LABEL, + OFFBOARDING_STATUS +} from '@/lib/terminationDisplay'; import { getJointRoundCutoffMsFromTimeline, isAuditLogInCurrentJointRound @@ -33,9 +39,14 @@ interface TerminationDetailsProps { export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) { const navigate = useNavigate(); + const { get: getSla } = useSlaBatchStatus( + terminationId ? [{ entityType: 'termination', entityId: terminationId }] : [], + Boolean(terminationId) + ); const [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'hold' | null }>({ open: false, type: null }); const [remarks, setRemarks] = useState(''); const [assignToUser, setAssignToUser] = useState(''); + const [forceTriggerFnF, setForceTriggerFnF] = useState(false); const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] }); const [isLoading, setIsLoading] = useState(true); const [terminationData, setTerminationData] = useState(null); @@ -209,6 +220,16 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Terminated'].includes(status) || currentStage === 'Terminated'; const isSettlementPhase = status === 'F&F Initiated' || currentStage === 'F&F Initiated' || status === 'Settled' || status === 'FNF_INITIATED'; + const isLwdReached = (() => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const lwdRaw = terminationData.proposedLwd; + if (!lwdRaw) return true; + const lwd = new Date(lwdRaw); + lwd.setHours(0, 0, 0, 0); + return today >= lwd; + })(); + const scnJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'scn_response_eval'); const rbmJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'rbm_review'); const isScnResponseEvalStage = @@ -271,7 +292,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi (currentStage === 'CEO Final Approval' && userRole === 'CEO') || userRole === 'Super Admin' ) && ['NBH Final Approval', 'CCO Approval', 'CEO Final Approval'].includes(currentStage) && !isFinalState, - canPushToFnF: canPushToFnF && !isSettlementPhase && ['Legal - Termination Letter', 'Terminated', 'Dealer Terminated'].includes(currentStage), + canPushToFnF: + canPushToFnF && + !isSettlementPhase && + !terminationData.fnfSettlement && + (currentStage === 'Terminated' || + status === OFFBOARDING_STATUS.AWAITING_FNF || + status === OFFBOARDING_STATUS.AWAITING_FNF_LWD_PENDING) && + isLwdReached, + isLwdReached, canWithdraw: userRole === 'ASM' && currentStage === 'Request Initiated' && !isFinalState, isFinalState, isSettlementPhase @@ -296,7 +325,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi const getProgressStatus = (stageName: string) => { const isTerminal = ['Rejected', 'Revoked', 'Withdrawn'].includes(request.status); - const isSuccessFinal = ['Completed', 'Terminated', 'Settled', 'F&F Initiated', 'FNF_INITIATED', 'Awaiting F&F', 'Awaiting F&F (LWD Pending)'].includes(request.status) || request.currentStage === 'Terminated'; + const isSuccessFinal = [ + 'Completed', + 'Terminated', + 'Settled', + 'F&F Initiated', + 'FNF_INITIATED', + OFFBOARDING_STATUS.AWAITING_FNF, + OFFBOARDING_STATUS.AWAITING_FNF_LWD_PENDING + ].includes(request.status) || request.currentStage === 'Terminated'; // For terminal states, we determine the last active stage from the timeline to keep the track visible let currentStageForProgress = request.currentStage || request.status; @@ -494,7 +531,9 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke' || actionType === 'hold') { response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks); } else if (actionType === 'pushfnf') { - response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks); + response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks, { + force: forceTriggerFnF + }); } else { toast.error('Action logic not fully implemented for this type'); setIsProcessing(false); @@ -503,7 +542,11 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi if (response && (response.success === false || response.ok === false)) { console.error('[TerminationDetails] Action failed:', response); - toast.error(response.message || response.data?.message || 'Failed to perform action'); + const failMsg = response.message || response.data?.message || 'Failed to perform action'; + toast.error(failMsg); + if (response.canForce || response.data?.canForce) { + toast.info('Enable "Force initiate F&F" in the dialog if an exception is approved.'); + } setIsProcessing(false); return; } @@ -521,10 +564,14 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi setActionDialog({ open: false, type: null }); setRemarks(''); setAssignToUser(''); + setForceTriggerFnF(false); fetchTermination(); } catch (error: any) { const msg = error.response?.data?.message || 'Failed to perform action'; toast.error(msg); + if (error?.response?.data?.canForce) { + toast.info('Enable "Force initiate F&F" in the dialog if an exception is approved.'); + } } finally { setIsProcessing(false); } @@ -578,6 +625,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi }> {request.status === 'Settled' ? 'Completed' : formatTerminationStatusLabel(request.status || 'Pending')} +
@@ -1099,6 +1147,29 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi )} + {canPushToFnF && + !permissions.isSettlementPhase && + !permissions.canPushToFnF && + !request.fnfSettlement && + (request.currentStage === 'Terminated' || + request.status === OFFBOARDING_STATUS.AWAITING_FNF || + request.status === OFFBOARDING_STATUS.AWAITING_FNF_LWD_PENDING) && + !permissions.isLwdReached && ( + + + + Push to F&F locked until {LAST_WORKING_DAY_LABEL} + + + {LAST_WORKING_DAY_LABEL} is{' '} + {request.proposedLwd + ? new Date(request.proposedLwd).toLocaleDateString('en-IN', { dateStyle: 'medium' }) + : 'not set'} + . Admins are notified by email when the {LAST_WORKING_DAY_LABEL.toLowerCase()} is reached. + + + )} + {!permissions.isFinalState && (
) : actionDialog.type === 'pushfnf' ? ( -
+
+

+ F&F can be started on or after the {LAST_WORKING_DAY_LABEL} + {request.proposedLwd + ? ` (${new Date(request.proposedLwd).toLocaleDateString('en-IN', { dateStyle: 'medium' })})` + : ''} + . +