in-app notiofication enhanced an SLA fe

implementation done partially
This commit is contained in:
laxman h 2026-04-20 19:56:33 +05:30
parent 37b3075a08
commit 01e22e4aa7
19 changed files with 1135 additions and 351 deletions

View File

@ -35,6 +35,7 @@ import { FinanceFnFDetailsPage } from '@/features/fnf/pages/FinanceFnFDetailsPag
import { MasterPage } from '@/features/master/pages/MasterPage'; import { MasterPage } from '@/features/master/pages/MasterPage';
import { UserManagementPage } from '@/components/admin/UserManagementPage'; import { UserManagementPage } from '@/components/admin/UserManagementPage';
import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage'; import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage';
import { SLAConfigPage } from '@/features/master/pages/SLAConfigPage';
import { ConstitutionalChangePage } from '@/features/constitutional/pages/ConstitutionalChangePage'; import { ConstitutionalChangePage } from '@/features/constitutional/pages/ConstitutionalChangePage';
import { ConstitutionalChangeDetails } from '@/features/constitutional/pages/ConstitutionalChangeDetails'; import { ConstitutionalChangeDetails } from '@/features/constitutional/pages/ConstitutionalChangeDetails';
import { RelocationRequestPage } from '@/features/relocation/pages/RelocationRequestPage'; import { RelocationRequestPage } from '@/features/relocation/pages/RelocationRequestPage';
@ -235,7 +236,7 @@ export default function App() {
{/* All Applications */} {/* All Applications */}
<Route path="/all-applications" element={ <Route path="/all-applications" element={
hasRole(['DD']) ? <AllApplicationsPage onViewDetails={(id: string) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" /> hasRole(['DD', 'DD Admin', 'Super Admin']) ? <AllApplicationsPage onViewDetails={(id: string) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" />
} /> } />
{/* FDD Routes - Integrated into Layout */} {/* FDD Routes - Integrated into Layout */}
@ -254,6 +255,11 @@ export default function App() {
? <ApprovalPoliciesPage /> ? <ApprovalPoliciesPage />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/sla-configurations" element={
(hasRole(['Super Admin', 'DD Admin', 'DD Lead']))
? <SLAConfigPage />
: <Navigate to="/dashboard" />
} />
<Route path="/master" element={<MasterPage />} /> <Route path="/master" element={<MasterPage />} />
<Route path="/questions" element={<QuestionnaireList />} /> <Route path="/questions" element={<QuestionnaireList />} />
<Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} /> <Route path="/questionnaire-builder" element={<QuestionnaireBuilder />} />

View File

@ -153,6 +153,7 @@ export const API = {
createResignation: (data: any) => client.post('/resignation', data), createResignation: (data: any) => client.post('/resignation', data),
approveResignation: (id: string, data?: any) => client.post(`/resignation/${id}/approve`, data), approveResignation: (id: string, data?: any) => client.post(`/resignation/${id}/approve`, data),
rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data), rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data),
withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }),
getTerminations: () => client.get('/termination'), getTerminations: () => client.get('/termination'),
createTermination: (data: any) => client.post('/termination', data), createTermination: (data: any) => client.post('/termination', data),
@ -164,6 +165,9 @@ export const API = {
getFnFSettlementById: (id: string) => client.get(`/settlement/fnf/${id}`), getFnFSettlementById: (id: string) => client.get(`/settlement/fnf/${id}`),
calculateFnF: (id: string) => client.post(`/settlement/fnf/${id}/calculate`), calculateFnF: (id: string) => client.post(`/settlement/fnf/${id}/calculate`),
updateFnF: (id: string, data: any) => client.put(`/settlement/fnf/${id}`, data), updateFnF: (id: string, data: any) => client.put(`/settlement/fnf/${id}`, data),
uploadFnFDocument: (id: string, data: any) => client.post(`/settlement/fnf/${id}/documents`, data, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
getSettlementDepartments: () => client.get('/settlement/departments'), getSettlementDepartments: () => client.get('/settlement/departments'),
// Line items // Line items
@ -199,7 +203,9 @@ export const API = {
uploadConstitutionalDocuments: (id: string, documents: any[]) => client.post(`/constitutional-change/${id}/documents`, { documents }), uploadConstitutionalDocuments: (id: string, documents: any[]) => client.post(`/constitutional-change/${id}/documents`, { documents }),
// SLA // SLA
getSlaConfigs: () => client.get('/sla/configs'), getSlaConfigs: () => client.get('/master/sla-configs'),
saveSlaConfig: (data: any) => client.post('/master/sla-configs', data),
initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'),
// System Configs // System Configs
getSystemConfigs: (params?: any) => client.get('/master/system-configs', params), getSystemConfigs: (params?: any) => client.get('/master/system-configs', params),

View File

@ -6,7 +6,6 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Search, Search,
Inbox,
UserMinus, UserMinus,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
@ -79,13 +78,15 @@ export function Sidebar({ onLogout }: SidebarProps) {
{ id: 'relocation-requests', label: 'Relocation Requests', icon: MapPin }, { id: 'relocation-requests', label: 'Relocation Requests', icon: MapPin },
]; ];
/*
// Add All Applications for DD role (before Dealership Requests) // Add All Applications for DD role (before Dealership Requests)
if (hasRole(['DD'])) { if (hasRole(['DD', 'DD Admin', 'Super Admin'])) {
menuItems.splice(1, 0, { id: 'all-applications', label: 'All Applications', icon: Inbox }); menuItems.splice(1, 0, { id: 'all-applications', label: 'All Applications', icon: Inbox });
} }
*/
// Add All Requests for DD Lead role (before Dealership Requests) // Add All Requests for DD Lead role (before Dealership Requests)
if (hasRole(['DD Lead', 'Super Admin'])) { if (hasRole(['DD Lead', 'DD Admin', 'Super Admin'])) {
menuItems.splice(1, 0, { menuItems.splice(1, 0, {
id: 'all-requests', id: 'all-requests',
label: 'All Requests', label: 'All Requests',
@ -102,6 +103,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
// Add Master for Super Admin, DD Admin, and DD Lead // Add Master for Super Admin, DD Admin, and DD Lead
if (hasRole(['Super Admin', 'DD Admin', 'DD Lead'])) { if (hasRole(['Super Admin', 'DD Admin', 'DD Lead'])) {
menuItems.push({ id: 'master', label: 'Master', icon: Settings }); menuItems.push({ id: 'master', label: 'Master', icon: Settings });
menuItems.push({ id: 'sla-configurations', label: 'SLA Matrix', icon: RefreshCcw });
} }
if (hasRole(['Super Admin'])) { if (hasRole(['Super Admin'])) {

View File

@ -612,12 +612,12 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div> <div>
<p className="text-slate-600 text-sm mb-2">Constitutional Change</p> <p className="text-slate-600 text-sm mb-2">Constitutional Change</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}> <Badge className={getTypeColor(request.oldValue || request.currentConstitution || request.outlet?.type || 'Proprietorship')}>
{request.outlet?.type || 'Proprietorship'} {request.oldValue || request.currentConstitution || request.outlet?.type || 'Proprietorship'}
</Badge> </Badge>
<ArrowRight className="w-4 h-4 text-slate-400" /> <ArrowRight className="w-4 h-4 text-slate-400" />
<Badge className={getTypeColor(request.changeType)}> <Badge className={getTypeColor(request.newValue || request.changeType)}>
{request.changeType} {request.newValue || request.changeType}
</Badge> </Badge>
</div> </div>
</div> </div>
@ -759,8 +759,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div key={stage.id} className="flex items-start gap-4"> <div key={stage.id} className="flex items-start gap-4">
{/* Status Icon */} {/* Status Icon */}
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${ <div className={`w-10 h-10 rounded-full flex items-center justify-center ${isCompleted ? 'bg-green-100' :
isCompleted ? 'bg-green-100' :
isCurrent ? 'bg-amber-100' : isCurrent ? 'bg-amber-100' :
'bg-slate-100' 'bg-slate-100'
}`}> }`}>
@ -773,8 +772,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
)} )}
</div> </div>
{index < workflowStages.length - 1 && ( {index < workflowStages.length - 1 && (
<div className={`w-0.5 h-12 ${ <div className={`w-0.5 h-12 ${isCompleted ? 'bg-green-300' : 'bg-slate-200'
isCompleted ? 'bg-green-300' : 'bg-slate-200'
}`} /> }`} />
)} )}
</div> </div>
@ -927,8 +925,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
return ( return (
<div <div
key={docNum} key={docNum}
className={`flex items-center justify-between p-3 rounded-lg border ${ className={`flex items-center justify-between p-3 rounded-lg border ${isRejected ? 'bg-red-50 border-red-200' :
isRejected ? 'bg-red-50 border-red-200' :
ok ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200' ok ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200'
}`} }`}
> >
@ -1013,7 +1010,12 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<Button size="sm" variant="outline" className="h-8 w-8 p-0" title="Download document"> <Button size="sm" variant="outline" className="h-8 w-8 p-0" title="Download document">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
{doc.status !== 'Verified' && doc.status !== 'Rejected' && currentUser?.role !== 'Dealer' && ( {doc.status !== 'Verified' && doc.status !== 'Rejected' && (() => {
const role = currentUser?.role || currentUser?.roleCode || '';
// SRS §12.2 — only authorized review roles can verify constitutional documents
const canVerifyDocs = ['DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
return canVerifyDocs;
})() && (
<> <>
<Button <Button
size="sm" size="sm"
@ -1058,10 +1060,33 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div className="space-y-4"> <div className="space-y-4">
{historyEntries.map((entry: any, index: number) => { {historyEntries.map((entry: any, index: number) => {
const pres = getConstitutionalHistoryPresentation(entry); const pres = getConstitutionalHistoryPresentation(entry);
// Clean actor name — deduplicate "Legal Admin · Legal Admin" pattern
const rawActor = entry.actor?.name || entry.userName || 'System';
const actorParts = rawActor.split('·').map((p: string) => p.trim());
const actorName = [...new Set(actorParts)].join(', ');
// Clean action label — strip [Master data] and UUID/technical prefixes
const rawStage = String(entry.stage || entry.action || '').trim();
const cleanStage = rawStage
.replace(/\[Master data\]\s*/i, '')
.replace(/Constitutional change [A-Z0-9-]+ (completed|updated):/i, '$1:')
.replace(/dealer constitution updated from "(.*)" to "(.*)"\.?/i, '"$1" → "$2"')
.trim();
// Human-readable heading: prefer stage, fall back to cleaned description
const heading = cleanStage || pres.badge || 'Action';
// Remarks: prefer user-entered remarks, strip auto-generated system strings
const rawRemarks = String(entry.remarks || '').trim();
const rawDescription = String(entry.description || '').trim();
const systemPrefixes = [/^Approval\s*-\s*Stage:/i, /^Record (created|updated)/i, /^Document (uploaded|verified|rejected)/i];
const isSystemDesc = systemPrefixes.some(re => re.test(rawDescription));
const displayRemarks = rawRemarks || (!isSystemDesc ? rawDescription : '') || null;
return ( return (
<div key={entry.id || index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0"> <div key={entry.id || index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${ <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${pres.variant === 'success' ? 'bg-green-100' :
pres.variant === 'success' ? 'bg-green-100' :
pres.variant === 'danger' ? 'bg-red-100' : pres.variant === 'danger' ? 'bg-red-100' :
pres.variant === 'pending' ? 'bg-amber-100' : pres.variant === 'pending' ? 'bg-amber-100' :
'bg-slate-100' 'bg-slate-100'
@ -1077,29 +1102,23 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between gap-2">
<div> <div>
<h4 className="text-slate-900">{entry.stage || entry.action}</h4> <h4 className="text-slate-900 capitalize">{heading}</h4>
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p> <p className="text-slate-500 text-sm">by <span className="font-medium text-slate-700">{actorName}</span></p>
</div> </div>
<Badge className={getStatusColor(pres.badge)}> <Badge className={getStatusColor(pres.badge)}>
{pres.badge} {pres.badge.replace(/_/g, ' ')}
</Badge> </Badge>
</div> </div>
<div className="mt-2 p-3 bg-slate-50 rounded border border-slate-100 italic text-slate-700 text-sm"> {displayRemarks && (
<span className="font-semibold non-italic text-slate-500 mr-2">Comments:</span> <div className="mt-2 p-3 bg-slate-50 rounded border border-slate-100 text-slate-700 text-sm">
{/* {displayRemarks}
Audit API puts user-entered text in `remarks` and a generated summary in `description`
(e.g. "Approval - Stage: ZM/RBM Review"). Prefer remarks so History matches the approve modal / Work Notes.
*/}
{String(entry.remarks || '').trim() ||
String(entry.description || '').trim() ||
'No remarks provided'}
</div> </div>
)}
<p className="text-slate-400 text-xs mt-2 font-medium uppercase tracking-wider"> <p className="text-slate-400 text-xs mt-2 font-medium uppercase tracking-wider">
{formatDateTime(entry.timestamp || entry.createdAt)} {formatDateTime(entry.timestamp || entry.createdAt)}
</p> </p>
</div> </div>
</div> </div>
); );
@ -1263,6 +1282,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<Label htmlFor="comments"> <Label htmlFor="comments">
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'} {actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'}
</Label> </Label>
</div>
<Textarea <Textarea
id="comments" id="comments"
value={comments} value={comments}
@ -1271,7 +1291,6 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
rows={4} rows={4}
required={actionType !== 'approve'} required={actionType !== 'approve'}
/> />
</div>
<DialogFooter> <DialogFooter>
<Button <Button

View File

@ -101,7 +101,7 @@ export function Dashboard({ onNavigate }: DashboardProps) {
color: 'bg-yellow-500', color: 'bg-yellow-500',
trend: { value: 2, isPositive: false }, trend: { value: 2, isPositive: false },
filter: 'all', filter: 'all',
action: 'all-applications' // Special action to navigate to all applications page action: 'opportunity-requests' // Changed from all-applications as per user request to hide that option
} }
]; ];

View File

@ -258,6 +258,13 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
uploadedOn: formatDateTime(c.clearedAt), uploadedOn: formatDateTime(c.clearedAt),
type: `${c.department} Proof`, type: `${c.department} Proof`,
url: c.supportingDocument url: c.supportingDocument
})),
...(s.clearanceDocuments || []).map((doc: any) => ({
name: doc.name || doc.supportingDocument?.split('/').pop() || 'Document',
size: 'N/A',
uploadedOn: formatDateTime(doc.clearedAt || s.createdAt),
type: 'Finance Upload',
url: doc.supportingDocument
})) }))
] ]
}; };
@ -448,8 +455,6 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
adjustments: '0' adjustments: '0'
}); });
const [uploadedDocuments, setUploadedDocuments] = useState<any[]>([]);
// Handlers for Payables // Handlers for Payables
const handleAddPayable = async () => { const handleAddPayable = async () => {
if (!newPayable.department || !newPayable.description || !newPayable.amount) { if (!newPayable.department || !newPayable.description || !newPayable.amount) {
@ -702,17 +707,26 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
} }
}; };
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files; const files = event.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
const newDocs = Array.from(files).map(file => ({ setLoading(true);
name: file.name, try {
size: `${(file.size / 1024).toFixed(0)} KB`, let successCount = 0;
uploadedOn: new Date().toISOString(), for (let i = 0; i < files.length; i++) {
type: 'Settlement Verification' const formData = new FormData();
})); formData.append('file', files[i]);
setUploadedDocuments([...uploadedDocuments, ...newDocs]); const response: any = await API.uploadFnFDocument(fnfId, formData);
toast.success(`${files.length} document(s) uploaded successfully`); if (response.data?.success) successCount++;
}
toast.success(`${successCount} document(s) uploaded successfully`);
fetchFnFDetails(false); // Fetch latest documents
} catch (error) {
toast.error('Failed to upload document(s)');
} finally {
setLoading(false);
}
} }
}; };
@ -1798,10 +1812,49 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<p className="text-sm text-slate-500">{doc.size} {doc.type} Uploaded on {doc.uploadedOn}</p> <p className="text-sm text-slate-500">{doc.size} {doc.type} Uploaded on {doc.uploadedOn}</p>
</div> </div>
</div> </div>
<Button variant="outline" size="sm"> <div className="flex items-center gap-2">
{doc.url && doc.url !== '#' && (
<button
onClick={() => setPreviewDocument({
fileName: doc.name,
filePath: doc.url,
documentType: doc.type
})}
className="text-amber-600 hover:text-amber-700 text-[10px] font-semibold flex items-center gap-1"
>
<Paperclip className="w-3 h-3" /> PREVIEW
</button>
)}
<Button variant="outline" size="sm" onClick={async () => {
if (doc.url && doc.url !== '#') {
try {
const response = await fetch(doc.url);
const blob = await response.blob();
const blobUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
link.download = doc.name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(blobUrl);
} catch (e) {
// Fallback if CORS prevents blob fetch
const link = document.createElement('a');
link.href = doc.url;
link.download = doc.name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
} else {
toast.error('Document URL not available');
}
}}>
Download Download
</Button> </Button>
</div> </div>
</div>
))} ))}
</div> </div>
</CardContent> </CardContent>
@ -1838,23 +1891,6 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</Button> </Button>
</label> </label>
</div> </div>
{uploadedDocuments.length > 0 && (
<div className="space-y-2">
<Label>Uploaded Documents</Label>
{uploadedDocuments.map((doc: any, index: number) => (
<div key={index} className="flex items-center justify-between p-3 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-green-600" />
<div>
<p className="text-slate-900">{doc.name}</p>
<p className="text-sm text-slate-500">{doc.size} {doc.type}</p>
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -314,7 +314,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<TableCell> <TableCell>
<Badge <Badge
variant={fnfCase.status === 'Settlement Approved' ? 'default' : 'secondary'} variant={fnfCase.status === 'Settlement Approved' ? 'default' : 'secondary'}
className={fnfCase.status === 'Settlement Approved' ? 'bg-green-600' : 'bg-amber-600'} className={fnfCase.status === 'Settlement Approved' ? 'bg-green-600 text-white' : 'bg-amber-600 text-white'}
> >
{fnfCase.status} {fnfCase.status}
</Badge> </Badge>
@ -695,7 +695,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<p className="text-sm text-slate-500">Status</p> <p className="text-sm text-slate-500">Status</p>
<Badge <Badge
variant={selectedCase.status === 'Settlement Approved' ? 'default' : 'secondary'} variant={selectedCase.status === 'Settlement Approved' ? 'default' : 'secondary'}
className={selectedCase.status === 'Settlement Approved' ? 'bg-green-600' : 'bg-amber-600'} className={selectedCase.status === 'Settlement Approved' ? 'bg-green-600 text-white' : 'bg-amber-600 text-white'}
> >
{selectedCase.status} {selectedCase.status}
</Badge> </Badge>

View File

@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
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, Edit2, AlertTriangle, Bell, Save } from 'lucide-react'; import { Clock, Pen, Bell, AlertTriangle, CheckCircle } from 'lucide-react';
import { RootState } from '@/store'; import { RootState } from '@/store';
interface SLAConfigurationProps { interface SLAConfigurationProps {
@ -18,31 +18,34 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<CardTitle>SLA & Escalation Matrix</CardTitle> <CardTitle>SLA Configuration</CardTitle>
<CardDescription>Configure Turn Around Time (TAT) and escalation rules for each process stage</CardDescription> <CardDescription>Configure service level agreements, reminders, and escalations for each stage</CardDescription>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-4">
{slaConfigs.map((sla) => ( {slaConfigs.map((sla) => (
<div key={sla.id} className="border rounded-xl p-5 space-y-4 bg-white shadow-sm hover:shadow-md transition-shadow"> <div key={sla.id} className="border rounded-lg p-4 bg-gradient-to-br from-white to-slate-50 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between"> <div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-50 rounded-lg flex items-center justify-center border border-amber-100">
<Clock className="w-5 h-5 text-amber-600" /> <Clock className="w-5 h-5 text-amber-600" />
</div>
<div> <div>
<h4 className="text-slate-900 font-medium">{sla.stage}</h4> <h4 className="text-slate-900 font-medium">{sla.activityName}</h4>
<p className="text-xs text-slate-500">Target TAT: <span className="text-amber-600 font-bold">{sla.days} Days</span></p> <p className="text-xs text-slate-600">TAT: {sla.tatHours} {sla.tatUnit}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={sla.enabled ? "default" : "secondary"} className={sla.enabled ? "bg-emerald-500" : ""}> <Badge className={sla.isActive ? "bg-green-600" : "bg-slate-400"}>
{sla.enabled ? 'Enabled' : 'Disabled'} {sla.isActive ? (
<><CheckCircle className="w-3 h-3 mr-1" /> Active</>
) : (
'Inactive'
)}
</Badge> </Badge>
<Button variant="ghost" size="sm" onClick={() => onConfigureSLA(sla)} className="text-slate-400 hover:text-amber-600"> <Button variant="outline" size="sm" onClick={() => onConfigureSLA(sla)} className="h-8 gap-1.5 px-3">
<Edit2 className="w-4 h-4" /> <Pen className="w-3 h-3 mr-2" />
Edit
</Button> </Button>
</div> </div>
</div> </div>
@ -51,42 +54,60 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
<div className="border-l-2 border-blue-400 pl-3"> <div className="border-l-2 border-blue-400 pl-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Bell className="w-4 h-4 text-blue-600" /> <Bell className="w-4 h-4 text-blue-600" />
<span className="text-xs text-slate-700 font-medium">Reminders ({sla.reminders.length})</span> <span className="text-xs text-slate-700">Reminders ({sla.reminders?.length || 0})</span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{sla.reminders.map((reminder: any, idx: number) => ( {(sla.reminders || []).map((reminder: any, idx: number) => (
<div key={idx} className="text-xs text-slate-600 flex items-center gap-1"> <div key={idx} className="text-xs text-slate-600 flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-4 px-1 bg-blue-50 border-blue-200"> <Badge variant="outline" className="text-xs bg-blue-50 border-blue-200">
{reminder.time} {reminder.unit} {reminder.timeValue} {reminder.timeUnit}
</Badge> </Badge>
<span>before</span> <span>before SLA</span>
</div> </div>
))} ))}
{(!sla.reminders || sla.reminders.length === 0) && (
<p className="text-[10px] text-slate-400">No reminders configured</p>
)}
</div> </div>
</div> </div>
<div className="border-l-2 border-red-400 pl-3"> <div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" /> <AlertTriangle className="w-4 h-4 text-red-600" />
<span className="text-xs text-slate-700 font-medium">Escalations ({sla.escalations.length})</span> <span className="text-xs text-slate-700">Escalations ({sla.escalationConfigs?.length || 0})</span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{sla.escalations.map((escalation: any, idx: number) => ( {(sla.escalationConfigs || []).map((esc: any, idx: number) => (
<div key={idx} className="text-xs"> <div key={idx} className="text-xs">
<Badge variant="outline" className="text-[10px] h-4 px-1 bg-red-50 border-red-200"> <div className="flex items-center gap-1 text-slate-900">
L{escalation.level} <Badge variant="outline" className="text-xs bg-red-50 border-red-200">
L{esc.level}
</Badge> </Badge>
<span className="text-slate-400 ml-1"> {escalation.userEmail.split('@')[0]}</span> <span>after {esc.timeValue} {esc.timeUnit}</span>
</div>
<p className="text-slate-500 ml-9"> {esc.notifyEmail}</p>
</div> </div>
))} ))}
{(!sla.escalationConfigs || sla.escalationConfigs.length === 0) && (
<p className="text-[10px] text-slate-400">No escalations configured</p>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
))} ))}
{slaConfigs.length === 0 && (
<div className="text-center py-12 border-2 border-dashed rounded-xl">
<Clock className="w-12 h-12 text-slate-300 mx-auto mb-3" />
<p className="text-slate-500 font-medium">No SLA configurations found</p>
<p className="text-slate-400 text-sm">Create a migration to seed default TAT values</p>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };

View File

@ -0,0 +1,356 @@
import React, { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Plus, Trash2, Bell, AlertTriangle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
import { masterService } from '@/services/master.service';
import { ROLES, STAGES_MAP } from '@/lib/constants';
interface SLADialogProps {
isOpen: boolean;
onClose: () => void;
sla: any | null;
onSave: () => void;
}
export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSave }) => {
const [formData, setFormData] = useState<any>({
activityName: '',
tatHours: 24,
tatUnit: 'hours',
isActive: true,
reminders: [],
escalationConfigs: []
});
const [loading, setLoading] = useState(false);
useEffect(() => {
if (sla) {
setFormData({
...sla,
reminders: sla.reminders || [],
escalationConfigs: sla.escalationConfigs || []
});
}
}, [sla]);
const handleAddReminder = () => {
setFormData({
...formData,
reminders: [...formData.reminders, { timeValue: 1, timeUnit: 'days', isEnabled: true }]
});
};
const handleRemoveReminder = (index: number) => {
const newReminders = [...formData.reminders];
newReminders.splice(index, 1);
setFormData({ ...formData, reminders: newReminders });
};
const handleAddEscalation = () => {
setFormData({
...formData,
escalationConfigs: [
...formData.escalationConfigs,
{ level: formData.escalationConfigs.length + 1, timeValue: 1, timeUnit: 'days', notifyEmail: '' }
]
});
};
const handleRemoveEscalation = (index: number) => {
const newEsc = [...formData.escalationConfigs];
newEsc.splice(index, 1);
// Re-adjust levels
const adjustedEsc = newEsc.map((e, i) => ({ ...e, level: i + 1 }));
setFormData({ ...formData, escalationConfigs: adjustedEsc });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.activityName || !formData.ownerRole) {
toast.error('Activity Name and Owner Role are required');
return;
}
try {
setLoading(true);
await masterService.saveSlaConfig(formData);
toast.success(sla?.id ? 'SLA Configuration updated' : 'New SLA Configuration created');
onSave();
onClose();
} catch (error) {
console.error('Save SLA error:', error);
toast.error('Failed to save SLA configuration');
} finally {
setLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{sla?.id ? 'Configure SLA' : 'Add New SLA'}: {formData.activityName || 'New Activity'}</DialogTitle>
<DialogDescription>
Define the Turn Around Time and notification rules for this stage.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="activityName">Activity Name (Workflow Stage)</Label>
<Select
value={formData.activityName}
onValueChange={(value) => {
let inferredRole = formData.ownerRole;
// Try to find the default owner in any module
for (const moduleStages of Object.values(STAGES_MAP)) {
if ((moduleStages as any)[value]) {
inferredRole = (moduleStages as any)[value];
break;
}
}
setFormData({ ...formData, activityName: value, ownerRole: inferredRole });
}}
disabled={!!sla?.id}
>
<SelectTrigger id="activityName">
<SelectValue placeholder="Select Stage" />
</SelectTrigger>
<SelectContent>
{Object.entries(STAGES_MAP).map(([module, stages]) => (
<React.Fragment key={module}>
<div className="px-2 py-1.5 text-xs font-semibold text-slate-500 bg-slate-50">{module}</div>
{Object.keys(stages).map(stage => (
<SelectItem key={stage} value={stage}>{stage}</SelectItem>
))}
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="ownerRole">Owner Role (Auto-resolved)</Label>
<Select
value={formData.ownerRole}
onValueChange={(value) => setFormData({ ...formData, ownerRole: value })}
disabled={!!sla?.id}
>
<SelectTrigger id="ownerRole">
<SelectValue placeholder="Select Role" />
</SelectTrigger>
<SelectContent>
{Object.values(ROLES).map(role => (
<SelectItem key={role} value={role}>{role}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tatHours">Target TAT</Label>
<div className="flex gap-2">
<Input
id="tatHours"
type="number"
value={formData.tatHours}
onChange={(e) => setFormData({ ...formData, tatHours: parseInt(e.target.value) })}
/>
<Select
value={formData.tatUnit}
onValueChange={(value) => setFormData({ ...formData, tatUnit: value })}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center space-x-2 pt-8">
<Switch
id="isActive"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive">Active SLA Tracking</Label>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2">
<Bell className="w-4 h-4 text-blue-600" />
<h4 className="font-medium text-sm">Reminders</h4>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddReminder} className="h-7 text-xs">
<Plus className="w-3 h-3 mr-1" /> Add Reminder
</Button>
</div>
<div className="space-y-3">
{formData.reminders.map((reminder: any, idx: number) => (
<div key={idx} className="flex items-center gap-3 bg-slate-50 p-2 rounded-lg border border-slate-100">
<span className="text-xs font-medium text-slate-500 w-12 text-center">Before</span>
<Input
type="number"
className="w-20 h-8"
value={reminder.timeValue}
onChange={(e) => {
const newReminders = [...formData.reminders];
newReminders[idx].timeValue = parseInt(e.target.value);
setFormData({ ...formData, reminders: newReminders });
}}
/>
<Select
value={reminder.timeUnit}
onValueChange={(value) => {
const newReminders = [...formData.reminders];
newReminders[idx].timeUnit = value;
setFormData({ ...formData, reminders: newReminders });
}}
>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveReminder(idx)}
className="h-8 w-8 p-0 text-slate-400 hover:text-red-500"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
))}
{formData.reminders.length === 0 && (
<p className="text-center text-xs text-slate-400 py-2">No reminders set</p>
)}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<h4 className="font-medium text-sm">Escalation Levels</h4>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddEscalation} className="h-7 text-xs">
<Plus className="w-3 h-3 mr-1" /> Add Level
</Button>
</div>
<div className="space-y-3">
{formData.escalationConfigs.map((esc: any, idx: number) => (
<div key={idx} className="bg-slate-50 p-3 rounded-lg border border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-100">
Level {esc.level}
</Badge>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleRemoveEscalation(idx)}
className="h-7 w-7 p-0 text-slate-400 hover:text-red-500"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label className="text-[10px] uppercase text-slate-500">Escalate After Breaching By</Label>
<div className="flex gap-2">
<Input
type="number"
className="h-8"
value={esc.timeValue}
onChange={(e) => {
const newEsc = [...formData.escalationConfigs];
newEsc[idx].timeValue = parseInt(e.target.value);
setFormData({ ...formData, escalationConfigs: newEsc });
}}
/>
<Select
value={esc.timeUnit}
onValueChange={(value) => {
const newEsc = [...formData.escalationConfigs];
newEsc[idx].timeUnit = value;
setFormData({ ...formData, escalationConfigs: newEsc });
}}
>
<SelectTrigger className="w-24 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hours">Hours</SelectItem>
<SelectItem value="days">Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-[10px] uppercase text-slate-500">Notification Recipient (Role)</Label>
<Select
value={esc.notifyRole || ''}
onValueChange={(value) => {
const newEsc = [...formData.escalationConfigs];
newEsc[idx].notifyRole = value;
newEsc[idx].notifyEmail = ''; // Clear explicit email
setFormData({ ...formData, escalationConfigs: newEsc });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select Recipient Role" />
</SelectTrigger>
<SelectContent>
{Object.values(ROLES).map(role => (
<SelectItem key={role} value={role}>{role}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
))}
{formData.escalationConfigs.length === 0 && (
<p className="text-center text-xs text-slate-400 py-2">No escalation levels defined</p>
)}
</div>
</div>
<DialogFooter className="pt-4 border-t">
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Configuration'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
};

View File

@ -3,7 +3,7 @@ import { useSelector } from 'react-redux';
import { import {
Tabs, TabsContent, TabsList, TabsTrigger Tabs, TabsContent, TabsList, TabsTrigger
} from '@/components/ui/tabs'; } from '@/components/ui/tabs';
import { Globe, Shield, Clock, Mail, MapPin, SlidersHorizontal, Settings, FileText } from 'lucide-react'; import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -18,7 +18,6 @@ import { RegionalManagement } from '@/features/master/components/RegionalManagem
import { ASMManagement } from '@/features/master/components/ASMManagement'; import { ASMManagement } from '@/features/master/components/ASMManagement';
import { ZMManagement } from '@/features/master/components/ZMManagement'; import { ZMManagement } from '@/features/master/components/ZMManagement';
import { UserManagementTable } from '@/features/master/components/UserManagementTable'; import { UserManagementTable } from '@/features/master/components/UserManagementTable';
import { SLAConfiguration } from '@/features/master/components/SLAConfiguration';
import { RolePermissions } from '@/features/master/components/RolePermissions'; import { RolePermissions } from '@/features/master/components/RolePermissions';
import { RoleDialog } from '@/features/master/components/RoleDialog'; import { RoleDialog } from '@/features/master/components/RoleDialog';
import { AddRoleDialog } from '@/features/master/components/AddRoleDialog'; import { AddRoleDialog } from '@/features/master/components/AddRoleDialog';
@ -396,7 +395,7 @@ export const MasterPage: React.FC = () => {
const payload = { const payload = {
id: editingLocationId, id: editingLocationId,
stateId: locationState, stateId: locationState,
stateName: selectedState?.name || selectedState?.stateName || '', stateName: (selectedState as any)?.name || (selectedState as any)?.stateName || '',
districtId: locationDistrict, districtId: locationDistrict,
name: locationCity || selectedDistrict?.name || 'New Location', name: locationCity || selectedDistrict?.name || 'New Location',
city: locationCity, city: locationCity,
@ -440,16 +439,13 @@ export const MasterPage: React.FC = () => {
</div> </div>
) : ( ) : (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6"> <Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-7 h-auto sticky top-0 z-10 bg-white/80 backdrop-blur-sm border shadow-sm rounded-xl p-1"> <TabsList className="grid w-full grid-cols-6 h-auto sticky top-0 z-10 bg-white/80 backdrop-blur-sm border shadow-sm rounded-xl p-1">
<TabsTrigger value="hierarchy" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white"> <TabsTrigger value="hierarchy" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<Globe className="w-4 h-4" /> Organisation <Globe className="w-4 h-4" /> Organisation
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white"> <TabsTrigger value="roles" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<Shield className="w-4 h-4" /> Roles <Shield className="w-4 h-4" /> Roles
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="sla" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<Clock className="w-4 h-4" /> SLA Config
</TabsTrigger>
<TabsTrigger value="templates" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white"> <TabsTrigger value="templates" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<Mail className="w-4 h-4" /> Emails <Mail className="w-4 h-4" /> Emails
</TabsTrigger> </TabsTrigger>
@ -505,10 +501,6 @@ export const MasterPage: React.FC = () => {
onEditRole={handleEditRole} /> onEditRole={handleEditRole} />
</TabsContent> </TabsContent>
<TabsContent value="sla" className="animate-in fade-in duration-300">
<SLAConfiguration onConfigureSLA={() => toast.info('SLA Matrix Configuration interface being updated')} />
</TabsContent>
<TabsContent value="templates" className="animate-in fade-in duration-300"> <TabsContent value="templates" className="animate-in fade-in duration-300">
<EmailTemplates <EmailTemplates
onEditTemplate={(template) => { onEditTemplate={(template) => {

View File

@ -0,0 +1,180 @@
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Clock, Plus, Pen, Bell, AlertTriangle, CheckCircle, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { masterService } from '@/services/master.service';
import { setMasterData } from '@/store/slices/masterSlice';
import { SLADialog } from '@/features/master/components/SLADialog';
export const SLAConfigPage: React.FC = () => {
const dispatch = useDispatch();
const { slaConfigs, loading } = useSelector((state: RootState) => state.master);
const [showSLADialog, setShowSLADialog] = useState(false);
const [selectedSLA, setSelectedSLA] = useState<any>(null);
const fetchConfigs = async () => {
try {
const res = await masterService.getSlaConfigs() as any;
if (res && res.success) {
dispatch(setMasterData({ slaConfigs: res.data }));
}
} catch (error) {
toast.error('Failed to fetch SLA configurations');
}
};
useEffect(() => {
fetchConfigs();
}, []);
const handleInitialize = async () => {
try {
setLoadingMore(true);
const res = await masterService.initializeDefaultSlas() as any;
if (res && res.success) {
toast.success('Default SLAs initialized successfully');
fetchConfigs();
}
} catch (error) {
toast.error('Failed to initialize default SLAs');
} finally {
setLoadingMore(false);
}
};
const handleEdit = (sla: any) => {
setSelectedSLA(sla);
setShowSLADialog(true);
};
const handleAddSLA = () => {
setSelectedSLA(null);
setShowSLADialog(true);
};
const [loadingMore, setLoadingMore] = useState(false);
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Clock className="w-6 h-6 text-amber-600" />
SLA & Escalation Matrix
</h1>
<p className="text-slate-500">Configure Turn Around Time (TAT) and escalation rules for each process stage</p>
</div>
<div className="flex items-center gap-3">
<Button variant="outline" onClick={handleInitialize} disabled={loading || loadingMore}>
<RefreshCw className={`w-4 h-4 mr-2 ${loadingMore ? 'animate-spin' : ''}`} />
Initialize Defaults
</Button>
<Button onClick={handleAddSLA} disabled={loading}>
<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 className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{slaConfigs.map((sla) => (
<Card key={sla.id} className="border-slate-200 hover:shadow-md transition-shadow group overflow-hidden">
<CardHeader className="pb-3 bg-gradient-to-br from-white to-slate-50/50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-100 flex items-center justify-center">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<div>
<CardTitle className="text-lg">{sla.activityName}</CardTitle>
<CardDescription className="flex items-center gap-1.5 mt-0.5">
<span className="font-semibold text-amber-700">Target TAT: {sla.tatHours} {sla.tatUnit}</span>
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={sla.isActive ? "default" : "secondary"} className={sla.isActive ? "bg-emerald-600" : "bg-slate-400"}>
{sla.isActive ? (
<><CheckCircle className="w-3 h-3 mr-1" /> Active</>
) : (
'Disabled'
)}
</Badge>
<Button variant="ghost" size="icon" onClick={() => handleEdit(sla)} className="h-8 w-8 text-slate-400 hover:text-amber-600">
<Pen className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="pt-4 grid grid-cols-2 gap-4">
<div className="border-l-2 border-blue-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<Bell className="w-4 h-4 text-blue-600" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">Reminders ({sla.reminders?.length || 0})</span>
</div>
<div className="space-y-1.5">
{(sla.reminders || []).map((reminder: any, idx: number) => (
<div key={idx} className="text-xs text-slate-600 flex items-center gap-1">
<Badge variant="outline" className="text-[10px] h-4.5 bg-blue-50 border-blue-200">
{reminder.timeValue} {reminder.timeUnit}
</Badge>
<span>before SLA</span>
</div>
))}
{(!sla.reminders || sla.reminders.length === 0) && (
<p className="text-[10px] text-slate-400 italic">None configured</p>
)}
</div>
</div>
<div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<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-red-700">
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>
<SLADialog
isOpen={showSLADialog}
onClose={() => setShowSLADialog(false)}
sla={selectedSLA}
onSave={fetchConfigs}
/>
</div>
);
};

View File

@ -1,7 +1,6 @@
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
AlertCircle, AlertCircle,
Calendar,
Check, Check,
CheckCircle, CheckCircle,
CheckCircle2, CheckCircle2,
@ -12,7 +11,6 @@ import {
Download, Download,
FileText, FileText,
GitBranch, GitBranch,
Info,
Lock, Lock,
RefreshCw, RefreshCw,
ShieldCheck, ShieldCheck,
@ -433,7 +431,10 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
setShowDocumentsModal(true); setShowDocumentsModal(true);
if (stageDocs.length === 0) setShowUploadForm(true); if (stageDocs.length === 0) setShowUploadForm(true);
}} }}
className="text-[10px] font-medium text-blue-600 hover:text-blue-800 flex items-center gap-1 transition-colors" className={cn(
"text-[10px] font-medium flex items-center gap-1 transition-colors",
branchColor === 'blue' ? "text-blue-600 hover:text-blue-800" : "text-green-600 hover:text-green-800"
)}
data-testid={`onboarding-progress-branch-stage-docs-${branchKey}-${bsIdx}`} data-testid={`onboarding-progress-branch-stage-docs-${branchKey}-${bsIdx}`}
> >
<FileText className="w-2.5 h-2.5" /> <FileText className="w-2.5 h-2.5" />

View File

@ -81,7 +81,7 @@ export function useApplicationDetailsStageData({
id: 11, name: 'Dealer Code Generation', status: getStageStatus('Dealer Code Generation', () => (application.dealerCode || ['Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status)) ? 'completed' : 'pending'), id: 11, name: 'Dealer Code Generation', status: getStageStatus('Dealer Code Generation', () => (application.dealerCode || ['Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status)) ? 'completed' : 'pending'),
date: application.dealerCodeDate, description: 'Dealer code generated and assigned', isParallel: true, date: application.dealerCodeDate, description: 'Dealer code generated and assigned', isParallel: true,
branches: [ branches: [
{ name: 'Architectural Work', color: 'blue', stages: [ { name: 'Architectural Work', color: 'green', stages: [
{ id: '11a-1', name: 'Architecture Assignment', status: application.architectureAssignedTo ? 'completed' : application.status === 'Architecture Team Assigned' ? 'active' : 'pending', description: 'Assigned to architecture team' }, { id: '11a-1', name: 'Architecture Assignment', status: application.architectureAssignedTo ? 'completed' : application.status === 'Architecture Team Assigned' ? 'active' : 'pending', description: 'Assigned to architecture team' },
{ id: '11a-2', name: 'Site Plan Blueprint', status: isDocumentUploaded('Architecture Blueprint') ? 'completed' : application.architectureAssignedTo ? 'active' : 'pending', description: 'Blueprints and site plans' }, { id: '11a-2', name: 'Site Plan Blueprint', status: isDocumentUploaded('Architecture Blueprint') ? 'completed' : application.architectureAssignedTo ? 'active' : 'pending', description: 'Blueprints and site plans' },
{ id: '11a-3', name: 'Architecture Work', status: application.architectureStatus === 'COMPLETED' ? 'completed' : (application.architectureStatus === 'IN_PROGRESS' || isDocumentUploaded('Architecture Blueprint')) ? 'active' : 'pending', description: 'Final architecture approval' }, { id: '11a-3', name: 'Architecture Work', status: application.architectureStatus === 'COMPLETED' ? 'completed' : (application.architectureStatus === 'IN_PROGRESS' || isDocumentUploaded('Architecture Blueprint')) ? 'active' : 'pending', description: 'Final architecture approval' },

View File

@ -1019,7 +1019,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
{doc.status === 'Pending Verification' && currentUser?.role !== 'Dealer' && ( {doc.status === 'Pending Verification' && (() => {
const role = currentUser?.role || currentUser?.roleCode || '';
// SRS — only authorized review roles can verify relocation documents
return ['DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
})() && (
<> <>
<Button <Button
size="sm" size="sm"

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Calendar, CheckCircle2, Clock, FileText, MapPin, MessageSquare, Upload, User } from 'lucide-react'; import { ArrowLeft, Calendar, CheckCircle2, Clock, FileText, MapPin, MessageSquare, Upload, User, XCircle } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -6,11 +6,22 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { resignationService } from '@/services/resignation.service'; import { resignationService } from '@/services/resignation.service';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { RESIGNATION_STAGE_OPTIONS, RESIGNATION_DOCUMENT_TYPES } from '@/lib/offboardingDocumentOptions'; import { RESIGNATION_STAGE_OPTIONS, RESIGNATION_DOCUMENT_TYPES } from '@/lib/offboardingDocumentOptions';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
interface DealerResignationDetailsPageProps { interface DealerResignationDetailsPageProps {
resignationId: string; resignationId: string;
@ -19,7 +30,7 @@ interface DealerResignationDetailsPageProps {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300'; if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status === 'Rejected') return 'bg-red-100 text-red-700 border-red-300'; if (status === 'Rejected' || status === 'Withdrawn') return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300'; if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
return 'bg-slate-100 text-slate-700 border-slate-300'; return 'bg-slate-100 text-slate-700 border-slate-300';
}; };
@ -33,6 +44,9 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]); const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState<string>(''); const [uploadStage, setUploadStage] = useState<string>('');
const [auditLogs, setAuditLogs] = useState<any[]>([]); const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [withdrawing, setWithdrawing] = useState(false);
const [isWithdrawDialogOpen, setIsWithdrawDialogOpen] = useState(false);
const [withdrawalReason, setWithdrawalReason] = useState('User requested withdrawal');
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
@ -104,6 +118,20 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
} }
}; };
const handleWithdraw = async () => {
try {
setWithdrawing(true);
await resignationService.withdraw(resignationId, withdrawalReason);
toast.success('Resignation request withdrawn successfully');
setIsWithdrawDialogOpen(false);
await refreshDetails();
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Failed to withdraw resignation');
} finally {
setWithdrawing(false);
}
};
if (loading) { if (loading) {
return ( return (
<div className="min-h-[320px] flex items-center justify-center"> <div className="min-h-[320px] flex items-center justify-center">
@ -138,14 +166,61 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
<ArrowLeft className="w-4 h-4 mr-2" /> <ArrowLeft className="w-4 h-4 mr-2" />
Back Back
</Button> </Button>
<div> <div className="flex-1">
<h1 className="text-slate-900">Resignation Request Details</h1> <h1 className="text-slate-900">Resignation Request Details</h1>
<p className="text-slate-600 text-sm"> <p className="text-slate-600 text-sm">
Track your request progress and uploaded documents Track your request progress and uploaded documents
</p> </p>
</div> </div>
{details.status !== 'Withdrawn' &&
details.status !== 'Completed' &&
details.status !== 'Rejected' &&
!['NBH', 'DD Admin', 'Legal', 'F&F Initiated'].includes(details.currentStage) && (
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-700"
onClick={() => setIsWithdrawDialogOpen(true)}
disabled={withdrawing}
>
<XCircle className="w-4 h-4 mr-2" />
{withdrawing ? 'Withdrawing...' : 'Withdraw Request'}
</Button>
)}
</div> </div>
<AlertDialog open={isWithdrawDialogOpen} onOpenChange={setIsWithdrawDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action will withdraw your resignation request. This action cannot be undone and you will need to submit a new request if you change your mind.
</AlertDialogDescription>
<div className="mt-4 space-y-2">
<Label htmlFor="withdrawal-reason">Reason for withdrawal</Label>
<Input
id="withdrawal-reason"
placeholder="Please enter a reason..."
value={withdrawalReason}
onChange={(e) => setWithdrawalReason(e.target.value)}
/>
</div>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={withdrawing}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleWithdraw();
}}
className="bg-red-600 hover:bg-red-700 text-white"
disabled={withdrawing}
>
{withdrawing ? 'Withdrawing...' : 'Yes, Withdraw Request'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">

View File

@ -192,7 +192,7 @@ 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 && !isFinalState, canPushToFnF: canPushToFnF && !isSettlementPhase && ['Legal - Termination Letter', 'Terminated', 'Dealer Terminated'].includes(currentStage),
canWithdraw: userRole === 'ASM' && currentStage === 'Request Initiated' && !isFinalState, canWithdraw: userRole === 'ASM' && currentStage === 'Request Initiated' && !isFinalState,
isFinalState, isFinalState,
isSettlementPhase isSettlementPhase
@ -250,6 +250,7 @@ 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'].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;
@ -262,6 +263,11 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
const currentIndex = stageSequence.indexOf(currentCanonical); const currentIndex = stageSequence.indexOf(currentCanonical);
const stageIndex = stageSequence.indexOf(stageName); const stageIndex = stageSequence.indexOf(stageName);
// If workflow finished successfully or entered F&F, ALL reached stages (including Terminated itself) turn completed
if (isSuccessFinal && stageIndex <= currentIndex) {
return 'completed';
}
if (stageIndex === -1) return 'pending'; if (stageIndex === -1) return 'pending';
if (currentIndex === -1) return stageName === 'Submitted' ? 'completed' : 'pending'; if (currentIndex === -1) return stageName === 'Submitted' ? 'completed' : 'pending';
@ -271,8 +277,6 @@ export function TerminationDetails({ terminationId, onBack, currentUser }: Termi
return 'active'; return 'active';
} }
if (stageName === 'Dealer Terminated' && currentIndex >= stageIndex) return 'completed';
return 'pending'; return 'pending';
}; };

65
src/lib/constants.ts Normal file
View File

@ -0,0 +1,65 @@
export const ROLES = {
DD: 'DD',
DD_ZM: 'DD-ZM',
RBM: 'RBM',
ZBH: 'ZBH',
DD_LEAD: 'DD Lead',
DD_HEAD: 'DD Head',
NBH: 'NBH',
DD_ADMIN: 'DD Admin',
LEGAL_ADMIN: 'Legal Admin',
SUPER_ADMIN: 'Super Admin',
DD_AM: 'DD AM',
ASM: 'ASM',
FINANCE: 'Finance',
DEALER: 'Dealer',
ARCHITECTURE: 'ARCHITECTURE',
FDD: 'FDD',
CCO: 'CCO',
CEO: 'CEO'
} as const;
export const STAGES_MAP = {
'ONBOARDING': {
'General': ROLES.DD_ADMIN,
'KYC': ROLES.DD_ADMIN,
'Level 1 Interview': ROLES.RBM,
'Level 2 Interview': ROLES.ZBH,
'Level 3 Interview': ROLES.NBH,
'FDD': ROLES.FDD,
'LOI Approval': `${ROLES.DD_HEAD},${ROLES.NBH}`,
'LOA Approval': ROLES.NBH,
'LOI Issue': ROLES.DD_ADMIN,
'Architecture Team Assigned': ROLES.ARCHITECTURE,
'Architecture Document Upload': ROLES.ARCHITECTURE,
'Architecture Team Completion': ROLES.ARCHITECTURE,
'EOR': ROLES.DD_ADMIN,
'Inauguration': ROLES.DD_ADMIN
},
'RESIGNATION': {
'Submission': ROLES.DEALER,
'Regional Review': ROLES.RBM,
'ZM Review': ROLES.DD_ZM,
'ZBH Review': ROLES.ZBH,
'Finance Review': ROLES.FINANCE,
'DDL Review': ROLES.DD_LEAD,
'Approved': ROLES.DD_HEAD
},
'RELOCATION': {
'Initiated': ROLES.DEALER,
'ASM Review': ROLES.ASM,
'ZM Review': ROLES.DD_ZM,
'ZBH Review': ROLES.ZBH,
'Completed': ROLES.DD_HEAD
},
'CONSTITUTIONAL_CHANGE': {
'Draft': ROLES.DEALER,
'Legal Review': ROLES.LEGAL_ADMIN,
'Approved': ROLES.DD_HEAD
},
'TERMINATION': {
'Hearing': ROLES.NBH,
'Review': ROLES.DD_LEAD,
'Closed': ROLES.DD_HEAD
}
} as const;

View File

@ -133,6 +133,14 @@ export const masterService = {
const response = await API.getSlaConfigs(); const response = await API.getSlaConfigs();
return response.data; return response.data;
}, },
saveSlaConfig: async (data: any) => {
const response = await API.saveSlaConfig(data);
return response.data;
},
initializeDefaultSlas: async () => {
const response = await (API as any).initializeDefaultSlas();
return response.data;
},
// Semantic wrappers for consistent API // Semantic wrappers for consistent API
saveZone: async (data: any) => { saveZone: async (data: any) => {

View File

@ -45,5 +45,14 @@ export const resignationService = {
console.error('Upload resignation document error:', error); console.error('Upload resignation document error:', error);
throw error; throw error;
} }
},
withdraw: async (id: string, reason: string) => {
try {
const response: any = await API.withdrawResignation(id, reason);
return response.data;
} catch (error) {
console.error('Withdraw resignation error:', error);
throw error;
}
} }
}; };