few de demo bugs and sla tracker implemeted alog with sla monitor screen

This commit is contained in:
Laxman 2026-05-18 21:10:22 +05:30
parent 81d4dd493f
commit 61deac775c
21 changed files with 1285 additions and 149 deletions

View File

@ -212,6 +212,17 @@ export const API = {
getSlaConfigs: () => client.get('/master/sla-configs'), getSlaConfigs: () => client.get('/master/sla-configs'),
saveSlaConfig: (data: any) => client.post('/master/sla-configs', data), saveSlaConfig: (data: any) => client.post('/master/sla-configs', data),
initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'), 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 // Interview Configs
getInterviewConfigs: (params?: any) => client.get('/master/interview-configs', params), getInterviewConfigs: (params?: any) => client.get('/master/interview-configs', params),

View File

@ -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<SlaBucket, string> = {
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<SlaBucket, string> = {
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 (
<Badge
variant="outline"
className={`text-[10px] font-semibold ${BUCKET_CLASS[bucket]} ${compact ? 'px-1.5' : ''}`}
title={`${status.stageName} · ${status.remainingLabel} (${status.percentUsed}% of TAT)`}
>
{compact ? label : `${label} · ${status.remainingLabel}`}
</Badge>
);
}

View File

@ -12,6 +12,8 @@ import { useState, useEffect, useMemo } from 'react';
import { User as UserType } from '@/lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { OFFBOARDING_ACTIONS } from '@/lib/offboarding-actions'; import { OFFBOARDING_ACTIONS } from '@/lib/offboarding-actions';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
@ -138,6 +140,10 @@ const normalizeConstitutionType = (value: string) => {
}; };
export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) { export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) {
const { get: getSla } = useSlaBatchStatus(
requestId ? [{ entityType: 'constitutional', entityId: requestId }] : [],
Boolean(requestId)
);
const navigate = useNavigate(); const navigate = useNavigate();
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false); const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'sendBack' | 'revoke'>('approve'); const [actionType, setActionType] = useState<'approve' | 'reject' | 'sendBack' | 'revoke'>('approve');
@ -614,6 +620,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{request.status} {request.status}
</Badge> </Badge>
<SlaBadge status={getSla('constitutional', requestId)} />
</div> </div>
{/* Request Overview */} {/* Request Overview */}

View File

