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'),
|
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),
|
||||||
|
|||||||
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 { 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 */}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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',
|
'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',
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
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 {
|
||||||
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
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;
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user