few de demo bugs and sla tracker implemeted alog with sla monitor screen
This commit is contained in:
parent
81d4dd493f
commit
61deac775c
@ -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),
|
||||
|
||||
34
src/components/sla/SlaBadge.tsx
Normal file
34
src/components/sla/SlaBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 }:
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('constitutional', requestId)} />
|
||||
</div>
|
||||
|
||||
{/* Request Overview */}
|
||||
|
||||
@ -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<any>(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
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -674,9 +685,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
@ -760,9 +774,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700">
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
|
||||
627
src/features/master/components/SLAMonitorPanel.tsx
Normal file
627
src/features/master/components/SLAMonitorPanel.tsx
Normal 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: '0–25% elapsed',
|
||||
warning: '26–75% elapsed',
|
||||
critical: '76–99% 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 26–99% 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>
|
||||
);
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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<any>(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 (
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Clock className="w-6 h-6 text-amber-600" />
|
||||
SLA & Escalation Matrix
|
||||
</h1>
|
||||
<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>
|
||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||
<Clock className="w-6 h-6 text-amber-600" />
|
||||
SLA & Escalation
|
||||
</h1>
|
||||
<p className="text-slate-500">Configure TAT rules and monitor live queue, breaches, and schedulers</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{slaConfigs.map((sla) => (
|
||||
<Card key={sla.id} className="border-slate-200 hover:shadow-md transition-shadow group overflow-hidden">
|
||||
<CardHeader className="pb-3 bg-gradient-to-br from-white to-slate-50/50">
|
||||
<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>
|
||||
<Tabs value={mainTab} onValueChange={setMainTab}>
|
||||
<TabsList className="bg-slate-100">
|
||||
<TabsTrigger value="monitor" className="flex items-center gap-1.5">
|
||||
<Activity className="w-4 h-4" />
|
||||
Operations monitor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config">Configuration matrix</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<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>
|
||||
))}
|
||||
<TabsContent value="monitor" className="mt-6">
|
||||
<SLAMonitorPanel />
|
||||
</TabsContent>
|
||||
|
||||
{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>
|
||||
<TabsContent value="config" className="mt-6 space-y-6">
|
||||
<div className="flex items-center justify-end 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>
|
||||
|
||||
<SLADialog
|
||||
isOpen={showSLADialog}
|
||||
onClose={() => setShowSLADialog(false)}
|
||||
sla={selectedSLA}
|
||||
onSave={fetchConfigs}
|
||||
/>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{slaConfigs.map((sla) => (
|
||||
<Card key={sla.id} className="border-slate-200 hover:shadow-md transition-shadow group overflow-hidden">
|
||||
<CardHeader className="pb-3 bg-gradient-to-br from-white to-slate-50/50">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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({
|
||||
<div className="truncate">
|
||||
<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>
|
||||
{slaStatus && (
|
||||
<div className="mt-1">
|
||||
<SlaBadge status={slaStatus} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@ -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<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 {
|
||||
application,
|
||||
@ -410,6 +424,7 @@ export const ApplicationDetails = () => {
|
||||
<div className="space-y-6">
|
||||
<ApplicationDetailsHeader
|
||||
application={application}
|
||||
slaStatus={slaStatus}
|
||||
isNonResponsive={isNonResponsive}
|
||||
isAdmin={isAdmin}
|
||||
onBack={onBack}
|
||||
|
||||
@ -3,6 +3,8 @@ import { toast } from 'sonner';
|
||||
import { ApplicationStatus, Application } from '@/lib/mock-data';
|
||||
import { formatDateTime } from '@/components/ui/utils';
|
||||
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 { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@ -62,6 +64,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
|
||||
// Real Data Integration
|
||||
const [applications, setApplications] = useState<Application[]>([]);
|
||||
const [slaByAppId, setSlaByAppId] = useState<Record<string, SlaStatusSnapshot | null>>({});
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(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<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
|
||||
if (locations.length === 0) {
|
||||
@ -324,6 +343,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Preferred Location</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>SLA</TableHead>
|
||||
<TableHead>Applicant Location</TableHead>
|
||||
<TableHead>Progress</TableHead>
|
||||
<TableHead>Applied On</TableHead>
|
||||
@ -348,6 +368,9 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
|
||||
{app.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SlaBadge status={slaByAppId[app.id]} compact />
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-application-addr-${idx}`}>
|
||||
{app.residentialAddress}
|
||||
</TableCell>
|
||||
|
||||
@ -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<any>(null);
|
||||
const [auditLogs, setAuditLogs] = useState<any[]>([]);
|
||||
@ -577,6 +583,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<p className="text-slate-600">
|
||||
{request.outlet?.name} ({request.outlet?.code})
|
||||
</p>
|
||||
<div className="mt-1">
|
||||
<SlaBadge status={getSla('relocation', requestId)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@ -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
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
|
||||
<Badge className={`border ${getStatusColor(request.currentStage)}`}>
|
||||
{request.currentStage}
|
||||
</Badge>
|
||||
@ -629,7 +638,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
</div>
|
||||
</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}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@ -701,7 +711,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
</div>
|
||||
</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}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
@ -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<string, string[]> = {
|
||||
};
|
||||
|
||||
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')}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('resignation', resignationId)} />
|
||||
</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" />
|
||||
<div className="text-sm text-amber-800">
|
||||
<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 className="flex items-center gap-2">
|
||||
|
||||
@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header Stats */}
|
||||
@ -150,6 +155,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('resignation', request.id)} compact />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
@ -276,6 +282,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('resignation', request.id)} compact />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
@ -337,6 +344,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{request.status}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('resignation', request.id)} compact />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
|
||||
@ -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<any>(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')}
|
||||
</Badge>
|
||||
<SlaBadge status={getSla('termination', terminationId)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1099,6 +1147,29 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
||||
</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 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@ -1154,7 +1225,7 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
||||
{actionDialog.type === 'assign'
|
||||
? 'Select a user to assign this request to'
|
||||
: 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'
|
||||
}
|
||||
</DialogDescription>
|
||||
@ -1180,7 +1251,14 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
||||
</Select>
|
||||
</div>
|
||||
) : 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>
|
||||
<Textarea
|
||||
value={remarks}
|
||||
@ -1188,6 +1266,17 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
|
||||
placeholder="Add any additional notes..."
|
||||
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 className="space-y-2">
|
||||
|
||||
@ -11,6 +11,8 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useState, useEffect } from 'react';
|
||||
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 {
|
||||
Pagination,
|
||||
@ -23,7 +25,11 @@ import {
|
||||
} from "@/components/ui/pagination";
|
||||
import { User } from '@/lib/mock-data';
|
||||
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 {
|
||||
currentUser: User | null;
|
||||
@ -62,6 +68,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
const [dealerCode, setDealerCode] = useState('');
|
||||
const [autoFilledData, setAutoFilledData] = useState<any>(null);
|
||||
const [terminations, setTerminations] = useState<any[]>([]);
|
||||
const [slaById, setSlaById] = useState<Record<string, SlaStatusSnapshot | null>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||
@ -87,6 +94,23 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
if (data?.success) {
|
||||
setTerminations(data.terminations);
|
||||
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) {
|
||||
console.error('Error fetching terminations:', error);
|
||||
@ -414,7 +438,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Proposed LWD *</Label>
|
||||
<Label>{PROPOSED_LAST_WORKING_DAY_LABEL} *</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.proposedLwd}
|
||||
@ -501,6 +525,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{formatStatus(request.status)}
|
||||
</Badge>
|
||||
<SlaBadge status={slaById[request.id]} compact />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
@ -520,7 +545,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<p>{formatStatus(request.currentStage)}</p>
|
||||
</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">
|
||||
<Calendar className="w-4 h-4 text-slate-500" />
|
||||
<p>{request.proposedLwd}</p>
|
||||
@ -573,6 +598,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{formatStatus(request.status)}
|
||||
</Badge>
|
||||
<SlaBadge status={slaById[request.id]} compact />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
@ -635,6 +661,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<Badge className={getStatusColor(request.status)}>
|
||||
{formatStatus(request.status)}
|
||||
</Badge>
|
||||
<SlaBadge status={slaById[request.id]} compact />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
@ -650,7 +677,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
|
||||
<p>{request.category}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-slate-600">LWD</p>
|
||||
<p className="text-slate-600">{LAST_WORKING_DAY_LABEL}</p>
|
||||
<p>{request.proposedLwd}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
27
src/hooks/useSlaBatchStatus.ts
Normal file
27
src/hooks/useSlaBatchStatus.ts
Normal 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 };
|
||||
}
|
||||
19
src/lib/offboardingDisplay.ts
Normal file
19
src/lib/offboardingDisplay.ts
Normal 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;
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
/** Legacy workflow used "Personal Hearing"; UI and newer APIs use "SCN Response Evaluation" wording. */
|
||||
export function formatTerminationStatusLabel(value: string | null | undefined): string {
|
||||
if (!value) return 'Pending';
|
||||
return value.replace(/Personal Hearing/gi, 'SCN Response Evaluation');
|
||||
}
|
||||
export {
|
||||
formatOffboardingStatusLabel as formatTerminationStatusLabel,
|
||||
LAST_WORKING_DAY_LABEL,
|
||||
PROPOSED_LAST_WORKING_DAY_LABEL,
|
||||
OFFBOARDING_STATUS
|
||||
} from './offboardingDisplay';
|
||||
|
||||
142
src/services/sla.service.ts
Normal file
142
src/services/sla.service.ts
Normal 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 };
|
||||
}
|
||||
};
|
||||
@ -7,8 +7,17 @@ export const terminationService = {
|
||||
return data?.termination || data?.data || data;
|
||||
},
|
||||
|
||||
updateTerminationStatus: async (id: string, action: string, remarks: string) => {
|
||||
const response = await API.updateTerminationStatus(id, { action, remarks });
|
||||
updateTerminationStatus: async (
|
||||
id: string,
|
||||
action: string,
|
||||
remarks: string,
|
||||
options?: { force?: boolean }
|
||||
) => {
|
||||
const response = await API.updateTerminationStatus(id, {
|
||||
action,
|
||||
remarks,
|
||||
...(options?.force ? { force: true } : {})
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user