@ -12,6 +12,8 @@ import { useState, useEffect } from 'react';
import { User as UserType } from '@/lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change'; import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
import { import {
@ -98,6 +100,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
const [paginationMeta, setPaginationMeta] = useState<any>(null); const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [activeTab, setActiveTab] = useState('all'); const [activeTab, setActiveTab] = useState('all');
const itemsPerPage = 10; 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 [isSubmitting, setIsSubmitting] = useState(false);
const [dialogDataLoading, setDialogDataLoading] = useState(false); const [dialogDataLoading, setDialogDataLoading] = useState(false);
@ -596,9 +604,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700"> <div className="flex flex-wrap items-center gap-1">
{request.currentStage} <Badge variant="outline" className="border-slate-300 text-slate-700">
</Badge> {request.currentStage}
</Badge>
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -674,9 +685,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700"> <div className="flex flex-wrap items-center gap-1">
{request.currentStage} <Badge variant="outline" className="border-slate-300 text-slate-700">
</Badge> {request.currentStage}
</Badge>
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
@ -760,9 +774,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700"> <div className="flex flex-wrap items-center gap-1">
{request.currentStage} <Badge variant="outline" className="border-slate-300 text-slate-700">
</Badge> {request.currentStage}
</Badge>
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button <Button

View File

@ -0,0 +1,627 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle2,
Clock,
Download,
ExternalLink,
Mail,
RefreshCw,
Server,
Timer
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import {
slaService,
SlaBucket,
SlaOperationsDashboard,
SlaQueueItem,
QuestionnaireReminderSettings
} from '@/services/sla.service';
const BUCKET_LABEL: Record<SlaBucket, string> = {
healthy: '025% elapsed',
warning: '2675% elapsed',
critical: '7699% elapsed',
breached: 'Breached'
};
const BUCKET_CLASS: Record<SlaBucket, string> = {
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 MODULES = ['ALL', 'ONBOARDING', 'TERMINATION', 'RESIGNATION', 'RELOCATION', 'CONSTITUTIONAL', 'FNF'];
function BucketBadge({ bucket }: { bucket: SlaBucket }) {
return (
<Badge variant="outline" className={`text-[10px] font-semibold ${BUCKET_CLASS[bucket]}`}>
{BUCKET_LABEL[bucket]}
</Badge>
);
}
function ProgressCell({ percent, bucket }: { percent: number; bucket: SlaBucket }) {
const barColor =
bucket === 'breached'
? 'bg-red-500'
: bucket === 'critical'
? 'bg-orange-500'
: bucket === 'warning'
? 'bg-amber-500'
: 'bg-emerald-500';
const capped = Math.min(percent, 100);
return (
<div className="min-w-[100px]">
<div className="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden mb-1">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${capped}%` }} />
</div>
<span className="text-[10px] text-slate-500">{percent}% of TAT</span>
<div className="mt-1">
<BucketBadge bucket={bucket} />
</div>
</div>
);
}
function QueueTable({ items, emptyMessage }: { items: SlaQueueItem[]; emptyMessage: string }) {
if (items.length === 0) {
return <p className="text-sm text-slate-500 py-8 text-center">{emptyMessage}</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Case</TableHead>
<TableHead>Module</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Time</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.trackingId} className={row.bucket === 'breached' ? 'bg-red-50/40' : undefined}>
<TableCell className="font-medium text-slate-900">{row.caseRef}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">
{row.module}
</Badge>
</TableCell>
<TableCell className="text-slate-700 max-w-[200px] truncate" title={row.stageName}>
{row.stageName}
</TableCell>
<TableCell className="text-slate-600 text-xs">{row.ownerRole}</TableCell>
<TableCell>
<ProgressCell percent={row.percentUsed} bucket={row.bucket} />
</TableCell>
<TableCell className="text-xs text-slate-600">
<div>{row.remainingLabel}</div>
<span className="text-[10px] text-slate-400 block">
Due {new Date(row.deadline).toLocaleString()}
</span>
</TableCell>
<TableCell>
<a
href={row.link}
target="_blank"
rel="noopener noreferrer"
className="text-amber-600 hover:text-amber-800"
title="Open case"
>
<ExternalLink className="w-4 h-4" />
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export const SLAMonitorPanel: React.FC = () => {
const [data, setData] = useState<SlaOperationsDashboard | null>(null);
const [loading, setLoading] = useState(true);
const [moduleFilter, setModuleFilter] = useState('ALL');
const [tab, setTab] = useState('queue');
const [mineOnly, setMineOnly] = useState(false);
const [qSettings, setQSettings] = useState<QuestionnaireReminderSettings | null>(null);
const [qDraft, setQDraft] = useState<Partial<QuestionnaireReminderSettings>>({});
const [savingQ, setSavingQ] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await slaService.getOperationsDashboard({
module: moduleFilter === 'ALL' ? undefined : moduleFilter,
mineOnly
});
if (res?.success) {
setData(res.data);
} else {
toast.error('Failed to load SLA monitor');
}
} catch {
toast.error('Failed to load SLA monitor');
} finally {
setLoading(false);
}
}, [moduleFilter, tab, mineOnly]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
slaService
.getQuestionnaireReminderSettings()
.then((res) => {
if (res?.success && res.data) {
setQSettings(res.data);
setQDraft(res.data);
}
})
.catch(() => undefined);
}, []);
const saveQuestionnaireSettings = async () => {
setSavingQ(true);
try {
const res = await slaService.updateQuestionnaireReminderSettings(qDraft);
if (res?.success) {
setQSettings(res.data);
setQDraft(res.data);
toast.success('Questionnaire reminder settings saved');
}
} catch {
toast.error('Failed to save settings');
} finally {
setSavingQ(false);
}
};
const summary = data?.summary;
const analytics = data?.analytics;
const queue = data?.activeQueue ?? [];
const breachedOnly = queue.filter((q) => q.bucket === 'breached');
const dueSoon = queue.filter((q) => q.bucket === 'warning' || q.bucket === 'critical');
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Activity className="w-5 h-5 text-amber-600" />
SLA Operations Monitor
</h2>
<p className="text-sm text-slate-500">
Live queue, aging buckets, breaches, and scheduler health
{data?.generatedAt && (
<span className="ml-2 text-slate-400">
· Updated {new Date(data.generatedAt).toLocaleTimeString()}
</span>
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 mr-2">
<Checkbox id="sla-mine-only" checked={mineOnly} onCheckedChange={(v) => setMineOnly(Boolean(v))} />
<Label htmlFor="sla-mine-only" className="text-sm cursor-pointer">
My queue only
</Label>
</div>
<Select value={moduleFilter} onValueChange={setModuleFilter}>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Module" />
</SelectTrigger>
<SelectContent>
{MODULES.map((m) => (
<SelectItem key={m} value={m}>
{m === 'ALL' ? 'All modules' : m}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
slaService
.exportOperationsCsv({
module: moduleFilter === 'ALL' ? undefined : moduleFilter,
mineOnly
})
.catch(() => toast.error('Export failed'))
}
>
<Download className="w-4 h-4 mr-1" />
Export CSV
</Button>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-3">
<SummaryCard label="In queue" value={summary?.activeCount ?? '—'} icon={<Clock className="w-4 h-4 text-blue-600" />} />
<SummaryCard
label="Breached"
value={summary?.breachedCount ?? '—'}
icon={<AlertTriangle className="w-4 h-4 text-red-600" />}
highlight="red"
/>
<SummaryCard label="Due soon" value={summary?.dueSoonCount ?? '—'} icon={<Timer className="w-4 h-4 text-amber-600" />} />
<SummaryCard
label="On track"
value={summary?.onTrackCount ?? '—'}
icon={<CheckCircle2 className="w-4 h-4 text-emerald-600" />}
/>
<SummaryCard
label="Open breaches"
value={summary?.openBreachesCount ?? '—'}
icon={<AlertTriangle className="w-4 h-4 text-re-red" />}
/>
</div>
{analytics && (
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-amber-600" />
Analytics (last {analytics.periodDays} days)
</CardTitle>
<CardDescription>Breach rate, resolution time, and top delayed stages</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<AnalyticsStat label="Tracks started" value={analytics.tracksStarted} />
<AnalyticsStat label="Breach rate" value={`${analytics.breachRatePercent}%`} highlight />
<AnalyticsStat
label="Avg resolution"
value={analytics.avgResolutionHours != null ? `${analytics.avgResolutionHours}h` : '—'}
/>
<AnalyticsStat label="Completed tracks" value={analytics.completedTracks} />
</div>
{analytics.topDelayedStages.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase mb-2">Top delayed stages</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Breaches (30d)</TableHead>
<TableHead>Active breached</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analytics.topDelayedStages.map((row) => (
<TableRow key={row.stageName}>
<TableCell className="font-medium">{row.stageName}</TableCell>
<TableCell>{row.breachCount}</TableCell>
<TableCell>{row.currentlyBreached}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{Object.keys(analytics.breachesByModule).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(analytics.breachesByModule).map(([mod, count]) => (
<Badge key={mod} variant="outline">
{mod}: {count} breaches
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)}
{summary && (
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Aging buckets</CardTitle>
<CardDescription>Percent of configured TAT elapsed</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{(Object.keys(BUCKET_LABEL) as SlaBucket[]).map((b) => (
<Badge key={b} variant="outline" className={`px-3 py-1 ${BUCKET_CLASS[b]}`}>
{BUCKET_LABEL[b]}: {summary.buckets[b] ?? 0}
</Badge>
))}
{summary.tracksWithoutConfig > 0 && (
<Badge variant="outline" className="bg-slate-100 text-slate-600">
No config match: {summary.tracksWithoutConfig}
</Badge>
)}
</CardContent>
</Card>
)}
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="bg-slate-100">
<TabsTrigger value="queue">Active queue ({queue.length})</TabsTrigger>
<TabsTrigger value="breached">Breached ({breachedOnly.length})</TabsTrigger>
<TabsTrigger value="due">Due soon ({dueSoon.length})</TabsTrigger>
<TabsTrigger value="breach-log">Open breaches ({data?.breaches?.length ?? 0})</TabsTrigger>
<TabsTrigger value="schedulers">Schedulers</TabsTrigger>
</TabsList>
<TabsContent value="queue" className="mt-4">
<Card>
<CardContent className="pt-4">
<QueueTable items={queue} emptyMessage="No active SLA timers in queue." />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="breached" className="mt-4">
<Card>
<CardContent className="pt-4">
<QueueTable items={breachedOnly} emptyMessage="No breached items — all active SLAs are within TAT." />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="due" className="mt-4">
<Card>
<CardContent className="pt-4">
<QueueTable items={dueSoon} emptyMessage="Nothing in the 2699% window right now." />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="breach-log" className="mt-4">
<Card>
<CardContent className="pt-4">
{!data?.breaches?.length ? (
<p className="text-sm text-slate-500 py-8 text-center">No open breach records.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Case</TableHead>
<TableHead>Module</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Breached at</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{data.breaches.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.caseRef}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">
{b.module}
</Badge>
</TableCell>
<TableCell className="truncate max-w-[200px]">{b.stageName}</TableCell>
<TableCell className="text-xs">{new Date(b.breachedAt).toLocaleString()}</TableCell>
<TableCell>
<Badge variant="outline">{b.status}</Badge>
</TableCell>
<TableCell>
<a href={b.link} target="_blank" rel="noopener noreferrer" className="text-amber-600">
<ExternalLink className="w-4 h-4" />
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="schedulers" className="mt-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Mail className="w-4 h-4" />
Prospect questionnaire reminders
</CardTitle>
<CardDescription>
Email/WhatsApp to applicants in Questionnaire Pending (not internal SLA)
{qSettings?.source && (
<span className="block text-slate-400">Source: {qSettings.source}</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<Checkbox
id="q-enabled"
checked={Boolean(qDraft.enabled)}
onCheckedChange={(v) => setQDraft((d) => ({ ...d, enabled: Boolean(v) }))}
/>
<Label htmlFor="q-enabled">Scheduler enabled</Label>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs">First after (days)</Label>
<Input
type="number"
min={1}
value={qDraft.firstAfterDays ?? ''}
onChange={(e) =>
setQDraft((d) => ({ ...d, firstAfterDays: Number(e.target.value) }))
}
/>
</div>
<div>
<Label className="text-xs">Interval (days)</Label>
<Input
type="number"
min={1}
value={qDraft.intervalDays ?? ''}
onChange={(e) =>
setQDraft((d) => ({ ...d, intervalDays: Number(e.target.value) }))
}
/>
</div>
<div>
<Label className="text-xs">Max reminders</Label>
<Input
type="number"
min={1}
value={qDraft.maxCount ?? ''}
onChange={(e) =>
setQDraft((d) => ({ ...d, maxCount: Number(e.target.value) }))
}
/>
</div>
</div>
<Button size="sm" onClick={saveQuestionnaireSettings} disabled={savingQ}>
{savingQ ? 'Saving…' : 'Save reminder settings'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Server className="w-4 h-4" />
Infrastructure
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<StatusRow label="Redis" value={data?.scheduler?.redisEnabled ? 'Enabled' : 'Disabled'} ok={data?.scheduler?.redisEnabled} />
<StatusRow label="SLA fast mode" value={data?.scheduler?.slaFastMode ? 'On' : 'Off'} ok={!data?.scheduler?.slaFastMode} />
<StatusRow
label="Questionnaire fast mode"
value={data?.scheduler?.questionnaireFastMode ? 'On' : 'Off'}
ok={!data?.scheduler?.questionnaireFastMode}
/>
</CardContent>
</Card>
{(data?.scheduler?.queues ?? []).map((q) => (
<Card key={q.name}>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{q.name}</CardTitle>
{q.key && <CardDescription>{q.key}</CardDescription>}
</CardHeader>
<CardContent className="text-sm">
{q.error ? (
<p className="text-red-600">{q.error}</p>
) : (
<>
{q.counts && (
<div className="flex flex-wrap gap-2 mb-3">
{Object.entries(q.counts).map(([k, v]) => (
<Badge key={k} variant="outline">
{k}: {v}
</Badge>
))}
</div>
)}
{q.repeatable?.length ? (
<ul className="text-xs text-slate-600 space-y-1">
{q.repeatable.map((r, i) => (
<li key={i}>
{r.name || r.pattern}
{r.next ? ` · next ${new Date(r.next).toLocaleString()}` : ''}
</li>
))}
</ul>
) : (
<p className="text-slate-400 text-xs">No repeatable jobs registered</p>
)}
</>
)}
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</div>
);
};
function AnalyticsStat({
label,
value,
highlight
}: {
label: string;
value: string | number;
highlight?: boolean;
}) {
return (
<div
className={`rounded-lg border p-3 ${highlight ? 'border-red-200 bg-red-50/40' : 'border-slate-200 bg-slate-50/50'}`}
>
<p className="text-[10px] uppercase tracking-wide text-slate-500">{label}</p>
<p className="text-xl font-bold text-slate-900">{value}</p>
</div>
);
}
function SummaryCard({
label,
value,
icon,
highlight
}: {
label: string;
value: number | string;
icon: React.ReactNode;
highlight?: 'red';
}) {
return (
<Card className={highlight === 'red' ? 'border-red-200 bg-red-50/30' : 'border-slate-200'}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-slate-900">{value}</p>
</div>
{icon}
</div>
</CardContent>
</Card>
);
}
function StatusRow({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<div className="flex justify-between">
<span className="text-slate-600">{label}</span>
<span className={ok === false ? 'text-amber-700 font-medium' : 'text-slate-900'}>{value}</span>
</div>
);
}

View File

@ -17,6 +17,7 @@ export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'EOR_COMPLETED', 'EOR_COMPLETED',
'FDD_DOCUMENT_REQUEST', 'FDD_DOCUMENT_REQUEST',
'FNF_INITIATED', 'FNF_INITIATED',
'FNF_LWD_READY',
'FNF_SUMMARY_PREPARED', 'FNF_SUMMARY_PREPARED',
'FNF_SETTLEMENT_APPROVED', 'FNF_SETTLEMENT_APPROVED',
'GENERIC_NOTIFICATION', 'GENERIC_NOTIFICATION',

View File

@ -4,7 +4,9 @@ import { RootState } from '@/store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; 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 { toast } from 'sonner';
import { masterService } from '@/services/master.service'; import { masterService } from '@/services/master.service';
import { setMasterData } from '@/store/slices/masterSlice'; import { setMasterData } from '@/store/slices/masterSlice';
@ -15,6 +17,8 @@ export const SLAConfigPage: React.FC = () => {
const { slaConfigs, loading } = useSelector((state: RootState) => state.master); const { slaConfigs, loading } = useSelector((state: RootState) => state.master);
const [showSLADialog, setShowSLADialog] = useState(false); const [showSLADialog, setShowSLADialog] = useState(false);
const [selectedSLA, setSelectedSLA] = useState<any>(null); const [selectedSLA, setSelectedSLA] = useState<any>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [mainTab, setMainTab] = useState('monitor');
const fetchConfigs = async () => { const fetchConfigs = async () => {
try { try {
@ -22,7 +26,7 @@ export const SLAConfigPage: React.FC = () => {
if (res && res.success) { if (res && res.success) {
dispatch(setMasterData({ slaConfigs: res.data })); dispatch(setMasterData({ slaConfigs: res.data }));
} }
} catch (error) { } catch {
toast.error('Failed to fetch SLA configurations'); toast.error('Failed to fetch SLA configurations');
} }
}; };
@ -39,7 +43,7 @@ export const SLAConfigPage: React.FC = () => {
toast.success('Default SLAs initialized successfully'); toast.success('Default SLAs initialized successfully');
fetchConfigs(); fetchConfigs();
} }
} catch (error) { } catch {
toast.error('Failed to initialize default SLAs'); toast.error('Failed to initialize default SLAs');
} finally { } finally {
setLoadingMore(false); setLoadingMore(false);
@ -56,125 +60,151 @@ export const SLAConfigPage: React.FC = () => {
setShowSLADialog(true); setShowSLADialog(true);
}; };
const [loadingMore, setLoadingMore] = useState(false);
return ( return (
<div className="space-y-6 max-w-7xl mx-auto"> <div className="space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between"> <div>
<div> <h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2"> <Clock className="w-6 h-6 text-amber-600" />
<Clock className="w-6 h-6 text-amber-600" /> SLA & Escalation
SLA & Escalation Matrix </h1>
</h1> <p className="text-slate-500">Configure TAT rules and monitor live queue, breaches, and schedulers</p>
<p className="text-slate-500">Configure Turn Around Time (TAT) and escalation rules for each process stage</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleInitialize} disabled={loading || loadingMore}>
<RefreshCw className={`w-4 h-4 mr-2 ${loadingMore ? 'animate-spin' : ''}`} />
Initialize Defaults
</Button>
<Button onClick={handleAddSLA} disabled={loading} className="bg-re-red hover:bg-re-red-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Manual SLA
</Button>
<Button variant="ghost" size="icon" onClick={fetchConfigs} disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <Tabs value={mainTab} onValueChange={setMainTab}>
{slaConfigs.map((sla) => ( <TabsList className="bg-slate-100">
<Card key={sla.id} className="border-slate-200 hover:shadow-md transition-shadow group overflow-hidden"> <TabsTrigger value="monitor" className="flex items-center gap-1.5">
<CardHeader className="pb-3 bg-gradient-to-br from-white to-slate-50/50"> <Activity className="w-4 h-4" />
<div className="flex items-start justify-between"> Operations monitor
<div className="flex items-center gap-3"> </TabsTrigger>
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-100 flex items-center justify-center"> <TabsTrigger value="config">Configuration matrix</TabsTrigger>
<Clock className="w-5 h-5 text-amber-600" /> </TabsList>
</div>
<div>
<CardTitle className="text-lg">{sla.activityName}</CardTitle>
<CardDescription className="flex items-center gap-1.5 mt-0.5">
<span className="font-semibold text-amber-700">Target TAT: {sla.tatHours} {sla.tatUnit}</span>
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={sla.isActive ? "default" : "secondary"} className={sla.isActive ? "bg-emerald-600" : "bg-slate-400"}>
{sla.isActive ? (
<><CheckCircle className="w-3 h-3 mr-1" /> Active</>
) : (
'Disabled'
)}
</Badge>
<Button variant="ghost" size="icon" onClick={() => handleEdit(sla)} className="h-8 w-8 text-slate-400 hover:text-amber-600">
<Pen className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-4 grid grid-cols-2 gap-4">
<div className="border-l-2 border-blue-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<Bell className="w-4 h-4 text-blue-600" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">Reminders ({sla.reminders?.length || 0})</span>
</div>
<div className="space-y-1.5">
{(sla.reminders || []).map((reminder: any, idx: number) => (
<div key={idx} className="text-xs text-slate-600 flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-4.5 bg-blue-50 border-blue-200">
{reminder.timeValue} {reminder.timeUnit}
</Badge>
<span>before SLA</span>
</div>
))}
{(!sla.reminders || sla.reminders.length === 0) && (
<p className="text-[10px] text-slate-400 italic">None configured</p>
)}
</div>
</div>
<div className="border-l-2 border-red-400 pl-3"> <TabsContent value="monitor" className="mt-6">
<div className="flex items-center gap-2 mb-2"> <SLAMonitorPanel />
<AlertTriangle className="w-4 h-4 text-re-red" /> </TabsContent>
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">Escalations ({sla.escalationConfigs?.length || 0})</span>
</div>
<div className="space-y-2">
{(sla.escalationConfigs || []).map((esc: any, idx: number) => (
<div key={idx} className="text-[11px]">
<div className="flex items-center gap-1.5 text-slate-900 font-medium">
<Badge variant="outline" className="text-[9px] h-4 px-1 bg-red-50 border-red-200 text-re-red-hover">
L{esc.level}
</Badge>
<span>after {esc.timeValue} {esc.timeUnit}</span>
</div>
<p className="text-slate-500 ml-8 font-mono truncate">{esc.notifyEmail}</p>
</div>
))}
{(!sla.escalationConfigs || sla.escalationConfigs.length === 0) && (
<p className="text-[10px] text-slate-400 italic">None configured</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
{slaConfigs.length === 0 && !loading && ( <TabsContent value="config" className="mt-6 space-y-6">
<div className="lg:col-span-2 py-20 text-center border-2 border-dashed rounded-2xl bg-slate-50/50"> <div className="flex items-center justify-end gap-3">
<Clock className="w-12 h-12 text-slate-200 mx-auto mb-4" /> <Button variant="outline" onClick={handleInitialize} disabled={loading || loadingMore}>
<h3 className="text-slate-600 font-medium">No SLA Workflows found</h3> <RefreshCw className={`w-4 h-4 mr-2 ${loadingMore ? 'animate-spin' : ''}`} />
<p className="text-slate-400 text-sm">Please initialize default configurations from the admin tools</p> Initialize Defaults
</Button>
<Button onClick={handleAddSLA} disabled={loading} className="bg-re-red hover:bg-re-red-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Manual SLA
</Button>
<Button variant="ghost" size="icon" onClick={fetchConfigs} disabled={loading}>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div> </div>
)}
</div>
<SLADialog <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
isOpen={showSLADialog} {slaConfigs.map((sla) => (
onClose={() => setShowSLADialog(false)} <Card key={sla.id} className="border-slate-200 hover:shadow-md transition-shadow group overflow-hidden">
sla={selectedSLA} <CardHeader className="pb-3 bg-gradient-to-br from-white to-slate-50/50">
onSave={fetchConfigs} <div className="flex items-start justify-between">
/> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-100 flex items-center justify-center">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<div>
<CardTitle className="text-lg">{sla.activityName}</CardTitle>
<CardDescription className="flex items-center gap-1.5 mt-0.5">
<span className="font-semibold text-amber-700">
Target TAT: {sla.tatHours} {sla.tatUnit}
</span>
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant={sla.isActive ? 'default' : 'secondary'}
className={sla.isActive ? 'bg-emerald-600' : 'bg-slate-400'}
>
{sla.isActive ? (
<>
<CheckCircle className="w-3 h-3 mr-1" /> Active
</>
) : (
'Disabled'
)}
</Badge>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(sla)}
className="h-8 w-8 text-slate-400 hover:text-amber-600"
>
<Pen className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-4 grid grid-cols-2 gap-4">
<div className="border-l-2 border-blue-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<Bell className="w-4 h-4 text-blue-600" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">
Reminders ({sla.reminders?.length || 0})
</span>
</div>
<div className="space-y-1.5">
{(sla.reminders || []).map((reminder: any, idx: number) => (
<div key={idx} className="text-xs text-slate-600 flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-4.5 bg-blue-50 border-blue-200">
{reminder.timeValue} {reminder.timeUnit}
</Badge>
<span>before SLA</span>
</div>
))}
{(!sla.reminders || sla.reminders.length === 0) && (
<p className="text-[10px] text-slate-400 italic">None configured</p>
)}
</div>
</div>
<div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-re-red" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">
Escalations ({sla.escalationConfigs?.length || 0})
</span>
</div>
<div className="space-y-2">
{(sla.escalationConfigs || []).map((esc: any, idx: number) => (
<div key={idx} className="text-[11px]">
<div className="flex items-center gap-1.5 text-slate-900 font-medium">
<Badge variant="outline" className="text-[9px] h-4 px-1 bg-red-50 border-red-200 text-re-red-hover">
L{esc.level}
</Badge>
<span>
after {esc.timeValue} {esc.timeUnit}
</span>
</div>
<p className="text-slate-500 ml-8 font-mono truncate">{esc.notifyEmail}</p>
</div>
))}
{(!sla.escalationConfigs || sla.escalationConfigs.length === 0) && (
<p className="text-[10px] text-slate-400 italic">None configured</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
{slaConfigs.length === 0 && !loading && (
<div className="lg:col-span-2 py-20 text-center border-2 border-dashed rounded-2xl bg-slate-50/50">
<Clock className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-slate-600 font-medium">No SLA Workflows found</h3>
<p className="text-slate-400 text-sm">Please initialize default configurations from the admin tools</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
<SLADialog isOpen={showSLADialog} onClose={() => setShowSLADialog(false)} sla={selectedSLA} onSave={fetchConfigs} />
</div> </div>
); );
}; };

View File

@ -1,9 +1,12 @@
import { ArrowLeft, MessageSquare, ShieldAlert } from 'lucide-react'; import { ArrowLeft, MessageSquare, ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Application } from '@/lib/mock-data'; import { Application } from '@/lib/mock-data';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { SlaStatusSnapshot } from '@/services/sla.service';
interface ApplicationDetailsHeaderProps { interface ApplicationDetailsHeaderProps {
application: Application; application: Application;
slaStatus?: SlaStatusSnapshot | null;
isNonResponsive: boolean; isNonResponsive: boolean;
isAdmin: boolean; isAdmin: boolean;
onBack: () => void; onBack: () => void;
@ -12,6 +15,7 @@ interface ApplicationDetailsHeaderProps {
export function ApplicationDetailsHeader({ export function ApplicationDetailsHeader({
application, application,
slaStatus,
isNonResponsive, isNonResponsive,
isAdmin, isAdmin,
onBack, onBack,
@ -58,6 +62,11 @@ export function ApplicationDetailsHeader({
<div className="truncate"> <div className="truncate">
<h1 className="text-slate-900 truncate leading-tight" data-testid="onboarding-details-application-name">{application.name}</h1> <h1 className="text-slate-900 truncate leading-tight" data-testid="onboarding-details-application-name">{application.name}</h1>
<p className="text-slate-600 truncate text-sm" data-testid="onboarding-details-registration-number">{application.registrationNumber}</p> <p className="text-slate-600 truncate text-sm" data-testid="onboarding-details-registration-number">{application.registrationNumber}</p>
{slaStatus && (
<div className="mt-1">
<SlaBadge status={slaStatus} />
</div>
)}
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@ -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 { useParams, useNavigate, useLocation } from 'react-router-dom';
import { onboardingService } from '@/services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { ApplicationDetailsHeader } from '@/features/onboarding/components/application-details/ApplicationDetailsHeader'; 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 { user: currentUser } = useSelector((state: RootState) => state.auth);
const applicationId = id || ''; const applicationId = id || '';
const onBack = () => navigate(-1); const onBack = () => navigate(-1);
const [slaStatus, setSlaStatus] = useState<SlaStatusSnapshot | null>(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 { const {
application, application,
@ -410,6 +424,7 @@ export const ApplicationDetails = () => {
<div className="space-y-6"> <div className="space-y-6">
<ApplicationDetailsHeader <ApplicationDetailsHeader
application={application} application={application}
slaStatus={slaStatus}
isNonResponsive={isNonResponsive} isNonResponsive={isNonResponsive}
isAdmin={isAdmin} isAdmin={isAdmin}
onBack={onBack} onBack={onBack}

View File

@ -3,6 +3,8 @@ import { toast } from 'sonner';
import { ApplicationStatus, Application } from '@/lib/mock-data'; import { ApplicationStatus, Application } from '@/lib/mock-data';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { onboardingService } from '@/services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {
@ -62,6 +64,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
// Real Data Integration // Real Data Integration
const [applications, setApplications] = useState<Application[]>([]); const [applications, setApplications] = useState<Application[]>([]);
const [slaByAppId, setSlaByAppId] = useState<Record<string, SlaStatusSnapshot | null>>({});
const [locations, setLocations] = useState<string[]>([]); const [locations, setLocations] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null); const [paginationMeta, setPaginationMeta] = useState<any>(null);
@ -87,7 +90,6 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const applicationsData = response.data || []; const applicationsData = response.data || [];
setPaginationMeta(response.meta); setPaginationMeta(response.meta);
// Map backend data to frontend Application interface
const mappedApps = applicationsData.map((app: any) => ({ const mappedApps = applicationsData.map((app: any) => ({
id: app.id, id: app.id,
registrationNumber: app.applicationId || 'N/A', registrationNumber: app.applicationId || 'N/A',
@ -122,6 +124,23 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
address: app.address address: app.address
})); }));
setApplications(mappedApps); 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<string, SlaStatusSnapshot | null> = {};
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 // Extract unique locations for filtering - could be optimized to fetch once
if (locations.length === 0) { if (locations.length === 0) {
@ -324,6 +343,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Preferred Location</TableHead> <TableHead>Preferred Location</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>SLA</TableHead>
<TableHead>Applicant Location</TableHead> <TableHead>Applicant Location</TableHead>
<TableHead>Progress</TableHead> <TableHead>Progress</TableHead>
<TableHead>Applied On</TableHead> <TableHead>Applied On</TableHead>
@ -348,6 +368,9 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
{app.status} {app.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell>
<SlaBadge status={slaByAppId[app.id]} compact />
</TableCell>
<TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-application-addr-${idx}`}> <TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-application-addr-${idx}`}>
{app.residentialAddress} {app.residentialAddress}
</TableCell> </TableCell>

View File

@ -15,6 +15,8 @@ import { useNavigate } from 'react-router-dom';
import { User as UserType } from '@/lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
interface RelocationRequestDetailsProps { interface RelocationRequestDetailsProps {
requestId: string; requestId: string;
@ -197,6 +199,10 @@ const getApiErrorMessage = (error: any, fallback: string) => {
}; };
export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) { export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) {
const { get: getSla } = useSlaBatchStatus(
requestId ? [{ entityType: 'relocation', entityId: requestId }] : [],
Boolean(requestId)
);
const navigate = useNavigate(); const navigate = useNavigate();
const [request, setRequest] = useState<any>(null); const [request, setRequest] = useState<any>(null);
const [auditLogs, setAuditLogs] = useState<any[]>([]); const [auditLogs, setAuditLogs] = useState<any[]>([]);
@ -577,6 +583,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<p className="text-slate-600"> <p className="text-slate-600">
{request.outlet?.name} ({request.outlet?.code}) {request.outlet?.name} ({request.outlet?.code})
</p> </p>
<div className="mt-1">
<SlaBadge status={getSla('relocation', requestId)} />
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -14,6 +14,8 @@ import { useState, useEffect } from 'react';
import { User } from '@/lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { import {
Pagination, Pagination,
@ -74,6 +76,12 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
// Constants // Constants
const isSuperAdmin = currentUser?.role === 'Super Admin' || currentUser?.roleCode === 'Super Admin'; 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) => const isCompletedRequest = (request: any) =>
request.status === 'Completed' || request.status === 'Closed' || request.currentStage === 'Completed'; request.status === 'Completed' || request.status === 'Closed' || request.currentStage === 'Completed';
@ -554,6 +562,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
<Badge className={`border ${getStatusColor(request.currentStage)}`}> <Badge className={`border ${getStatusColor(request.currentStage)}`}>
{request.currentStage} {request.currentStage}
</Badge> </Badge>
@ -629,7 +638,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={`border ${getStatusColor(request.currentStage)}`}> <SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
<Badge className={`border ${getStatusColor(request.currentStage)}`}>
{request.currentStage} {request.currentStage}
</Badge> </Badge>
</TableCell> </TableCell>
@ -701,7 +711,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={`border ${getStatusColor(request.currentStage)}`}> <SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
<Badge className={`border ${getStatusColor(request.currentStage)}`}>
{request.currentStage} {request.currentStage}
</Badge> </Badge>
</TableCell> </TableCell>

View File

@ -15,10 +15,16 @@ import { useNavigate } from 'react-router-dom';
import { User as UserType } from '@/lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { resignationService } from '@/services/resignation.service'; import { resignationService } from '@/services/resignation.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '@/components/ui/utils'; 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 { RESIGNATION_DOCUMENT_TYPES, RESIGNATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions';
import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles'; import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles';
@ -59,6 +65,10 @@ const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = {
}; };
export function ResignationDetails({ resignationId, onBack, currentUser }: ResignationDetailsProps) { 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 getDocumentsForStage = (stageName: string, stageKey?: string) => {
const allDocs = [ const allDocs = [
...(resignationData?.documents || []), ...(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. // When Legal approval bumps into LWD gate for F&F initiation, guide user explicitly.
if (response.data?.canForce) { 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) { } catch (error: any) {
@ -382,7 +394,9 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
toast.error(error.response?.data?.message || 'Failed to submit action'); toast.error(error.response?.data?.message || 'Failed to submit action');
if (error?.response?.data?.canForce) { 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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -485,8 +499,11 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
? 'bg-red-100 text-red-700 border-red-300' ? 'bg-red-100 text-red-700 border-red-300'
: 'bg-yellow-100 text-yellow-700 border-yellow-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')}
</Badge> </Badge>
<SlaBadge status={getSla('resignation', resignationId)} />
</div> </div>
</div> </div>
@ -1168,7 +1185,10 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" /> <AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="text-sm text-amber-800"> <div className="text-sm text-amber-800">
<p className="font-bold">Manual Trigger Notice</p> <p className="font-bold">Manual Trigger Notice</p>
<p>Normally F&F is triggered after LWD. Use manual trigger only if urgent clearance is required.</p> <p>
Normally F&F is triggered after the {LAST_WORKING_DAY_LABEL.toLowerCase()}. Use manual
trigger only if urgent clearance is required.
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { User as UserType } from '@/lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
@ -71,6 +73,9 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
const openRequests = statusTab === 'open' ? resignations : []; const openRequests = statusTab === 'open' ? resignations : [];
const completedRequests = statusTab === 'completed' ? 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header Stats */} {/* Header Stats */}
@ -150,6 +155,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{request.status} {request.status}
</Badge> </Badge>
<SlaBadge status={getSla('resignation', request.id)} compact />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
@ -276,6 +282,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{request.status} {request.status}
</Badge> </Badge>
<SlaBadge status={getSla('resignation', request.id)} compact />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
@ -337,6 +344,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{request.status} {request.status}
</Badge> </Badge>
<SlaBadge status={getSla('resignation', request.id)} compact />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>

View File

@ -14,10 +14,16 @@ import { useState, useEffect } from 'react';
import { User } from '@/lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { terminationService } from '@/services/termination.service'; import { terminationService } from '@/services/termination.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay'; import {
formatTerminationStatusLabel,
LAST_WORKING_DAY_LABEL,
OFFBOARDING_STATUS
} from '@/lib/terminationDisplay';
import { import {
getJointRoundCutoffMsFromTimeline, getJointRoundCutoffMsFromTimeline,
isAuditLogInCurrentJointRound isAuditLogInCurrentJointRound
@ -33,9 +39,14 @@ interface TerminationDetailsProps {
export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) { export function TerminationDetails({ terminationId, onBack, currentUser }: TerminationDetailsProps) {
const navigate = useNavigate(); 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 [actionDialog, setActionDialog] = useState<{ open: boolean; type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'hold' | null }>({ open: false, type: null });
const [remarks, setRemarks] = useState(''); const [remarks, setRemarks] = useState('');
const [assignToUser, setAssignToUser] = 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 [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] });
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [terminationData, setTerminationData] = useState<any>(null); const [terminationData, setTerminationData] = useState<any>(null);
@ -209,6 +220,16 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Terminated'].includes(status) || currentStage === 'Terminated'; 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 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 scnJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'scn_response_eval');
const rbmJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'rbm_review'); const rbmJointRoundCutoffMs = getJointRoundCutoffMsFromTimeline(terminationData.timeline, 'rbm_review');
const isScnResponseEvalStage = const isScnResponseEvalStage =
@ -271,7 +292,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
(currentStage === 'CEO Final Approval' && userRole === 'CEO') || (currentStage === 'CEO Final Approval' && userRole === 'CEO') ||
userRole === 'Super Admin' userRole === 'Super Admin'
) && ['NBH Final Approval', 'CCO Approval', 'CEO Final Approval'].includes(currentStage) && !isFinalState, ) && ['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, canWithdraw: userRole === 'ASM' && currentStage === 'Request Initiated' && !isFinalState,
isFinalState, isFinalState,
isSettlementPhase isSettlementPhase
@ -296,7 +325,15 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const getProgressStatus = (stageName: string) => { const getProgressStatus = (stageName: string) => {
const isTerminal = ['Rejected', 'Revoked', 'Withdrawn'].includes(request.status); 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 // For terminal states, we determine the last active stage from the timeline to keep the track visible
let currentStageForProgress = request.currentStage || request.status; 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') { if (actionType === 'approve' || actionType === 'sendBack' || actionType === 'withdrawal' || actionType === 'revoke' || actionType === 'hold') {
response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks); response = await terminationService.updateTerminationStatus(terminationId, effectiveAction, remarks);
} else if (actionType === 'pushfnf') { } else if (actionType === 'pushfnf') {
response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks); response = await terminationService.updateTerminationStatus(terminationId, 'pushfnf', remarks, {
force: forceTriggerFnF
});
} else { } else {
toast.error('Action logic not fully implemented for this type'); toast.error('Action logic not fully implemented for this type');
setIsProcessing(false); setIsProcessing(false);
@ -503,7 +542,11 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
if (response && (response.success === false || response.ok === false)) { if (response && (response.success === false || response.ok === false)) {
console.error('[TerminationDetails] Action failed:', response); 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); setIsProcessing(false);
return; return;
} }
@ -521,10 +564,14 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
setActionDialog({ open: false, type: null }); setActionDialog({ open: false, type: null });
setRemarks(''); setRemarks('');
setAssignToUser(''); setAssignToUser('');
setForceTriggerFnF(false);
fetchTermination(); fetchTermination();
} catch (error: any) { } catch (error: any) {
const msg = error.response?.data?.message || 'Failed to perform action'; const msg = error.response?.data?.message || 'Failed to perform action';
toast.error(msg); toast.error(msg);
if (error?.response?.data?.canForce) {
toast.info('Enable "Force initiate F&F" in the dialog if an exception is approved.');
}
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
@ -578,6 +625,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
}> }>
{request.status === 'Settled' ? 'Completed' : formatTerminationStatusLabel(request.status || 'Pending')} {request.status === 'Settled' ? 'Completed' : formatTerminationStatusLabel(request.status || 'Pending')}
</Badge> </Badge>
<SlaBadge status={getSla('termination', terminationId)} />
</div> </div>
</div> </div>
@ -1099,6 +1147,29 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</Button> </Button>
)} )}
{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 && (
<Alert className="border-amber-200 bg-amber-50">
<AlertTriangle className="h-4 w-4 text-amber-700" />
<AlertTitle className="text-amber-900">
Push to F&F locked until {LAST_WORKING_DAY_LABEL}
</AlertTitle>
<AlertDescription className="text-amber-800 text-sm">
{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.
</AlertDescription>
</Alert>
)}
{!permissions.isFinalState && ( {!permissions.isFinalState && (
<Button <Button
variant="outline" variant="outline"
@ -1154,7 +1225,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
{actionDialog.type === 'assign' {actionDialog.type === 'assign'
? 'Select a user to assign this request to' ? 'Select a user to assign this request to'
: actionDialog.type === 'pushfnf' : actionDialog.type === 'pushfnf'
? 'This will move the termination case to F&F for dues clearance' ? 'This creates the F&F settlement record and starts departmental clearance (manual step only).'
: 'Please provide remarks for this action' : 'Please provide remarks for this action'
} }
</DialogDescription> </DialogDescription>
@ -1180,7 +1251,14 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
</Select> </Select>
</div> </div>
) : actionDialog.type === 'pushfnf' ? ( ) : actionDialog.type === 'pushfnf' ? (
<div className="space-y-2"> <div className="space-y-4">
<p className="text-sm text-slate-600">
F&F can be started on or after the {LAST_WORKING_DAY_LABEL}
{request.proposedLwd
? ` (${new Date(request.proposedLwd).toLocaleDateString('en-IN', { dateStyle: 'medium' })})`
: ''}
.
</p>
<Label>Remarks (Optional)</Label> <Label>Remarks (Optional)</Label>
<Textarea <Textarea
value={remarks} value={remarks}
@ -1188,6 +1266,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
placeholder="Add any additional notes..." placeholder="Add any additional notes..."
rows={3} rows={3}
/> />
<label className="flex items-start gap-2 text-sm text-slate-700 cursor-pointer">
<input
type="checkbox"
className="mt-1"
checked={forceTriggerFnF}
onChange={(e) => setForceTriggerFnF(e.target.checked)}
/>
<span>
Force initiate F&F before {LAST_WORKING_DAY_LABEL} (requires business approval; exception use only)
</span>
</label>
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">

View File

@ -11,6 +11,8 @@ import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { API } from '@/api/API'; import { API } from '@/api/API';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { import {
Pagination, Pagination,
@ -23,7 +25,11 @@ import {
} from "@/components/ui/pagination"; } from "@/components/ui/pagination";
import { User } from '@/lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatTerminationStatusLabel } from '@/lib/terminationDisplay'; import {
formatTerminationStatusLabel,
LAST_WORKING_DAY_LABEL,
PROPOSED_LAST_WORKING_DAY_LABEL
} from '@/lib/terminationDisplay';
interface TerminationPageProps { interface TerminationPageProps {
currentUser: User | null; currentUser: User | null;
@ -62,6 +68,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
const [dealerCode, setDealerCode] = useState(''); const [dealerCode, setDealerCode] = useState('');
const [autoFilledData, setAutoFilledData] = useState<any>(null); const [autoFilledData, setAutoFilledData] = useState<any>(null);
const [terminations, setTerminations] = useState<any[]>([]); const [terminations, setTerminations] = useState<any[]>([]);
const [slaById, setSlaById] = useState<Record<string, SlaStatusSnapshot | null>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null); const [paginationMeta, setPaginationMeta] = useState<any>(null);
@ -87,6 +94,23 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
if (data?.success) { if (data?.success) {
setTerminations(data.terminations); setTerminations(data.terminations);
setPaginationMeta(data.meta); setPaginationMeta(data.meta);
const rows = data.terminations || [];
if (rows.length) {
slaService
.getBatchStatus(rows.map((t: any) => ({ entityType: 'termination', entityId: t.id })))
.then((slaRes) => {
if (slaRes?.success) {
const map: Record<string, SlaStatusSnapshot | null> = {};
rows.forEach((t: any) => {
map[t.id] = slaRes.data[`termination:${t.id}`] ?? null;
});
setSlaById(map);
}
})
.catch(() => setSlaById({}));
} else {
setSlaById({});
}
} }
} catch (error) { } catch (error) {
console.error('Error fetching terminations:', error); console.error('Error fetching terminations:', error);
@ -414,7 +438,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Proposed LWD *</Label> <Label>{PROPOSED_LAST_WORKING_DAY_LABEL} *</Label>
<Input <Input
type="date" type="date"
value={formData.proposedLwd} value={formData.proposedLwd}
@ -501,6 +525,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{formatStatus(request.status)} {formatStatus(request.status)}
</Badge> </Badge>
<SlaBadge status={slaById[request.id]} compact />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
@ -520,7 +545,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<p>{formatStatus(request.currentStage)}</p> <p>{formatStatus(request.currentStage)}</p>
</div> </div>
<div> <div>
<p className="text-slate-600">Proposed LWD</p> <p className="text-slate-600">{PROPOSED_LAST_WORKING_DAY_LABEL}</p>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="w-4 h-4 text-slate-500" /> <Calendar className="w-4 h-4 text-slate-500" />
<p>{request.proposedLwd}</p> <p>{request.proposedLwd}</p>
@ -573,6 +598,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{formatStatus(request.status)} {formatStatus(request.status)}
</Badge> </Badge>
<SlaBadge status={slaById[request.id]} compact />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
@ -635,6 +661,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<Badge className={getStatusColor(request.status)}> <Badge className={getStatusColor(request.status)}>
{formatStatus(request.status)} {formatStatus(request.status)}
</Badge> </Badge>
<SlaBadge status={slaById[request.id]} compact />
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div> <div>
@ -650,7 +677,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<p>{request.category}</p> <p>{request.category}</p>
</div> </div>
<div> <div>
<p className="text-slate-600">LWD</p> <p className="text-slate-600">{LAST_WORKING_DAY_LABEL}</p>
<p>{request.proposedLwd}</p> <p>{request.proposedLwd}</p>
</div> </div>
</div> </div>

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
export function useSlaBatchStatus(
items: Array<{ entityType: string; entityId: string }>,
enabled = true
) {
const [byKey, setByKey] = useState<Record<string, SlaStatusSnapshot | null>>({});
useEffect(() => {
if (!enabled || items.length === 0) {
setByKey({});
return;
}
slaService
.getBatchStatus(items)
.then((res) => {
if (res?.success) setByKey(res.data);
})
.catch(() => setByKey({}));
}, [enabled, JSON.stringify(items.map((i) => `${i.entityType}:${i.entityId}`).sort())]);
const get = (entityType: string, entityId: string) =>
byKey[`${entityType}:${entityId}`] ?? null;
return { byKey, get };
}

View File

@ -0,0 +1,19 @@
/** User-facing labels — avoid "LWD" shorthand in the UI */
export const LAST_WORKING_DAY_LABEL = 'Last Working Day';
export const PROPOSED_LAST_WORKING_DAY_LABEL = 'Proposed Last Working Day';
/** Backend status values (unchanged — use for API/state comparisons) */
export const OFFBOARDING_STATUS = {
AWAITING_FNF: 'Awaiting F&F',
AWAITING_FNF_LWD_PENDING: 'Awaiting F&F (LWD Pending)'
} as const;
/** Maps backend status / stage strings to user-facing labels */
export function formatOffboardingStatusLabel(value: string | null | undefined): string {
if (!value) return 'Pending';
let label = value;
label = label.replace(/Personal Hearing/gi, 'SCN Response Evaluation');
label = label.replace(/\(LWD Pending\)/gi, '(Last Working Day Pending)');
label = label.replace(/\bLWD\b/g, LAST_WORKING_DAY_LABEL);
return label;
}

View File

@ -1,5 +1,6 @@
/** Legacy workflow used "Personal Hearing"; UI and newer APIs use "SCN Response Evaluation" wording. */ export {
export function formatTerminationStatusLabel(value: string | null | undefined): string { formatOffboardingStatusLabel as formatTerminationStatusLabel,
if (!value) return 'Pending'; LAST_WORKING_DAY_LABEL,
return value.replace(/Personal Hearing/gi, 'SCN Response Evaluation'); PROPOSED_LAST_WORKING_DAY_LABEL,
} OFFBOARDING_STATUS
} from './offboardingDisplay';

142
src/services/sla.service.ts Normal file
View File

@ -0,0 +1,142 @@
import { API } from '@/api/API';
export type SlaBucket = 'healthy' | 'warning' | 'critical' | 'breached';
export interface SlaQueueItem {
trackingId: string;
entityType: string;
entityId: string;
applicationId: string | null;
module: string;
caseRef: string;
stageName: string;
ownerRole: string;
startTime: string;
deadline: string;
percentUsed: number;
bucket: SlaBucket;
isBreached: boolean;
remainingLabel: string;
link: string;
}
export interface SlaStatusSnapshot {
entityType: string;
entityId: string;
stageName: string;
ownerRole: string;
percentUsed: number;
bucket: SlaBucket;
isBreached: boolean;
isPaused?: boolean;
remainingLabel: string;
deadline: string;
}
export interface SlaBreachRow {
id: string;
breachedAt: string;
status: string;
stageName: string;
module: string;
caseRef: string;
link: string;
}
export interface SlaOperationsDashboard {
generatedAt: string;
scheduler: {
redisEnabled: boolean;
slaFastMode: boolean;
questionnaireFastMode: boolean;
queues: Array<{
name: string;
key?: string;
counts?: Record<string, number>;
repeatable?: Array<{ name?: string; pattern?: string; next?: number }>;
error?: string;
}>;
};
summary: {
activeCount: number;
breachedCount: number;
dueSoonCount: number;
onTrackCount: number;
openBreachesCount: number;
tracksWithoutConfig: number;
configsActive: number;
configsInactive: number;
buckets: Record<SlaBucket, number>;
byModule: Record<string, number>;
};
activeQueue: SlaQueueItem[];
breaches: SlaBreachRow[];
analytics?: {
periodDays: number;
tracksStarted: number;
breachesRecorded: number;
breachRatePercent: number;
avgResolutionHours: number | null;
completedTracks: number;
topDelayedStages: Array<{
stageName: string;
breachCount: number;
currentlyBreached: number;
score: number;
}>;
breachesByModule: Record<string, number>;
};
}
export type QuestionnaireReminderSettings = {
enabled: boolean;
firstAfterDays: number;
intervalDays: number;
maxCount: number;
source: 'database' | 'environment';
};
export const slaService = {
getOperationsDashboard: async (params?: {
module?: string;
breachedOnly?: boolean;
mineOnly?: boolean;
}) => {
const response = await API.getSlaOperationsDashboard(params);
return response.data as { success: boolean; data: SlaOperationsDashboard };
},
getBatchStatus: async (items: Array<{ entityType: string; entityId: string }>) => {
const response = await (API as any).postSlaBatchStatus({ items });
return response.data as { success: boolean; data: Record<string, SlaStatusSnapshot | null> };
},
exportOperationsCsv: async (params?: { module?: string; mineOnly?: boolean }) => {
const base = (import.meta as any).env?.VITE_API_URL || '/api';
const qs = new URLSearchParams();
if (params?.module) qs.set('module', params.module);
if (params?.mineOnly) qs.set('mineOnly', 'true');
const token = localStorage.getItem('token');
const res = await fetch(`${base}/sla/operations/export?${qs.toString()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `sla-queue-${Date.now()}.csv`;
a.click();
URL.revokeObjectURL(url);
},
getQuestionnaireReminderSettings: async () => {
const response = await API.getQuestionnaireReminderSettings();
return response.data as { success: boolean; data: QuestionnaireReminderSettings };
},
updateQuestionnaireReminderSettings: async (data: Partial<QuestionnaireReminderSettings>) => {
const response = await API.updateQuestionnaireReminderSettings(data);
return response.data as { success: boolean; data: QuestionnaireReminderSettings };
}
};

View File

@ -7,8 +7,17 @@ export const terminationService = {
return data?.termination || data?.data || data; return data?.termination || data?.data || data;
}, },
updateTerminationStatus: async (id: string, action: string, remarks: string) => { updateTerminationStatus: async (
const response = await API.updateTerminationStatus(id, { action, remarks }); id: string,
action: string,
remarks: string,
options?: { force?: boolean }
) => {
const response = await API.updateTerminationStatus(id, {
action,
remarks,
...(options?.force ? { force: true } : {})
});
return response.data; return response.data;
}, },