major chnges made in all modules lin worknote nline history audit log enhncement progress bar improvement aross differnt modules
This commit is contained in:
parent
d3bdea8318
commit
8ea748fde6
@ -97,7 +97,7 @@ export const API = {
|
|||||||
deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
|
deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
|
||||||
|
|
||||||
// Dealer & Outlets
|
// Dealer & Outlets
|
||||||
getDealers: () => client.get('/dealer'),
|
getDealers: (params?: { onboarded?: string }) => client.get('/dealer', { params }),
|
||||||
createDealer: (data: any) => client.post('/dealer', data),
|
createDealer: (data: any) => client.post('/dealer', data),
|
||||||
getDealerById: (id: string) => client.get(`/dealer/${id}`),
|
getDealerById: (id: string) => client.get(`/dealer/${id}`),
|
||||||
updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data),
|
updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data),
|
||||||
@ -180,8 +180,11 @@ export const API = {
|
|||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
}),
|
}),
|
||||||
verifyRelocationDocument: (id: string, documentId: string) => client.post(`/relocation/${id}/documents/${documentId}/verify`),
|
verifyRelocationDocument: (id: string, documentId: string) => client.post(`/relocation/${id}/documents/${documentId}/verify`),
|
||||||
|
rejectRelocationDocument: (id: string, documentId: string, data?: any) =>
|
||||||
|
client.post(`/relocation/${id}/documents/${documentId}/reject`, data || {}),
|
||||||
|
|
||||||
getConstitutionalChanges: () => client.get('/constitutional-change'),
|
getConstitutionalChanges: () => client.get('/constitutional-change'),
|
||||||
|
getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'),
|
||||||
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
|
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
|
||||||
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
|
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
|
||||||
updateConstitutionalChange: (id: string, action: string, data?: any) => client.post(`/constitutional-change/${id}/action`, { action, ...data }),
|
updateConstitutionalChange: (id: string, action: string, data?: any) => client.post(`/constitutional-change/${id}/action`, { action, ...data }),
|
||||||
|
|||||||
@ -255,6 +255,26 @@ const KT_MATRIX_CRITERIA = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function auditLogActionBadgeClass(action: string): string {
|
||||||
|
const a = String(action || '').toUpperCase();
|
||||||
|
if (a.includes('REJECT') || a.includes('DELET') || a.includes('DISQUALIF')) {
|
||||||
|
return 'border-red-200 bg-red-50/90 text-red-800';
|
||||||
|
}
|
||||||
|
if (a === 'CREATED' || a.includes('APPROV') || a.includes('COMPLETE')) {
|
||||||
|
return 'border-emerald-200 bg-emerald-50/90 text-emerald-900';
|
||||||
|
}
|
||||||
|
if (a.includes('DOCUMENT') || a.includes('UPLOAD') || a.includes('ATTACHMENT')) {
|
||||||
|
return 'border-sky-200 bg-sky-50/80 text-sky-900';
|
||||||
|
}
|
||||||
|
if (a.includes('PAYMENT') || a.includes('SECURITY') || a.includes('DEPOSIT')) {
|
||||||
|
return 'border-violet-200 bg-violet-50/80 text-violet-900';
|
||||||
|
}
|
||||||
|
if (a.includes('FDD') || a.includes('QUESTIONNAIRE') || a.includes('INTERVIEW')) {
|
||||||
|
return 'border-amber-200 bg-amber-50/80 text-amber-900';
|
||||||
|
}
|
||||||
|
return 'border-slate-200 bg-slate-50 text-slate-700';
|
||||||
|
}
|
||||||
|
|
||||||
export const ApplicationDetails = () => {
|
export const ApplicationDetails = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -442,7 +462,7 @@ export const ApplicationDetails = () => {
|
|||||||
const fetchAuditLogs = async () => {
|
const fetchAuditLogs = async () => {
|
||||||
setAuditLoading(true);
|
setAuditLoading(true);
|
||||||
try {
|
try {
|
||||||
const logs = await auditService.getAuditLogs('application', application.id);
|
const logs = await auditService.getAuditLogs('application', application.id, 1, 100);
|
||||||
setAuditLogs(Array.isArray(logs) ? logs : []);
|
setAuditLogs(Array.isArray(logs) ? logs : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch audit logs', error);
|
console.error('Failed to fetch audit logs', error);
|
||||||
@ -730,6 +750,7 @@ export const ApplicationDetails = () => {
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setKtMatrixScores({});
|
setKtMatrixScores({});
|
||||||
|
setKtMatrixSelectedValues({});
|
||||||
setKtMatrixRemarks('');
|
setKtMatrixRemarks('');
|
||||||
await fetchInterviews();
|
await fetchInterviews();
|
||||||
await fetchApplication(); // Refresh application status and progress
|
await fetchApplication(); // Refresh application status and progress
|
||||||
@ -1205,7 +1226,28 @@ export const ApplicationDetails = () => {
|
|||||||
{
|
{
|
||||||
id: 8,
|
id: 8,
|
||||||
name: 'LOI Approval',
|
name: 'LOI Approval',
|
||||||
status: getStageStatus('LOI Approval', () => ['Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOI In Progress' ? 'active' : 'pending'),
|
status: getStageStatus('LOI Approval', () =>
|
||||||
|
[
|
||||||
|
'Security Details',
|
||||||
|
'Payment Pending',
|
||||||
|
'LOI Issued',
|
||||||
|
'Statutory LOI Ack',
|
||||||
|
'Dealer Code Generation',
|
||||||
|
'Architecture Work',
|
||||||
|
'Statutory Work',
|
||||||
|
'LOA Pending',
|
||||||
|
'LOA Issued',
|
||||||
|
'EOR In Progress',
|
||||||
|
'EOR Complete',
|
||||||
|
'Inauguration',
|
||||||
|
'Approved',
|
||||||
|
'Onboarded',
|
||||||
|
].includes(application.status)
|
||||||
|
? 'completed'
|
||||||
|
: application.status === 'LOI In Progress'
|
||||||
|
? 'active'
|
||||||
|
: 'pending',
|
||||||
|
),
|
||||||
date: application.loiApprovalDate,
|
date: application.loiApprovalDate,
|
||||||
description: 'Letter of Intent approval',
|
description: 'Letter of Intent approval',
|
||||||
evaluators: Array.from(new Set((application.participants || [])
|
evaluators: Array.from(new Set((application.participants || [])
|
||||||
@ -1217,7 +1259,26 @@ export const ApplicationDetails = () => {
|
|||||||
{
|
{
|
||||||
id: 9,
|
id: 9,
|
||||||
name: 'Security Details',
|
name: 'Security Details',
|
||||||
status: getStageStatus('Security Details', () => ['LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Payment Pending' ? 'active' : 'pending'),
|
status: getStageStatus('Security Details', () =>
|
||||||
|
[
|
||||||
|
'LOI Issued',
|
||||||
|
'Statutory LOI Ack',
|
||||||
|
'Dealer Code Generation',
|
||||||
|
'Architecture Work',
|
||||||
|
'Statutory Work',
|
||||||
|
'LOA Pending',
|
||||||
|
'LOA Issued',
|
||||||
|
'EOR In Progress',
|
||||||
|
'EOR Complete',
|
||||||
|
'Inauguration',
|
||||||
|
'Approved',
|
||||||
|
'Onboarded',
|
||||||
|
].includes(application.status)
|
||||||
|
? 'completed'
|
||||||
|
: application.status === 'Security Details' || application.status === 'Payment Pending'
|
||||||
|
? 'active'
|
||||||
|
: 'pending',
|
||||||
|
),
|
||||||
date: application.securityDetailsDate,
|
date: application.securityDetailsDate,
|
||||||
description: 'Security verification',
|
description: 'Security verification',
|
||||||
documentsUploaded: 3
|
documentsUploaded: 3
|
||||||
@ -1225,10 +1286,27 @@ export const ApplicationDetails = () => {
|
|||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
name: 'LOI Issue',
|
name: 'LOI Issue',
|
||||||
status: getStageStatus('LOI Issue', () =>
|
status: getStageStatus('LOI Issue', () => {
|
||||||
['Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' :
|
if (
|
||||||
application.status === 'LOI Issued' ? 'active' : 'pending'
|
[
|
||||||
),
|
'Statutory LOI Ack',
|
||||||
|
'Dealer Code Generation',
|
||||||
|
'Architecture Work',
|
||||||
|
'Statutory Work',
|
||||||
|
'LOA Pending',
|
||||||
|
'LOA Issued',
|
||||||
|
'EOR In Progress',
|
||||||
|
'EOR Complete',
|
||||||
|
'Inauguration',
|
||||||
|
'Approved',
|
||||||
|
'Onboarded',
|
||||||
|
].includes(application.status)
|
||||||
|
) {
|
||||||
|
return 'completed';
|
||||||
|
}
|
||||||
|
if (application.status === 'LOI Issued') return 'active';
|
||||||
|
return 'pending';
|
||||||
|
}),
|
||||||
date: application.loiIssueDate,
|
date: application.loiIssueDate,
|
||||||
description: 'Letter of Intent issued',
|
description: 'Letter of Intent issued',
|
||||||
documentsUploaded: 1
|
documentsUploaded: 1
|
||||||
@ -1446,6 +1524,7 @@ export const ApplicationDetails = () => {
|
|||||||
case 'FDD Verification':
|
case 'FDD Verification':
|
||||||
newStatus = 'LOI In Progress'; break;
|
newStatus = 'LOI In Progress'; break;
|
||||||
case 'LOI In Progress':
|
case 'LOI In Progress':
|
||||||
|
newStatus = 'Security Details'; break;
|
||||||
case 'Security Details':
|
case 'Security Details':
|
||||||
case 'Payment Pending':
|
case 'Payment Pending':
|
||||||
newStatus = 'LOI Issued'; break;
|
newStatus = 'LOI Issued'; break;
|
||||||
@ -1773,7 +1852,14 @@ export const ApplicationDetails = () => {
|
|||||||
// Centralized Permissions Utility (Consolidates 500 lines of fragmented logic)
|
// Centralized Permissions Utility (Consolidates 500 lines of fragmented logic)
|
||||||
const getApplicationPermissions = () => {
|
const getApplicationPermissions = () => {
|
||||||
if (!application || !currentUser) {
|
if (!application || !currentUser) {
|
||||||
return { canApprove: false, canReject: false, canSchedule: false, canAssign: false, isLoaLocked: false, showDecisionMessage: false };
|
return {
|
||||||
|
canApprove: false,
|
||||||
|
canReject: false,
|
||||||
|
canSchedule: false,
|
||||||
|
canAssign: false,
|
||||||
|
isLoaLocked: false,
|
||||||
|
showDecisionMessage: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Core Flags
|
// 1. Core Flags
|
||||||
@ -2491,7 +2577,7 @@ export const ApplicationDetails = () => {
|
|||||||
<Card>
|
<Card>
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||||
<CardHeader className="pb-4 px-4 sm:px-6">
|
<CardHeader className="pb-4 px-4 sm:px-6">
|
||||||
<div className="overflow-x-auto scrollbar-hide -mx-4 px-4 sm:-mx-6 sm:px-6">
|
<div className="overflow-x-auto custom-scrollbar-x -mx-4 px-4 sm:-mx-6 sm:px-6">
|
||||||
<TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1">
|
<TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1">
|
||||||
<TabsTrigger value="questionnaire" className="min-w-[120px]">Questionnaire</TabsTrigger>
|
<TabsTrigger value="questionnaire" className="min-w-[120px]">Questionnaire</TabsTrigger>
|
||||||
<TabsTrigger value="progress" className="min-w-[80px]">Progress</TabsTrigger>
|
<TabsTrigger value="progress" className="min-w-[80px]">Progress</TabsTrigger>
|
||||||
@ -3400,41 +3486,63 @@ export const ApplicationDetails = () => {
|
|||||||
|
|
||||||
{/* Audit Trail Tab */}
|
{/* Audit Trail Tab */}
|
||||||
<TabsContent value="audit">
|
<TabsContent value="audit">
|
||||||
<ScrollArea className="h-96">
|
<ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50">
|
||||||
<div className="space-y-4">
|
<div className="space-y-2.5 p-3 pr-4">
|
||||||
{auditLoading ? (
|
{auditLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-10">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600" />
|
||||||
<span className="ml-2 text-slate-500">Loading audit trail...</span>
|
<span className="ml-2 text-sm text-slate-500">Loading audit trail…</span>
|
||||||
</div>
|
</div>
|
||||||
) : auditLogs.length === 0 ? (
|
) : auditLogs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-slate-500">
|
<div className="rounded-lg border border-dashed border-slate-200 bg-white py-10 text-center text-sm text-slate-500">
|
||||||
No audit logs recorded yet for this application.
|
No audit logs recorded yet for this application.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
auditLogs.map((log: any) => (
|
auditLogs.map((log: any) => (
|
||||||
<div key={log.id} className="flex gap-4 p-3 hover:bg-slate-50 rounded-lg">
|
<div
|
||||||
<div className="w-2 h-2 bg-amber-600 rounded-full mt-2 flex-shrink-0"></div>
|
key={log.id}
|
||||||
<div className="flex-1">
|
className="rounded-lg border border-slate-200/90 bg-white p-3 text-sm shadow-sm"
|
||||||
<div className="flex items-start justify-between">
|
>
|
||||||
<p className="text-slate-900 font-medium">{log.description || log.action}</p>
|
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||||
<span className="text-slate-500 text-sm whitespace-nowrap ml-4">
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
{formatDateTime(log.timestamp)}
|
<Badge
|
||||||
</span>
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 text-[10px] font-semibold uppercase tracking-wide',
|
||||||
|
auditLogActionBadgeClass(log.action)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{String(log.action || 'EVENT').replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
{log.stage ? (
|
||||||
|
<span
|
||||||
|
className="max-w-[200px] truncate text-[11px] text-slate-500"
|
||||||
|
title={log.stage}
|
||||||
|
>
|
||||||
|
{log.stage}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 mt-1">by {log.userName || 'System'}</p>
|
<time
|
||||||
{log.remarks && (
|
className="shrink-0 text-xs tabular-nums text-slate-400"
|
||||||
<p className="mt-2 text-red-600 text-sm font-bold bg-red-50 p-2 rounded border border-red-100 italic">
|
dateTime={log.timestamp}
|
||||||
"{log.remarks}"
|
>
|
||||||
</p>
|
{formatDateTime(log.timestamp)}
|
||||||
)}
|
</time>
|
||||||
{log.changes && log.changes.length > 0 && (
|
</div>
|
||||||
<div className="mt-1 space-y-0.5">
|
<p className="mt-2 text-[13px] leading-relaxed text-slate-800">
|
||||||
{log.changes.map((change: string, idx: number) => (
|
{log.description || '—'}
|
||||||
<p key={idx} className="text-slate-500 text-sm">{change}</p>
|
</p>
|
||||||
))}
|
<div className="mt-2 flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
</div>
|
<User className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
|
||||||
)}
|
<span className="min-w-0 truncate">
|
||||||
|
<span className="font-medium text-slate-600">
|
||||||
|
{log.userName || 'System'}
|
||||||
|
</span>
|
||||||
|
{log.userEmail ? (
|
||||||
|
<span className="text-slate-400"> · {log.userEmail}</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@ -3509,9 +3617,40 @@ export const ApplicationDetails = () => {
|
|||||||
{permissions.isLoaLocked && (
|
{permissions.isLoaLocked && (
|
||||||
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800">
|
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800">
|
||||||
<Lock className="w-4 h-4 text-amber-600" />
|
<Lock className="w-4 h-4 text-amber-600" />
|
||||||
<AlertTitle className="text-amber-900 font-semibold">Stage Locked</AlertTitle>
|
<AlertTitle className="text-amber-900 font-semibold">LOA approval locked</AlertTitle>
|
||||||
<AlertDescription className="text-amber-800">
|
<AlertDescription className="text-amber-800">
|
||||||
First Fill (₹15L) must be verified by Finance before LOA Approval can proceed.
|
<span className="font-medium">First Fill</span> (later-stage payment) must be verified by Finance
|
||||||
|
before LOA approval can proceed. This is separate from the initial security deposit before LOI Issued.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getDeposit('FIRST_FILL')?.status === 'Verified' &&
|
||||||
|
application.status !== 'LOA Pending' &&
|
||||||
|
!['LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded', 'Rejected'].includes(
|
||||||
|
application.status,
|
||||||
|
) && (
|
||||||
|
<Alert className="mb-4 border-violet-200 bg-violet-50/90 text-violet-950">
|
||||||
|
<Info className="h-4 w-4 text-violet-700" />
|
||||||
|
<AlertTitle className="font-semibold">First Fill verified on file</AlertTitle>
|
||||||
|
<AlertDescription className="text-sm text-violet-900/90 leading-relaxed">
|
||||||
|
Finance has verified the <span className="font-medium">First Fill</span> payment. The application
|
||||||
|
status was <span className="font-medium">not</span> changed until you reach{' '}
|
||||||
|
<span className="font-medium">LOA Pending</span>. When you get there, LOA approval will not be
|
||||||
|
blocked by payment (same pattern as recording the initial security deposit before the LOI
|
||||||
|
security step).
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{['Security Details', 'Payment Pending'].includes(application.status) && (
|
||||||
|
<Alert className="mb-4 border-sky-200 bg-sky-50/90 text-sky-900">
|
||||||
|
<Info className="h-4 w-4 text-sky-700" />
|
||||||
|
<AlertTitle className="text-sky-950 font-semibold">Security Details review</AlertTitle>
|
||||||
|
<AlertDescription className="text-sm text-sky-900/90 leading-relaxed">
|
||||||
|
Check the initial security deposit on the <span className="font-medium">Payments</span> tab (Finance
|
||||||
|
may have already marked it verified). When satisfied, use <span className="font-medium">Approve</span>{' '}
|
||||||
|
to move to <span className="font-medium">LOI Issued</span>.
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@ -3537,24 +3676,24 @@ export const ApplicationDetails = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{permissions.canApprove && (
|
{permissions.canApprove && (
|
||||||
<>
|
<Button
|
||||||
<Button
|
className="w-full bg-green-600 hover:bg-green-700 font-bold"
|
||||||
className="w-full bg-green-600 hover:bg-green-700 font-bold"
|
onClick={() => setShowApproveModal(true)}
|
||||||
onClick={() => setShowApproveModal(true)}
|
>
|
||||||
>
|
<CheckCircle className="w-4 h-4 mr-2" />
|
||||||
<CheckCircle className="w-4 h-4 mr-2" />
|
{['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'}
|
||||||
{['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'}
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
|
|
||||||
<Button
|
{permissions.canReject && (
|
||||||
variant="destructive"
|
<Button
|
||||||
className="w-full font-bold"
|
variant="destructive"
|
||||||
onClick={() => setShowRejectModal(true)}
|
className="w-full font-bold"
|
||||||
>
|
onClick={() => setShowRejectModal(true)}
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
>
|
||||||
Reject
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Reject
|
||||||
</>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{permissions.showDecisionMessage && (
|
{permissions.showDecisionMessage && (
|
||||||
@ -4187,120 +4326,83 @@ export const ApplicationDetails = () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* KT Matrix Modal */}
|
{/* KT Matrix — Level 1 */}
|
||||||
<Dialog open={showKTMatrixModal} onOpenChange={setShowKTMatrixModal}>
|
<Dialog open={showKTMatrixModal} onOpenChange={setShowKTMatrixModal}>
|
||||||
<DialogContent className="max-w-4xl h-[85vh] p-0 overflow-hidden flex flex-col gap-0 border-none shadow-2xl">
|
<DialogContent className="flex min-h-0 max-h-[90vh] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-lg">
|
||||||
{/* Ultra-Simple Header */}
|
<DialogHeader className="shrink-0 space-y-2 border-b px-5 py-4 text-left">
|
||||||
<div className="px-8 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50 shrink-0">
|
<DialogTitle className="text-base">KT matrix</DialogTitle>
|
||||||
<div>
|
<DialogDescription className="text-sm leading-relaxed">
|
||||||
<DialogTitle className="text-base font-bold text-slate-900 leading-tight">KT Matrix Assessment</DialogTitle>
|
Level 1 interview · {application.name}
|
||||||
<p className="text-slate-400 text-[11px] font-medium tracking-tight">Evaluate technical capability for {application.name}</p>
|
<span className="mt-1 block text-xs text-muted-foreground">
|
||||||
</div>
|
{Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} criteria answered
|
||||||
<div className="text-right">
|
</span>
|
||||||
<div className="text-xs font-bold text-slate-600 mb-1">{Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} Completed</div>
|
</DialogDescription>
|
||||||
<Progress value={(Object.keys(ktMatrixSelectedValues).length / KT_MATRIX_CRITERIA.length) * 100} className="w-28 h-1.5 bg-slate-100" />
|
</DialogHeader>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto bg-white">
|
<div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||||
<div className="p-8 max-w-3xl mx-auto">
|
<div className="space-y-6">
|
||||||
{/* Question List - Minimalist Style */}
|
{KT_MATRIX_CRITERIA.map((criterion, idx) => (
|
||||||
<div className="divide-y divide-slate-100 border-x border-t border-slate-100 rounded-t-xl overflow-hidden shadow-sm">
|
<div key={criterion.name} className="space-y-2">
|
||||||
{KT_MATRIX_CRITERIA.map((criterion, idx) => (
|
<Label
|
||||||
<div key={criterion.name} className="p-5 hover:bg-slate-50/50 transition-colors">
|
htmlFor={`kt-matrix-${idx}`}
|
||||||
<div className="flex justify-between items-start gap-4 mb-4">
|
className="block text-sm font-medium leading-relaxed text-foreground"
|
||||||
<h4 className="text-sm font-semibold text-slate-800 leading-snug">
|
>
|
||||||
<span className="text-slate-400 mr-2 tabular-nums">{idx + 1}.</span>
|
<span className="text-muted-foreground">{idx + 1}.</span> {criterion.name}{' '}
|
||||||
{criterion.name}
|
<span className="font-normal text-muted-foreground">({criterion.weight}%)</span>
|
||||||
</h4>
|
</Label>
|
||||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest bg-slate-50 px-2 py-0.5 rounded border border-slate-100">
|
<Select
|
||||||
WT: {criterion.weight}%
|
value={ktMatrixSelectedValues[criterion.name] ?? undefined}
|
||||||
</span>
|
onValueChange={(value) => {
|
||||||
</div>
|
const option = criterion.options.find((o) => o.value === value);
|
||||||
|
if (option) handleKTMatrixChange(criterion.name, option.value, option.score);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`kt-matrix-${idx}`} className="h-10 w-full text-left text-sm font-normal">
|
||||||
|
<SelectValue placeholder="Choose an option…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" className="max-h-72 w-[var(--radix-select-trigger-width)]">
|
||||||
|
{criterion.options.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value} className="py-2.5 text-sm leading-snug">
|
||||||
|
{option.label}{' '}
|
||||||
|
<span className="text-muted-foreground">({option.score})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="space-y-2 border-t border-border pt-6">
|
||||||
{criterion.options.map((option) => {
|
<Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">
|
||||||
const isSelected = ktMatrixSelectedValues[criterion.name] === option.value;
|
Notes <span className="font-normal text-muted-foreground">(optional)</span>
|
||||||
return (
|
</Label>
|
||||||
<div
|
|
||||||
key={option.value}
|
|
||||||
onClick={() => handleKTMatrixChange(criterion.name, option.value, option.score)}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 rounded-lg border text-[11px] font-bold cursor-pointer transition-all flex items-center gap-2 select-none",
|
|
||||||
isSelected
|
|
||||||
? "bg-slate-900 border-slate-900 text-white shadow-md"
|
|
||||||
: "bg-white border-slate-200 text-slate-500 hover:border-slate-400"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isSelected && <Check className="w-3 h-3 text-amber-400" />}
|
|
||||||
{option.label}
|
|
||||||
<span className={cn(
|
|
||||||
"ml-1 font-mono",
|
|
||||||
isSelected ? "text-slate-400" : "text-slate-300"
|
|
||||||
)}>
|
|
||||||
[{option.score}]
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Remarks Component */}
|
|
||||||
<div className="p-6 border border-slate-100 bg-slate-50 rounded-b-xl space-y-3">
|
|
||||||
<Label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Additional Evaluation Notes</Label>
|
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Record observations, strengths or concerns..."
|
id="kt-matrix-remarks"
|
||||||
className="min-h-[80px] text-sm resize-none border-slate-200 rounded-lg bg-white p-4 focus:ring-1 focus:ring-slate-400 transition-all font-medium"
|
placeholder="Optional remarks…"
|
||||||
|
className="min-h-[96px] resize-y text-sm leading-relaxed"
|
||||||
value={ktMatrixRemarks}
|
value={ktMatrixRemarks}
|
||||||
onChange={(e) => setKtMatrixRemarks(e.target.value)}
|
onChange={(e) => setKtMatrixRemarks(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Ultra-Simple Summary Line */}
|
<div className="flex shrink-0 flex-col gap-4 border-t px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="mt-8 p-6 bg-slate-900 rounded-2xl flex items-center justify-between text-white shadow-xl shadow-slate-200">
|
<p className="text-sm text-muted-foreground">
|
||||||
<div className="space-y-1">
|
Weighted total{' '}
|
||||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Composite Assessment Score</p>
|
<span className="font-semibold tabular-nums text-foreground">{calculateKTScore()}</span>
|
||||||
<div className="flex items-center gap-3">
|
<span className="text-muted-foreground"> / 100</span>
|
||||||
<div className={cn(
|
</p>
|
||||||
"w-2.5 h-2.5 rounded-full shadow-sm",
|
<div className="flex gap-2 sm:shrink-0">
|
||||||
Number(calculateKTScore()) >= 60 ? "bg-green-500" :
|
<Button variant="outline" onClick={() => setShowKTMatrixModal(false)}>
|
||||||
Number(calculateKTScore()) >= 40 ? "bg-amber-500" : "bg-red-500"
|
Cancel
|
||||||
)} />
|
</Button>
|
||||||
<span className="text-xs font-bold text-slate-200">
|
<Button
|
||||||
{Number(calculateKTScore()) >= 60 ? "Strong Profile" :
|
onClick={handleSubmitKTMatrix}
|
||||||
Number(calculateKTScore()) >= 40 ? "Needs Review" : "Low Alignment"}
|
disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length}
|
||||||
</span>
|
>
|
||||||
</div>
|
{isSubmittingKT ? 'Saving…' : 'Submit'}
|
||||||
</div>
|
</Button>
|
||||||
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="flex items-baseline justify-end gap-1">
|
|
||||||
<span className="text-4xl font-black italic tracking-tighter tabular-nums">{calculateKTScore()}</span>
|
|
||||||
<span className="text-slate-500 font-bold text-lg">/100</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Compact Footer Actions */}
|
|
||||||
<div className="flex gap-3 mt-8 pb-10">
|
|
||||||
<Button
|
|
||||||
className="flex-[2] bg-slate-900 hover:bg-slate-800 text-white font-black rounded-xl h-12 shadow-lg"
|
|
||||||
onClick={handleSubmitKTMatrix}
|
|
||||||
disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length}
|
|
||||||
>
|
|
||||||
{isSubmittingKT ? 'Saving...' : 'Complete Evaluation'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="flex-1 rounded-xl text-slate-500 border-slate-200 font-bold h-12"
|
|
||||||
onClick={() => setShowKTMatrixModal(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, ArrowRight, MessageSquare, Loader2 } from 'lucide-react';
|
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, ArrowRight, MessageSquare, Loader2, Ban, Undo2 } from 'lucide-react';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
@ -8,8 +8,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
|||||||
import { Textarea } from '../ui/textarea';
|
import { Textarea } from '../ui/textarea';
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Input } from '../ui/input';
|
import { Input } from '../ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
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';
|
||||||
@ -39,6 +38,7 @@ const workflowStages = [
|
|||||||
const documentRequirements: Record<string, number[]> = {
|
const documentRequirements: Record<string, number[]> = {
|
||||||
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
|
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
|
||||||
'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16],
|
'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16],
|
||||||
|
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
|
||||||
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16],
|
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16],
|
||||||
'Proprietorship': [1, 2, 3, 10, 16]
|
'Proprietorship': [1, 2, 3, 10, 16]
|
||||||
};
|
};
|
||||||
@ -76,26 +76,57 @@ const getTypeColor = (type: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
if (status === 'Completed' || status === 'Verified') return 'bg-green-100 text-green-700 border-green-300';
|
const s = String(status || '');
|
||||||
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress' || status === 'Submitted') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
if (s === 'Completed' || s === 'Verified' || s === 'APPROVED' || s === 'COMPLETED' || s === 'CREATED' || /^DOCUMENT/i.test(s)) {
|
||||||
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
|
return 'bg-green-100 text-green-700 border-green-300';
|
||||||
|
}
|
||||||
|
if (s.includes('Revoked') || s === 'REVOKED') return 'bg-orange-100 text-orange-800 border-orange-300';
|
||||||
|
if (s.includes('Rejected') || s === 'REJECTED') return 'bg-red-100 text-red-700 border-red-300';
|
||||||
|
if (s === 'SENT BACK' || s.includes('Review') || s.includes('Pending') || s === 'In Progress' || s === 'Submitted') {
|
||||||
|
return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
||||||
|
}
|
||||||
|
if (s === 'UPDATED') return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Audit rows were stored as UPDATED for approvals; avoid treating "UPDATED" as pending via substring "update". */
|
||||||
|
const getConstitutionalHistoryPresentation = (entry: any) => {
|
||||||
|
const raw = String(entry.action || 'UPDATED').toUpperCase();
|
||||||
|
const details = entry.details || entry.newData || {};
|
||||||
|
const targetStage = details.targetStage as string | undefined;
|
||||||
|
const remarks = String(entry.remarks || '').toLowerCase();
|
||||||
|
|
||||||
|
if (raw === 'REJECTED') return { variant: 'danger' as const, badge: 'REJECTED' };
|
||||||
|
if (raw === 'CONSTITUTIONAL_REVOKED' || raw === 'REVOKED') return { variant: 'danger' as const, badge: 'REVOKED' };
|
||||||
|
if (raw === 'CONSTITUTIONAL_SENT_BACK') return { variant: 'pending' as const, badge: 'SENT BACK' };
|
||||||
|
if (raw === 'DOCUMENT_REJECTED') return { variant: 'danger' as const, badge: 'DOCUMENT REJECTED' };
|
||||||
|
if (raw === 'APPROVED' || raw === 'CREATED' || raw === 'DOCUMENT_UPLOADED' || raw === 'DOCUMENT_VERIFIED') {
|
||||||
|
return { variant: 'success' as const, badge: raw.replace(/_/g, ' ') };
|
||||||
|
}
|
||||||
|
if (raw === 'UPDATED') {
|
||||||
|
if (remarks.includes('send') && remarks.includes('back')) return { variant: 'pending' as const, badge: 'SENT BACK' };
|
||||||
|
if (remarks.includes('reject')) return { variant: 'danger' as const, badge: 'REJECTED' };
|
||||||
|
if (targetStage === 'Completed') return { variant: 'success' as const, badge: 'COMPLETED' };
|
||||||
|
if (targetStage) return { variant: 'success' as const, badge: 'APPROVED' };
|
||||||
|
return { variant: 'neutral' as const, badge: 'UPDATED' };
|
||||||
|
}
|
||||||
|
return { variant: 'neutral' as const, badge: raw.replace(/_/g, ' ') || 'EVENT' };
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeConstitutionType = (value: string) => {
|
const normalizeConstitutionType = (value: string) => {
|
||||||
const input = String(value || '').trim().toLowerCase();
|
const input = String(value || '').trim().toLowerCase();
|
||||||
if (!input) return '';
|
if (!input) return '';
|
||||||
if (input.includes('proprietor')) return 'Proprietorship';
|
if (input.includes('proprietor')) return 'Proprietorship';
|
||||||
if (input.includes('partner')) return 'Partnership';
|
if (input.includes('partner')) return 'Partnership';
|
||||||
if (input.includes('llp')) return 'LLP';
|
if (input.includes('llp')) return 'LLP';
|
||||||
if (input.includes('private') || input.includes('pvt')) return 'Pvt Ltd';
|
if (input.includes('private') || input.includes('pvt')) return 'Private Limited';
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) {
|
export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
|
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold'>('approve');
|
const [actionType, setActionType] = useState<'approve' | 'reject' | 'sendBack' | 'revoke'>('approve');
|
||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||||
const [selectedDocType, setSelectedDocType] = useState<number | null>(null);
|
const [selectedDocType, setSelectedDocType] = useState<number | null>(null);
|
||||||
@ -107,15 +138,15 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||||
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
|
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
|
||||||
|
const [rejectDocDialogOpen, setRejectDocDialogOpen] = useState(false);
|
||||||
|
const [rejectDocIndex, setRejectDocIndex] = useState<number | null>(null);
|
||||||
|
const [rejectDocReason, setRejectDocReason] = useState('');
|
||||||
|
const [isRejectingDoc, setIsRejectingDoc] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchAuditLogs = async (entityId: string) => {
|
||||||
fetchRequestDetails();
|
if (!entityId) return;
|
||||||
fetchAuditLogs();
|
|
||||||
}, [requestId]);
|
|
||||||
|
|
||||||
const fetchAuditLogs = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response: any = await API.getAuditLogs('constitutional_change', requestId);
|
const response: any = await API.getAuditLogs('constitutional_change', entityId);
|
||||||
if (response.data && response.data.success) {
|
if (response.data && response.data.success) {
|
||||||
setAuditLogs(response.data.data || []);
|
setAuditLogs(response.data.data || []);
|
||||||
}
|
}
|
||||||
@ -124,12 +155,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchRequestDetails = async () => {
|
const fetchRequestDetails = async (opts?: { silent?: boolean }) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
if (!opts?.silent) setIsLoading(true);
|
||||||
const response = await API.getConstitutionalChangeById(requestId) as any;
|
const response = await API.getConstitutionalChangeById(requestId) as any;
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setRequest(response.data.request);
|
const reqData = response.data.request;
|
||||||
|
setRequest(reqData);
|
||||||
|
await fetchAuditLogs(reqData?.id || requestId);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to fetch request details');
|
toast.error('Failed to fetch request details');
|
||||||
}
|
}
|
||||||
@ -137,10 +170,28 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
console.error('Fetch request details error:', error);
|
console.error('Fetch request details error:', error);
|
||||||
toast.error('Error loading request details');
|
toast.error('Error loading request details');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
if (!opts?.silent) setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequestDetails();
|
||||||
|
}, [requestId]);
|
||||||
|
|
||||||
|
const historyEntries = useMemo(() => {
|
||||||
|
if (auditLogs.length > 0) return auditLogs;
|
||||||
|
const tl = (request?.timeline || []) as any[];
|
||||||
|
return tl.map((t: any, i: number) => ({
|
||||||
|
id: `timeline-${i}`,
|
||||||
|
action: String(t.action || 'UPDATED'),
|
||||||
|
description: t.remarks || t.action || 'Updated',
|
||||||
|
stage: t.stage || null,
|
||||||
|
userName: t.user || t.userName || 'System',
|
||||||
|
remarks: t.remarks,
|
||||||
|
timestamp: t.timestamp || t.createdAt
|
||||||
|
}));
|
||||||
|
}, [auditLogs, request?.timeline]);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
|
||||||
@ -163,14 +214,26 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
// Get required documents for this request (normalized mapping handles values like "LLP Conversion")
|
// Get required documents for this request (normalized mapping handles values like "LLP Conversion")
|
||||||
const normalizedChangeType = normalizeConstitutionType(request.changeType);
|
const normalizedChangeType = normalizeConstitutionType(request.changeType);
|
||||||
const requiredDocs = documentRequirements[normalizedChangeType] || [];
|
const requiredDocs = documentRequirements[normalizedChangeType] || [];
|
||||||
const uploadedDocNumbers = new Set(
|
|
||||||
(request.documents || [])
|
const findUploadedForDocNum = (docNum: number) => {
|
||||||
.map((doc: any) => Number(doc?.docNumber))
|
const docs = request.documents || [];
|
||||||
.filter((num: number) => !Number.isNaN(num) && num > 0)
|
const label = documentNames[docNum];
|
||||||
);
|
return docs.find((d: any) => {
|
||||||
|
const n = Number(d?.docNumber);
|
||||||
|
if (!Number.isNaN(n) && n === docNum) return true;
|
||||||
|
if (label && typeof d?.name === 'string' && d.name.includes(label)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDocTypeUploaded = (docNum: number) => {
|
||||||
|
const u = findUploadedForDocNum(docNum);
|
||||||
|
return !!u && String(u.status || '') !== 'Rejected';
|
||||||
|
};
|
||||||
|
|
||||||
// Calculate current stage index mapping to backend stages
|
// Calculate current stage index mapping to backend stages
|
||||||
const getCurrentStageIndex = () => {
|
const getCurrentStageIndex = () => {
|
||||||
|
if (request.currentStage === 'Rejected' || request.currentStage === 'Revoked') return -1;
|
||||||
const stageMap: Record<string, number> = {
|
const stageMap: Record<string, number> = {
|
||||||
'Submitted': 1,
|
'Submitted': 1,
|
||||||
'ASM Review': 2,
|
'ASM Review': 2,
|
||||||
@ -186,6 +249,15 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentStageIndex = getCurrentStageIndex();
|
const currentStageIndex = getCurrentStageIndex();
|
||||||
|
/** When the request has reached terminal success, every workflow row (including the "Completed" step) should show as done — not "In Progress". */
|
||||||
|
const flowComplete =
|
||||||
|
request.currentStage === 'Completed' ||
|
||||||
|
(String(request.status || '') === 'Completed' && !['Rejected', 'Revoked'].includes(String(request.currentStage || '')));
|
||||||
|
|
||||||
|
/** SRS §12.2 — closed failure states: do not show misleading step progress. */
|
||||||
|
const workflowTerminalNegative =
|
||||||
|
['Rejected', 'Revoked'].includes(String(request.status || '')) ||
|
||||||
|
['Rejected', 'Revoked'].includes(String(request.currentStage || ''));
|
||||||
|
|
||||||
const getLatestStageTimelineEntry = (stageName: string) => {
|
const getLatestStageTimelineEntry = (stageName: string) => {
|
||||||
const aliases: Record<string, string[]> = {
|
const aliases: Record<string, string[]> = {
|
||||||
@ -211,61 +283,104 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
// Centralized Permissions Utility (Fixes security gap where buttons showed for everyone)
|
// Centralized Permissions Utility (Fixes security gap where buttons showed for everyone)
|
||||||
const getConstitutionalPermissions = () => {
|
const getConstitutionalPermissions = () => {
|
||||||
if (!request || !currentUser) {
|
if (!request || !currentUser) {
|
||||||
return { canApprove: false, canReject: false, canHold: false, isFinalState: false };
|
return { canApprove: false, canReject: false, canSendBack: false, canRevoke: false, isFinalState: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentStage = request.currentStage;
|
const currentStage = request.currentStage;
|
||||||
const status = request.status;
|
const status = request.status;
|
||||||
const userRole = currentUser.role;
|
const userRole = currentUser.role;
|
||||||
|
|
||||||
const isFinalState = ['Completed', 'Rejected', 'Hold'].includes(status);
|
const isFinalState = ['Completed', 'Rejected', 'Revoked'].includes(String(status || '')) ||
|
||||||
|
['Rejected', 'Revoked', 'Completed'].includes(String(currentStage || ''));
|
||||||
// Find stage definition
|
|
||||||
const stageDef = workflowStages.find(s => s.name === currentStage || s.key === currentStage);
|
const stageDef = workflowStages.find(s => s.name === currentStage || s.key === currentStage);
|
||||||
|
|
||||||
// Role matching logic (Handles Role names from constants vs workflow mapping)
|
/**
|
||||||
const isCurrentlyAssigned = currentUser.roleCode === 'SUPER_ADMIN' || (
|
* DB stage `Submitted` means the dealer (or internal on behalf) has already filed the request.
|
||||||
(stageDef?.role === 'ASM' && userRole === 'ASM') ||
|
* The next gate is ASM — there is no second “dealer” action on this row. ASM Approve moves to `ASM Review`.
|
||||||
(stageDef?.role === 'ZM/RBM' && (userRole === 'DD-ZM' || userRole === 'RBM')) ||
|
*/
|
||||||
(stageDef?.role === 'ZBH' && userRole === 'ZBH') ||
|
const atSubmittedDbStage = currentStage === 'Submitted';
|
||||||
(stageDef?.role === 'DD Lead' && userRole === 'DD Lead') ||
|
const isCurrentlyAssigned =
|
||||||
(stageDef?.role === 'DD Head' && userRole === 'DD Head') ||
|
currentUser.roleCode === 'SUPER_ADMIN' ||
|
||||||
(stageDef?.role === 'NBH' && userRole === 'NBH') ||
|
(atSubmittedDbStage && (userRole === 'ASM' || currentUser.roleCode === 'ASM')) ||
|
||||||
(stageDef?.role === 'Legal Team' && (userRole === 'Legal Admin'))
|
(!atSubmittedDbStage &&
|
||||||
);
|
!!(
|
||||||
|
(stageDef?.role === 'ASM' && userRole === 'ASM') ||
|
||||||
|
(stageDef?.role === 'ZM/RBM' && (userRole === 'DD-ZM' || userRole === 'RBM')) ||
|
||||||
|
(stageDef?.role === 'ZBH' && userRole === 'ZBH') ||
|
||||||
|
(stageDef?.role === 'DD Lead' && userRole === 'DD Lead') ||
|
||||||
|
(stageDef?.role === 'DD Head' && userRole === 'DD Head') ||
|
||||||
|
(stageDef?.role === 'NBH' && userRole === 'NBH') ||
|
||||||
|
(stageDef?.role === 'Legal Team' && userRole === 'Legal Admin')
|
||||||
|
));
|
||||||
|
|
||||||
|
/** SRS §12.2.5 — Send Back / Revoke for ZBH, DD Lead, DD Head, NBH (not at Legal-only step). */
|
||||||
|
const sendBackRevokeRoles = ['ZBH', 'DD Lead', 'DD Head', 'NBH'];
|
||||||
|
const canSendBackOrRevoke =
|
||||||
|
isCurrentlyAssigned &&
|
||||||
|
!isFinalState &&
|
||||||
|
(currentUser.roleCode === 'SUPER_ADMIN' || sendBackRevokeRoles.includes(userRole)) &&
|
||||||
|
currentStage !== 'Legal Review' &&
|
||||||
|
currentStage !== 'Submitted';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
canApprove: isCurrentlyAssigned && !isFinalState,
|
canApprove: isCurrentlyAssigned && !isFinalState,
|
||||||
canReject: isCurrentlyAssigned && !isFinalState,
|
canReject: isCurrentlyAssigned && !isFinalState,
|
||||||
canHold: isCurrentlyAssigned && !isFinalState,
|
canSendBack: canSendBackOrRevoke,
|
||||||
|
canRevoke: canSendBackOrRevoke,
|
||||||
isFinalState
|
isFinalState
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const permissions = getConstitutionalPermissions();
|
const permissions = getConstitutionalPermissions();
|
||||||
|
|
||||||
const handleAction = (type: 'approve' | 'reject' | 'hold') => {
|
const dealerProfile = request.dealer?.dealerProfile;
|
||||||
|
const onboardingApplication = dealerProfile?.application;
|
||||||
|
const approvedLoiRequest = (onboardingApplication?.loiRequests || []).find((r: any) =>
|
||||||
|
/approved/i.test(String(r?.status || ''))
|
||||||
|
);
|
||||||
|
const approvedLoaRequest = (onboardingApplication?.loaRequests || []).find((r: any) =>
|
||||||
|
/approved/i.test(String(r?.status || ''))
|
||||||
|
);
|
||||||
|
/** `dealers.loi_date` / `dealers.loa_date` (API), else approved LOI/LOA workflow timestamps */
|
||||||
|
const establishmentLoiDate = dealerProfile?.loiDate ?? approvedLoiRequest?.approvedAt;
|
||||||
|
const establishmentLoaDate = dealerProfile?.loaDate ?? approvedLoaRequest?.approvedAt;
|
||||||
|
|
||||||
|
const handleAction = (type: 'approve' | 'reject' | 'sendBack' | 'revoke') => {
|
||||||
setActionType(type);
|
setActionType(type);
|
||||||
setIsActionDialogOpen(true);
|
setIsActionDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmitAction = async (e: React.FormEvent) => {
|
const handleSubmitAction = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
const remarksRequired = actionType === 'sendBack' || actionType === 'revoke';
|
||||||
|
if (remarksRequired && !String(comments || '').trim()) {
|
||||||
|
toast.error('Remarks are required for Send Back and Revoke (SRS §12.2.3).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsActionLoading(true);
|
setIsActionLoading(true);
|
||||||
const action = actionType === 'approve' ? 'approve' : actionType === 'reject' ? 'reject' : 'hold';
|
const actionLabel =
|
||||||
const response = await API.updateConstitutionalChange(requestId, action, {
|
actionType === 'approve' ? 'Approve' :
|
||||||
|
actionType === 'reject' ? 'Reject' :
|
||||||
|
actionType === 'sendBack' ? 'Send Back' :
|
||||||
|
'Revoke';
|
||||||
|
const response = await API.updateConstitutionalChange(requestId, actionLabel, {
|
||||||
comments
|
comments
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
const actionText = actionType === 'approve' ? 'approved' : actionType === 'reject' ? 'rejected' : 'put on hold';
|
const actionText =
|
||||||
|
actionType === 'approve' ? 'approved' :
|
||||||
|
actionType === 'reject' ? 'rejected' :
|
||||||
|
actionType === 'sendBack' ? 'sent back' :
|
||||||
|
'revoked';
|
||||||
toast.success(`Request ${actionText} successfully`);
|
toast.success(`Request ${actionText} successfully`);
|
||||||
setIsActionDialogOpen(false);
|
setIsActionDialogOpen(false);
|
||||||
setComments('');
|
setComments('');
|
||||||
fetchRequestDetails();
|
fetchRequestDetails();
|
||||||
fetchAuditLogs();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit action error:', error);
|
console.error('Submit action error:', error);
|
||||||
@ -303,7 +418,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
setIsUploadDialogOpen(false);
|
setIsUploadDialogOpen(false);
|
||||||
setSelectedDocType(null);
|
setSelectedDocType(null);
|
||||||
setUploadFile(null);
|
setUploadFile(null);
|
||||||
fetchRequestDetails();
|
await fetchRequestDetails({ silent: true });
|
||||||
} else {
|
} else {
|
||||||
toast.error('Failed to upload document');
|
toast.error('Failed to upload document');
|
||||||
}
|
}
|
||||||
@ -338,6 +453,42 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitRejectDocument = async () => {
|
||||||
|
if (rejectDocIndex == null || !String(rejectDocReason).trim()) {
|
||||||
|
toast.error('Please enter a rejection reason (SRS document verification).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setIsRejectingDoc(true);
|
||||||
|
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
|
||||||
|
const updatedDocs = existingDocs.map((doc: any, index: number) => {
|
||||||
|
if (index !== rejectDocIndex) return doc;
|
||||||
|
return {
|
||||||
|
...doc,
|
||||||
|
status: 'Rejected',
|
||||||
|
rejectedOn: new Date().toISOString(),
|
||||||
|
rejectedBy: (currentUser as any)?.fullName || 'System',
|
||||||
|
rejectionReason: rejectDocReason.trim()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const response = await API.uploadConstitutionalDocuments(requestId, updatedDocs) as any;
|
||||||
|
if (response.data?.success) {
|
||||||
|
toast.success('Document marked as rejected');
|
||||||
|
setRejectDocDialogOpen(false);
|
||||||
|
setRejectDocIndex(null);
|
||||||
|
setRejectDocReason('');
|
||||||
|
await fetchRequestDetails({ silent: true });
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to reject document');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reject document error:', error);
|
||||||
|
toast.error('Failed to reject document');
|
||||||
|
} finally {
|
||||||
|
setIsRejectingDoc(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@ -432,11 +583,11 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600 text-sm mb-1">LOI Date</p>
|
<p className="text-slate-600 text-sm mb-1">LOI Date</p>
|
||||||
<p className="text-slate-900">{request.dealer?.dealerProfile?.loiDate ? formatDateTime(request.dealer.dealerProfile.loiDate, 'date') : 'N/A'}</p>
|
<p className="text-slate-900">{establishmentLoiDate ? formatDateTime(establishmentLoiDate, 'date') : 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-600 text-sm mb-1">LOA Date</p>
|
<p className="text-slate-600 text-sm mb-1">LOA Date</p>
|
||||||
<p className="text-slate-900">{request.dealer?.dealerProfile?.loaDate ? formatDateTime(request.dealer.dealerProfile.loaDate, 'date') : 'N/A'}</p>
|
<p className="text-slate-900">{establishmentLoaDate ? formatDateTime(establishmentLoaDate, 'date') : 'N/A'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -474,11 +625,35 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workflow Stages */}
|
{/* Workflow Stages — SRS §12.2.8 style: Completed / In Progress / Pending */}
|
||||||
|
{workflowTerminalNegative && (
|
||||||
|
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
This request is closed as <strong>{String(request.status)}</strong>. The approval path below is for reference only.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{workflowStages.map((stage, index) => {
|
{workflowTerminalNegative ? (
|
||||||
const isCompleted = index < currentStageIndex - 1;
|
<ul className="list-disc space-y-1 pl-5 text-sm text-slate-600">
|
||||||
const isCurrent = index === currentStageIndex - 1;
|
{workflowStages.map((stage) => (
|
||||||
|
<li key={stage.id}>
|
||||||
|
<span className="text-slate-900">{stage.name}</span> — {stage.role}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
workflowStages.map((stage, index) => {
|
||||||
|
/**
|
||||||
|
* While DB stage is still `Submitted`, the filing step is already done; the queue is at ASM.
|
||||||
|
* Show “Submitted” as completed and “ASM Review” as in progress (no extra dealer action).
|
||||||
|
*/
|
||||||
|
const atSubmittedGate = request.currentStage === 'Submitted';
|
||||||
|
const isCompleted =
|
||||||
|
flowComplete ||
|
||||||
|
index < currentStageIndex - 1 ||
|
||||||
|
(atSubmittedGate && index === 0);
|
||||||
|
const isCurrent =
|
||||||
|
!flowComplete &&
|
||||||
|
(atSubmittedGate ? index === 1 : index === currentStageIndex - 1);
|
||||||
const timelineEntry = getLatestStageTimelineEntry(stage.name);
|
const timelineEntry = getLatestStageTimelineEntry(stage.name);
|
||||||
const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks;
|
const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks;
|
||||||
|
|
||||||
@ -514,7 +689,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
{stage.name}
|
{stage.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}>
|
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}>
|
||||||
Responsible: {stage.role}
|
{atSubmittedGate && index === 0
|
||||||
|
? 'Dealer action: filing complete (no further step here).'
|
||||||
|
: `Responsible: ${stage.role}`}
|
||||||
|
{atSubmittedGate && index === 1 ? (
|
||||||
|
<span className="block mt-0.5 text-amber-800/90">
|
||||||
|
ASM approves to advance the request (first workflow action after submission).
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={
|
<Badge className={
|
||||||
@ -543,7 +725,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -577,26 +760,22 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label>Document Type</Label>
|
<Label>Document Type</Label>
|
||||||
<Select
|
<select
|
||||||
value={selectedDocType ? String(selectedDocType) : ''}
|
className="mt-1 flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2"
|
||||||
onValueChange={(value) => setSelectedDocType(Number(value))}
|
value={selectedDocType != null ? String(selectedDocType) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setSelectedDocType(v ? Number(v) : null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full mt-1">
|
<option value="">Select document type</option>
|
||||||
<SelectValue placeholder="Select document type" />
|
{requiredDocs.map((docNum) => (
|
||||||
</SelectTrigger>
|
<option key={docNum} value={String(docNum)}>
|
||||||
<SelectContent>
|
{isDocTypeUploaded(docNum) ? '✅ ' : ''}
|
||||||
{requiredDocs.map((docNum) => (
|
{documentNames[docNum]}
|
||||||
<SelectItem key={docNum} value={String(docNum)}>
|
</option>
|
||||||
<span className="flex w-full items-center justify-between">
|
))}
|
||||||
<span>{documentNames[docNum]}</span>
|
</select>
|
||||||
{uploadedDocNumbers.has(docNum) && (
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label>Upload File</Label>
|
<Label>Upload File</Label>
|
||||||
@ -620,31 +799,39 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{requiredDocs.map((docNum) => {
|
{requiredDocs.map((docNum) => {
|
||||||
const uploaded = (request.documents || []).find((d: any) => d.docNumber === docNum || d.name?.includes(documentNames[docNum]));
|
const uploaded = findUploadedForDocNum(docNum);
|
||||||
|
const isRejected = uploaded && String(uploaded.status) === 'Rejected';
|
||||||
|
const ok = uploaded && !isRejected;
|
||||||
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 ${
|
||||||
uploaded ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200'
|
isRejected ? 'bg-red-50 border-red-200' :
|
||||||
|
ok ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{uploaded ? (
|
{isRejected ? (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
) : ok ? (
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="w-5 h-5 text-slate-400" />
|
<AlertCircle className="w-5 h-5 text-slate-400" />
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<p className={uploaded ? 'text-green-900' : 'text-slate-900'}>
|
<p className={isRejected ? 'text-red-900' : ok ? 'text-green-900' : 'text-slate-900'}>
|
||||||
{documentNames[docNum]}
|
{documentNames[docNum]}
|
||||||
</p>
|
</p>
|
||||||
{uploaded && (
|
{uploaded && (
|
||||||
<p className="text-green-700 text-sm">{uploaded.fileName || uploaded.name}</p>
|
<p className={isRejected ? 'text-red-700 text-sm' : ok ? 'text-green-700 text-sm' : 'text-slate-600 text-sm'}>
|
||||||
|
{uploaded.fileName || uploaded.name}
|
||||||
|
{isRejected && uploaded.rejectionReason ? ` — ${uploaded.rejectionReason}` : ''}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{uploaded ? (
|
{uploaded ? (
|
||||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
<Badge className={getStatusColor(uploaded.status)}>
|
||||||
{uploaded.status}
|
{uploaded.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
@ -704,14 +891,28 @@ 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' && currentUser?.role !== 'Dealer' && (
|
{doc.status !== 'Verified' && doc.status !== 'Rejected' && currentUser?.role !== 'Dealer' && (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
className="bg-green-600 hover:bg-green-700"
|
size="sm"
|
||||||
onClick={() => handleVerifyDocument(doc, index)}
|
className="bg-green-600 hover:bg-green-700"
|
||||||
>
|
onClick={() => handleVerifyDocument(doc, index)}
|
||||||
Verify
|
>
|
||||||
</Button>
|
Verify
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="border-red-300 text-red-700 hover:bg-red-50"
|
||||||
|
onClick={() => {
|
||||||
|
setRejectDocIndex(index);
|
||||||
|
setRejectDocReason('');
|
||||||
|
setRejectDocDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -733,17 +934,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
{/* History Tab */}
|
{/* History Tab */}
|
||||||
<TabsContent value="history" className="mt-0">
|
<TabsContent value="history" className="mt-0">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{auditLogs.map((entry: any, index: number) => (
|
{historyEntries.map((entry: any, index: number) => {
|
||||||
<div key={index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0">
|
const pres = getConstitutionalHistoryPresentation(entry);
|
||||||
|
return (
|
||||||
|
<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 ${
|
||||||
(entry.action)?.toLowerCase().includes('approve') || (entry.action)?.toLowerCase().includes('complete') ? 'bg-green-100' :
|
pres.variant === 'success' ? 'bg-green-100' :
|
||||||
(entry.action)?.toLowerCase().includes('pending') || (entry.action)?.toLowerCase().includes('progress') || (entry.action)?.toLowerCase().includes('update') ? 'bg-amber-100' :
|
pres.variant === 'danger' ? 'bg-red-100' :
|
||||||
|
pres.variant === 'pending' ? 'bg-amber-100' :
|
||||||
'bg-slate-100'
|
'bg-slate-100'
|
||||||
}`}>
|
}`}>
|
||||||
{(entry.action)?.toLowerCase().includes('approve') || (entry.action)?.toLowerCase().includes('complete') ? (
|
{pres.variant === 'success' ? (
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||||
) : (
|
) : pres.variant === 'danger' ? (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
) : pres.variant === 'pending' ? (
|
||||||
<Clock className="w-5 h-5 text-amber-600" />
|
<Clock className="w-5 h-5 text-amber-600" />
|
||||||
|
) : (
|
||||||
|
<Clock className="w-5 h-5 text-slate-500" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
@ -752,16 +960,17 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
<h4 className="text-slate-900">{entry.stage || entry.action}</h4>
|
<h4 className="text-slate-900">{entry.stage || entry.action}</h4>
|
||||||
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
|
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={getStatusColor(entry.action)}>
|
<Badge className={getStatusColor(pres.badge)}>
|
||||||
{entry.action}
|
{pres.badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-slate-600 text-sm mt-2">{entry.description || entry.remarks || 'No remarks provided'}</p>
|
<p className="text-slate-600 text-sm mt-2">{entry.description || entry.remarks || 'No remarks provided'}</p>
|
||||||
<p className="text-slate-500 text-sm mt-1">{formatDateTime(entry.timestamp)}</p>
|
<p className="text-slate-500 text-sm mt-1">{formatDateTime(entry.timestamp || entry.createdAt)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
{auditLogs.length === 0 && (
|
})}
|
||||||
|
{historyEntries.length === 0 && (
|
||||||
<div className="text-center py-8 text-slate-500">
|
<div className="text-center py-8 text-slate-500">
|
||||||
No history found
|
No history found
|
||||||
</div>
|
</div>
|
||||||
@ -821,11 +1030,43 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
) : (
|
) : (
|
||||||
<AlertCircle className="w-4 h-4 mr-2" />
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
)}
|
)}
|
||||||
Reject Request
|
Reject proposal
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!permissions.canApprove && !permissions.canReject && (
|
{permissions.canSendBack && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-amber-300 text-amber-900 hover:bg-amber-50"
|
||||||
|
onClick={() => handleAction('sendBack')}
|
||||||
|
disabled={isActionLoading}
|
||||||
|
>
|
||||||
|
{isActionLoading && actionType === 'sendBack' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Undo2 className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Send back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissions.canRevoke && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-orange-300 text-orange-900 hover:bg-orange-50"
|
||||||
|
onClick={() => handleAction('revoke')}
|
||||||
|
disabled={isActionLoading}
|
||||||
|
>
|
||||||
|
{isActionLoading && actionType === 'revoke' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Revoke request
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!permissions.canApprove && !permissions.canReject && !permissions.canSendBack && !permissions.canRevoke && (
|
||||||
<div className="text-center py-4 bg-slate-50 rounded-lg border border-dashed border-slate-200">
|
<div className="text-center py-4 bg-slate-50 rounded-lg border border-dashed border-slate-200">
|
||||||
<p className="text-slate-500 text-xs px-4">
|
<p className="text-slate-500 text-xs px-4">
|
||||||
{permissions.isFinalState ? 'This request is finalized.' : 'No actions available for your role at this stage.'}
|
{permissions.isFinalState ? 'This request is finalized.' : 'No actions available for your role at this stage.'}
|
||||||
@ -836,11 +1077,12 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
<div className="border-t border-slate-200 pt-3 mt-3">
|
<div className="border-t border-slate-200 pt-3 mt-3">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full border-blue- blue-700 hover:bg-blue-50"
|
className="w-full border-blue-700 text-blue-800 hover:bg-blue-50"
|
||||||
onClick={() => navigate(`/worknotes/constitutional-change/${requestId}`, {
|
onClick={() => navigate(`/worknotes/constitutional/${request?.id || requestId}`, {
|
||||||
state: {
|
state: {
|
||||||
|
requestType: 'constitutional',
|
||||||
applicationName: request?.outlet?.name || 'Constitutional Change',
|
applicationName: request?.outlet?.name || 'Constitutional Change',
|
||||||
registrationNumber: requestId || '',
|
registrationNumber: request?.requestId || requestId || '',
|
||||||
participants: request?.participants || []
|
participants: request?.participants || []
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
@ -859,25 +1101,30 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{actionType === 'approve' ? 'Approve Request' :
|
{actionType === 'approve' ? 'Approve request' :
|
||||||
actionType === 'reject' ? 'Reject Request' :
|
actionType === 'reject' ? 'Reject proposal' :
|
||||||
'Put Request on Hold'}
|
actionType === 'sendBack' ? 'Send back to previous stage' :
|
||||||
|
'Revoke request'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Please provide comments for this action. This will be recorded in the audit trail.
|
{actionType === 'sendBack' || actionType === 'revoke'
|
||||||
|
? 'SRS §12.2.3: remarks are mandatory and will be posted to Work Notes for Send Back / Revoke.'
|
||||||
|
: 'Comments will be recorded in the audit trail and work notes where applicable.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmitAction} className="space-y-4">
|
<form onSubmit={handleSubmitAction} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="comments">Comments *</Label>
|
<Label htmlFor="comments">
|
||||||
|
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="comments"
|
id="comments"
|
||||||
value={comments}
|
value={comments}
|
||||||
onChange={(e) => setComments(e.target.value)}
|
onChange={(e) => setComments(e.target.value)}
|
||||||
placeholder="Enter your comments..."
|
placeholder={actionType === 'sendBack' || actionType === 'revoke' ? 'Enter mandatory remarks for Work Notes…' : 'Enter your comments…'}
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required={actionType !== 'approve'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -894,7 +1141,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
className={
|
className={
|
||||||
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' :
|
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' :
|
||||||
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' :
|
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' :
|
||||||
'bg-amber-600 hover:bg-amber-700'
|
actionType === 'sendBack' ? 'bg-amber-600 hover:bg-amber-700' :
|
||||||
|
'bg-orange-600 hover:bg-orange-700'
|
||||||
}
|
}
|
||||||
disabled={isActionLoading}
|
disabled={isActionLoading}
|
||||||
>
|
>
|
||||||
@ -906,7 +1154,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
) : (
|
) : (
|
||||||
actionType === 'approve' ? 'Approve' :
|
actionType === 'approve' ? 'Approve' :
|
||||||
actionType === 'reject' ? 'Reject' :
|
actionType === 'reject' ? 'Reject' :
|
||||||
'Put on Hold'
|
actionType === 'sendBack' ? 'Send back' :
|
||||||
|
'Revoke'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@ -914,6 +1163,40 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<Dialog open={rejectDocDialogOpen} onOpenChange={setRejectDocDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reject document</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Per SRS relocation-style verification states, mark this upload as Rejected and provide a reason for the dealer.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label htmlFor="rejectReason">Rejection reason *</Label>
|
||||||
|
<Textarea
|
||||||
|
id="rejectReason"
|
||||||
|
rows={4}
|
||||||
|
value={rejectDocReason}
|
||||||
|
onChange={(e) => setRejectDocReason(e.target.value)}
|
||||||
|
placeholder="Explain what must be corrected or re-uploaded…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setRejectDocDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
disabled={isRejectingDoc}
|
||||||
|
onClick={() => void submitRejectDocument()}
|
||||||
|
>
|
||||||
|
{isRejectingDoc ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Confirm reject'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { Button } from '../ui/button';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
||||||
import { Input } from '../ui/input';
|
|
||||||
import { Label } from '../ui/label';
|
import { Label } from '../ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||||
import { Textarea } from '../ui/textarea';
|
import { Textarea } from '../ui/textarea';
|
||||||
@ -14,17 +13,18 @@ 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 { formatDateTime } from '../ui/utils';
|
import { formatDateTime } from '../ui/utils';
|
||||||
|
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
||||||
|
|
||||||
interface ConstitutionalChangePageProps {
|
interface ConstitutionalChangePageProps {
|
||||||
currentUser: UserType | null;
|
currentUser?: UserType | null;
|
||||||
onViewDetails: (id: string) => void;
|
onViewDetails: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document requirements mapping
|
// Document requirements mapping (keys = DB `changeType` ENUM values)
|
||||||
const documentRequirements: Record<string, number[]> = {
|
const documentRequirements: Record<string, number[]> = {
|
||||||
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
|
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
|
||||||
'LLP': [1, 2, 3, 7, 8, 9, 10, 16],
|
'LLP': [1, 2, 3, 7, 8, 9, 10, 16],
|
||||||
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16],
|
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
|
||||||
'Proprietorship': [1, 2, 3, 10, 16]
|
'Proprietorship': [1, 2, 3, 10, 16]
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -60,15 +60,24 @@ const getTypeColor = (type: string) => {
|
|||||||
switch(type) {
|
switch(type) {
|
||||||
case 'Proprietorship': return 'bg-purple-100 text-purple-700 border-purple-300';
|
case 'Proprietorship': return 'bg-purple-100 text-purple-700 border-purple-300';
|
||||||
case 'Partnership': return 'bg-blue-100 text-blue-700 border-blue-300';
|
case 'Partnership': return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||||
case 'LLP': return 'bg-indigo-100 text-indigo-700 border-indigo-300';
|
case 'LLP':
|
||||||
case 'Pvt Ltd': return 'bg-cyan-100 text-cyan-700 border-cyan-300';
|
case 'LLP Conversion':
|
||||||
|
return 'bg-indigo-100 text-indigo-700 border-indigo-300';
|
||||||
|
case 'Private Limited':
|
||||||
|
case 'Pvt Ltd':
|
||||||
|
return 'bg-cyan-100 text-cyan-700 border-cyan-300';
|
||||||
default: return 'bg-slate-100 text-slate-700 border-slate-300';
|
default: return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChangePageProps) {
|
export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChangePageProps) {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [dealerCode, setDealerCode] = useState('');
|
const [structureTargets, setStructureTargets] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
const [dealers, setDealers] = useState<any[]>([]);
|
||||||
|
const [allOutlets, setAllOutlets] = useState<any[]>([]);
|
||||||
|
const [selectedDealerUserId, setSelectedDealerUserId] = useState('');
|
||||||
|
const [selectedOutletId, setSelectedOutletId] = useState('');
|
||||||
|
const [outletsForDealer, setOutletsForDealer] = useState<any[]>([]);
|
||||||
const [dealerData, setDealerData] = useState<any>(null);
|
const [dealerData, setDealerData] = useState<any>(null);
|
||||||
const [targetType, setTargetType] = useState('');
|
const [targetType, setTargetType] = useState('');
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
@ -76,11 +85,43 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
const [requests, setRequests] = useState<any[]>([]);
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [dialogDataLoading, setDialogDataLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRequests();
|
fetchRequests();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDialogOpen) return;
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setDialogDataLoading(true);
|
||||||
|
const [metaRes, dealersRes, outletsRes] = await Promise.all([
|
||||||
|
API.getConstitutionalChangeMeta() as any,
|
||||||
|
API.getDealers({ onboarded: 'true' }) as any,
|
||||||
|
API.getOutlets() as any
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (metaRes.data?.success && Array.isArray(metaRes.data.structureTargets)) {
|
||||||
|
setStructureTargets(metaRes.data.structureTargets);
|
||||||
|
}
|
||||||
|
const dealerRows = dealersRes.data?.data ?? dealersRes.data?.dealers ?? [];
|
||||||
|
setDealers(Array.isArray(dealerRows) ? dealerRows : []);
|
||||||
|
const outlets = outletsRes.data?.outlets ?? [];
|
||||||
|
setAllOutlets(Array.isArray(outlets) ? outlets : []);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
toast.error('Failed to load dealers or form options');
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setDialogDataLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isDialogOpen]);
|
||||||
|
|
||||||
const fetchRequests = async () => {
|
const fetchRequests = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -96,33 +137,39 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDealerCodeChange = async (code: string) => {
|
const handleDealerUserSelect = (dealerUserId: string) => {
|
||||||
setDealerCode(code);
|
setSelectedDealerUserId(dealerUserId);
|
||||||
if (code.length >= 5) {
|
setSelectedOutletId('');
|
||||||
try {
|
setTargetType('');
|
||||||
const response = await API.getOutletByCode(code) as any;
|
setRequiredDocs([]);
|
||||||
if (response.data.success && response.data.outlet) {
|
if (!dealerUserId) {
|
||||||
const outlet = response.data.outlet;
|
|
||||||
setDealerData({
|
|
||||||
id: outlet.id,
|
|
||||||
dealerName: outlet.name,
|
|
||||||
address: outlet.address,
|
|
||||||
dealershipName: outlet.name,
|
|
||||||
gst: outlet.gstNumber || 'N/A',
|
|
||||||
currentType: outlet.type || 'Proprietorship',
|
|
||||||
region: outlet.region || 'N/A',
|
|
||||||
zone: outlet.zone || 'N/A'
|
|
||||||
});
|
|
||||||
toast.success('Dealer details loaded successfully');
|
|
||||||
} else {
|
|
||||||
setDealerData(null);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setDealerData(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setDealerData(null);
|
setDealerData(null);
|
||||||
|
setOutletsForDealer([]);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
const row = dealers.find((d: any) => d.user?.id === dealerUserId);
|
||||||
|
if (!row?.user?.id) {
|
||||||
|
setDealerData(null);
|
||||||
|
setOutletsForDealer([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const norm = normalizeDealerProfileConstitution(row.constitutionType);
|
||||||
|
const outs = allOutlets.filter((o: any) => String(o.dealerId) === String(dealerUserId));
|
||||||
|
setOutletsForDealer(outs);
|
||||||
|
if (outs.length === 1) {
|
||||||
|
setSelectedOutletId(outs[0].id);
|
||||||
|
}
|
||||||
|
setDealerData({
|
||||||
|
dealerUserId: row.user.id,
|
||||||
|
dealerName: row.businessName,
|
||||||
|
dealershipName: row.legalName || row.businessName,
|
||||||
|
address: row.registeredAddress || row.application?.preferredLocation || '—',
|
||||||
|
gst: row.gstNumber || 'N/A',
|
||||||
|
currentType: norm,
|
||||||
|
dealerCode: row.dealerCode?.dealerCode || '—',
|
||||||
|
region: '—',
|
||||||
|
zone: '—'
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTargetTypeChange = (type: string) => {
|
const handleTargetTypeChange = (type: string) => {
|
||||||
@ -133,13 +180,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
const handleSubmitRequest = async (e: React.FormEvent) => {
|
const handleSubmitRequest = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!dealerData) {
|
if (!dealerData?.dealerUserId) {
|
||||||
toast.error('Please enter a valid dealer code');
|
toast.error('Please select a dealer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outletsForDealer.length > 1 && !selectedOutletId) {
|
||||||
|
toast.error('Please select an outlet for this dealer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!targetType) {
|
if (!targetType) {
|
||||||
toast.error('Please select target dealership type');
|
toast.error('Please select proposed constitution type');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,17 +201,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (dealerData.currentType === targetType) {
|
if (dealerData.currentType === targetType) {
|
||||||
toast.error('Target type cannot be same as current type');
|
toast.error('Proposed type cannot be the same as the current constitution');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const payload = {
|
const payload = {
|
||||||
outletId: dealerData.id,
|
forDealerUserId: dealerData.dealerUserId,
|
||||||
|
outletId: selectedOutletId || undefined,
|
||||||
changeType: targetType,
|
changeType: targetType,
|
||||||
description: reason,
|
reason: reason.trim(),
|
||||||
newEntityDetails: {}
|
currentConstitution: dealerData.currentType
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await API.createConstitutionalChange(payload) as any;
|
const response = await API.createConstitutionalChange(payload) as any;
|
||||||
@ -169,15 +222,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
fetchRequests();
|
fetchRequests();
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setDealerCode('');
|
setSelectedDealerUserId('');
|
||||||
|
setSelectedOutletId('');
|
||||||
|
setOutletsForDealer([]);
|
||||||
setDealerData(null);
|
setDealerData(null);
|
||||||
setTargetType('');
|
setTargetType('');
|
||||||
setReason('');
|
setReason('');
|
||||||
setRequiredDocs([]);
|
setRequiredDocs([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Submit request error:', error);
|
console.error('Submit request error:', error);
|
||||||
toast.error('Failed to submit request');
|
const msg = error?.response?.data?.message || 'Failed to submit request';
|
||||||
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -245,33 +301,77 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmitRequest} className="space-y-4">
|
<form onSubmit={handleSubmitRequest} className="space-y-4">
|
||||||
{/* Dealer Code */}
|
{dialogDataLoading && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500 py-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading dealers and options…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dealer (onboarded) — same POST as dealer self-service, with forDealerUserId */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="dealerCode">Dealer Code *</Label>
|
<Label htmlFor="dealerUser">Dealer *</Label>
|
||||||
<Input
|
<Select
|
||||||
id="dealerCode"
|
value={selectedDealerUserId}
|
||||||
placeholder="Enter dealer code (e.g., DL-MH-001)"
|
onValueChange={handleDealerUserSelect}
|
||||||
value={dealerCode}
|
disabled={dialogDataLoading}
|
||||||
onChange={(e) => handleDealerCodeChange(e.target.value)}
|
|
||||||
required
|
required
|
||||||
/>
|
>
|
||||||
|
<SelectTrigger id="dealerUser">
|
||||||
|
<SelectValue placeholder="Select dealer account" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-72">
|
||||||
|
{dealers
|
||||||
|
.filter((d: any) => d.user?.id)
|
||||||
|
.map((d: any) => (
|
||||||
|
<SelectItem key={d.user.id} value={d.user.id}>
|
||||||
|
{(d.dealerCode?.dealerCode || 'No code') + ' — ' + (d.businessName || d.legalName || 'Dealer')}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Internal users create the request on behalf of the selected dealer; the workflow uses the same endpoint as dealer-initiated requests.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{outletsForDealer.length > 1 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="outletPick">Outlet *</Label>
|
||||||
|
<Select value={selectedOutletId} onValueChange={setSelectedOutletId} required>
|
||||||
|
<SelectTrigger id="outletPick">
|
||||||
|
<SelectValue placeholder="Select outlet" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{outletsForDealer.map((o: any) => (
|
||||||
|
<SelectItem key={o.id} value={o.id}>
|
||||||
|
{o.code} — {o.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Auto-populated Dealer Details */}
|
{/* Auto-populated Dealer Details */}
|
||||||
{dealerData && (
|
{dealerData && (
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-3">
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-3">
|
||||||
<h3 className="text-slate-900">Dealer Details</h3>
|
<h3 className="text-slate-900">Dealer Details</h3>
|
||||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-600">Dealer Code:</span>
|
||||||
|
<p className="text-slate-900">{dealerData.dealerCode}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-600">Dealer Name:</span>
|
<span className="text-slate-600">Dealer Name:</span>
|
||||||
<p className="text-slate-900">{dealerData.dealerName}</p>
|
<p className="text-slate-900">{dealerData.dealerName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-600">Dealership Name:</span>
|
<span className="text-slate-600">Legal / display name:</span>
|
||||||
<p className="text-slate-900">{dealerData.dealershipName}</p>
|
<p className="text-slate-900">{dealerData.dealershipName}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-600">Location:</span>
|
<span className="text-slate-600">Address:</span>
|
||||||
<p className="text-slate-900">{dealerData.address}</p>
|
<p className="text-slate-900">{dealerData.address}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -279,31 +379,30 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
<p className="text-slate-900">{dealerData.gst}</p>
|
<p className="text-slate-900">{dealerData.gst}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-600">Current Type:</span>
|
<span className="text-slate-600">Current constitution (from profile):</span>
|
||||||
<Badge className={getTypeColor(dealerData.currentType)}>
|
<Badge className={getTypeColor(dealerData.currentType)}>
|
||||||
{dealerData.currentType}
|
{dealerData.currentType}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="text-slate-600">Region/Zone:</span>
|
|
||||||
<p className="text-slate-900">{dealerData.region} / {dealerData.zone}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Target Dealership Type */}
|
{/* Proposed constitution — ENUM-aligned options from API */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="targetType">Target Dealership Type *</Label>
|
<Label htmlFor="targetType">Proposed constitution *</Label>
|
||||||
<Select value={targetType} onValueChange={handleTargetTypeChange} required>
|
<Select value={targetType} onValueChange={handleTargetTypeChange} required>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select target dealership type" />
|
<SelectValue placeholder="Select proposed constitution" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="Proprietorship">Proprietorship</SelectItem>
|
{structureTargets
|
||||||
<SelectItem value="Partnership">Partnership</SelectItem>
|
.filter((opt) => opt.value !== dealerData?.currentType)
|
||||||
<SelectItem value="LLP">LLP (Limited Liability Partnership)</SelectItem>
|
.map((opt) => (
|
||||||
<SelectItem value="Pvt Ltd">Pvt Ltd (Private Limited)</SelectItem>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{dealerData && targetType && dealerData.currentType === targetType && (
|
{dealerData && targetType && dealerData.currentType === targetType && (
|
||||||
@ -350,7 +449,14 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="bg-amber-600 hover:bg-amber-700"
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
disabled={!dealerData || !targetType || (dealerData && dealerData.currentType === targetType) || isSubmitting}
|
disabled={
|
||||||
|
!dealerData ||
|
||||||
|
!targetType ||
|
||||||
|
(dealerData && dealerData.currentType === targetType) ||
|
||||||
|
(outletsForDealer.length > 1 && !selectedOutletId) ||
|
||||||
|
dialogDataLoading ||
|
||||||
|
isSubmitting
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
@ -444,8 +550,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<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.currentConstitution || 'Proprietorship')}>
|
||||||
{request.outlet?.type || 'Proprietorship'}
|
{request.currentConstitution || '—'}
|
||||||
</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.changeType)}>
|
||||||
@ -522,8 +628,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<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.currentConstitution || 'Proprietorship')}>
|
||||||
{request.outlet?.type || 'Proprietorship'}
|
{request.currentConstitution || '—'}
|
||||||
</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.changeType)}>
|
||||||
@ -597,8 +703,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<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.currentConstitution || 'Proprietorship')}>
|
||||||
{request.outlet?.type || 'Proprietorship'}
|
{request.currentConstitution || '—'}
|
||||||
</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.changeType)}>
|
||||||
@ -677,8 +783,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<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.currentConstitution || 'Proprietorship')}>
|
||||||
{request.outlet?.type || 'Proprietorship'}
|
{request.currentConstitution || '—'}
|
||||||
</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.changeType)}>
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, Navigation, MapPin, MessageSquare, Loader2 } from 'lucide-react';
|
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, Navigation, MapPin, MessageSquare, Loader2, Calendar, Reply, Ban } from 'lucide-react';
|
||||||
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal';
|
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal';
|
||||||
import { formatDateTime } from '../ui/utils';
|
import { formatDateTime } from '../ui/utils';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
||||||
@ -36,6 +36,82 @@ const workflowStages = [
|
|||||||
{ id: 10, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' }
|
{ id: 10, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Map API stage / status label to 1-based workflow row index; 0 = unknown; length+1 = finished */
|
||||||
|
function relocationStageLabelToOrdinal(label: string | null | undefined): number {
|
||||||
|
const raw = String(label || '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\u00a0/g, ' ');
|
||||||
|
if (!raw || raw === 'Submitted') return 0;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (['completed', 'relocation complete', 'closed'].includes(lower)) {
|
||||||
|
return workflowStages.length + 1;
|
||||||
|
}
|
||||||
|
const pendingMatch = raw.match(/^pending\s+(.+)$/i);
|
||||||
|
const core = (pendingMatch ? pendingMatch[1] : raw).trim();
|
||||||
|
let idx = workflowStages.findIndex(
|
||||||
|
(s) => s.name === core || s.name === raw || s.key === core || s.key === raw
|
||||||
|
);
|
||||||
|
if (idx < 0) {
|
||||||
|
idx = workflowStages.findIndex(
|
||||||
|
(s) =>
|
||||||
|
core.toLowerCase().includes(s.name.toLowerCase()) ||
|
||||||
|
s.name.toLowerCase().includes(core.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return idx >= 0 ? idx + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Furthest workflow step implied by timeline entries (ignores document-only rows). */
|
||||||
|
function relocationTimelineMaxOrdinal(entries: any[]): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const e of entries || []) {
|
||||||
|
const action = String(e?.action || '');
|
||||||
|
if (/document\s*verified/i.test(action)) continue;
|
||||||
|
if (/document/i.test(action) && /upload/i.test(action)) continue;
|
||||||
|
for (const lab of [e?.targetStage, e?.stage].filter(Boolean)) {
|
||||||
|
const o = relocationStageLabelToOrdinal(lab as string);
|
||||||
|
if (o > max) max = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Furthest stage implied by relocation audit rows (same idea as timeline; helps when timeline JSON is stale). */
|
||||||
|
function relocationAuditMaxOrdinal(logs: any[]): number {
|
||||||
|
let max = 0;
|
||||||
|
for (const log of logs || []) {
|
||||||
|
const action = String(log?.action || '');
|
||||||
|
if (action === 'DOCUMENT_UPLOADED' || action === 'DOCUMENT_VERIFIED') continue;
|
||||||
|
const d = log?.details || log?.newData || {};
|
||||||
|
for (const lab of [d.targetStage, d.stage, log?.stage].filter(Boolean)) {
|
||||||
|
const o = relocationStageLabelToOrdinal(String(lab));
|
||||||
|
if (o > max) max = o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeline rows for a workflow stage (matches resignation: filter by `stage` at action time). */
|
||||||
|
function getRelocationTimelineEntriesForStage(
|
||||||
|
entries: any[],
|
||||||
|
stage: { name: string; key: string },
|
||||||
|
stageIndex: number
|
||||||
|
): any[] {
|
||||||
|
const list = Array.isArray(entries) ? [...entries] : [];
|
||||||
|
const filtered = list.filter((t: any) => {
|
||||||
|
const src = String(t?.stage || '').trim();
|
||||||
|
if (src === stage.name || src === stage.key) return true;
|
||||||
|
if (stageIndex === 0 && (src === 'Submitted' || src === 'Request submitted')) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const ta = new Date(a?.timestamp || a?.createdAt || 0).getTime();
|
||||||
|
const tb = new Date(b?.timestamp || b?.createdAt || 0).getTime();
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
// Required documents configuration
|
// Required documents configuration
|
||||||
const requiredDocuments = [
|
const requiredDocuments = [
|
||||||
'Property documents for new location',
|
'Property documents for new location',
|
||||||
@ -55,11 +131,27 @@ const requiredDocuments = [
|
|||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
if (status === 'Completed' || status === 'Verified' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
|
if (status === 'Completed' || status === 'Verified' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
|
||||||
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
||||||
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
|
if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
|
||||||
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
|
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getApiErrorMessage = (error: any, fallback: string) => {
|
||||||
|
const responseData = error?.response?.data || error?.data;
|
||||||
|
if (responseData?.readiness) {
|
||||||
|
const missing = responseData.readiness?.missingUploads || [];
|
||||||
|
const pending = responseData.readiness?.pendingVerification || [];
|
||||||
|
const details = [
|
||||||
|
missing.length ? `Missing: ${missing.join(', ')}` : '',
|
||||||
|
pending.length ? `Pending verification: ${pending.join(', ')}` : ''
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ');
|
||||||
|
return details ? `${responseData.message || fallback} (${details})` : (responseData.message || fallback);
|
||||||
|
}
|
||||||
|
return responseData?.message || error?.message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) {
|
export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [request, setRequest] = useState<any>(null);
|
const [request, setRequest] = useState<any>(null);
|
||||||
@ -67,7 +159,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
|
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
|
||||||
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold'>('approve');
|
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold' | 'send_back' | 'revoke'>('approve');
|
||||||
const [comments, setComments] = useState('');
|
const [comments, setComments] = useState('');
|
||||||
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||||
const [eorChecklist, setEorChecklist] = useState<any>(null);
|
const [eorChecklist, setEorChecklist] = useState<any>(null);
|
||||||
@ -96,10 +188,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchEorChecklist = async () => {
|
const fetchEorChecklist = async (relocationUuid?: string) => {
|
||||||
try {
|
try {
|
||||||
setIsEorLoading(true);
|
setIsEorLoading(true);
|
||||||
const response = await API.getEorChecklistForRelocation(requestId) as any;
|
const id = relocationUuid || request?.id || requestId;
|
||||||
|
const response = await API.getEorChecklistForRelocation(id) as any;
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setEorChecklist(response.data.data);
|
setEorChecklist(response.data.data);
|
||||||
}
|
}
|
||||||
@ -125,7 +218,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Update EOR item error:', error);
|
console.error('Update EOR item error:', error);
|
||||||
toast.error('Failed to update item');
|
toast.error(getApiErrorMessage(error, 'Failed to update item'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -144,7 +237,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit EOR audit error:', error);
|
console.error('Submit EOR audit error:', error);
|
||||||
toast.error('Failed to submit EOR audit');
|
toast.error(getApiErrorMessage(error, 'Failed to submit EOR audit'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingEor(false);
|
setIsSubmittingEor(false);
|
||||||
}
|
}
|
||||||
@ -155,33 +248,94 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
if (!isSilent) setIsLoading(true);
|
if (!isSilent) setIsLoading(true);
|
||||||
const response = await API.getRelocationRequestById(requestId) as any;
|
const response = await API.getRelocationRequestById(requestId) as any;
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
setRequest(response.data.request);
|
const req = response.data.request;
|
||||||
|
setRequest(req);
|
||||||
|
|
||||||
// Auto-fetch EOR checklist if in the correct stage
|
const currentStage = req.currentStage;
|
||||||
const currentStage = response.data.request.currentStage;
|
if (
|
||||||
if (currentStage === 'NBH_CLEARANCE_EOR' || currentStage === 'NBH Clearance with EOR' || response.data.request.status === 'Completed') {
|
currentStage === 'NBH_CLEARANCE_EOR' ||
|
||||||
fetchEorChecklist();
|
currentStage === 'NBH Clearance with EOR' ||
|
||||||
|
req.status === 'Completed'
|
||||||
|
) {
|
||||||
|
fetchEorChecklist(req.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch relocation request details error:', error);
|
console.error('Fetch relocation request details error:', error);
|
||||||
toast.error('Failed to fetch request details');
|
toast.error(getApiErrorMessage(error, 'Failed to fetch request details'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate current stage index based on request data
|
/**
|
||||||
const getCurrentStageIndex = () => {
|
* 1-based ordinal from persisted record (currentStage / status) — used for Approve/Reject RBAC only.
|
||||||
if (!request) return 0;
|
*/
|
||||||
const stageIndex = workflowStages.findIndex(s =>
|
const getDbStageOrdinal = () => {
|
||||||
s.key === request.currentStage ||
|
if (!request) return 1;
|
||||||
s.name === request.currentStage ||
|
if (request.status === 'Completed' || request.currentStage === 'Completed') {
|
||||||
s.name === (request.currentStage?.replace(/_/g, ' ') || '')
|
return workflowStages.length + 1;
|
||||||
|
}
|
||||||
|
if (request.currentStage === 'Rejected' || request.status === 'Rejected') {
|
||||||
|
const tl = [...(request.timeline || [])].filter(Boolean).reverse();
|
||||||
|
for (const e of tl) {
|
||||||
|
const st = e.stage;
|
||||||
|
const idx = workflowStages.findIndex((s) => s.name === st);
|
||||||
|
if (idx >= 0) return idx + 1;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
const stageName = request.currentStage;
|
||||||
|
const idx = workflowStages.findIndex(
|
||||||
|
(s) =>
|
||||||
|
s.name === stageName ||
|
||||||
|
s.key === stageName ||
|
||||||
|
s.name.replace(/\s+/g, ' ') === String(stageName || '').replace(/\s+/g, ' ')
|
||||||
);
|
);
|
||||||
return stageIndex !== -1 ? stageIndex + 1 : 1;
|
return idx >= 0 ? idx + 1 : 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const timelineEntries = Array.isArray(request?.timeline) ? request.timeline : [];
|
||||||
|
const timelineMaxOrdinal = relocationTimelineMaxOrdinal(timelineEntries);
|
||||||
|
const auditMaxOrdinal = relocationAuditMaxOrdinal(auditLogs);
|
||||||
|
const dbOrdinal = request ? getDbStageOrdinal() : 1;
|
||||||
|
/** Workflow list + progress bar: use furthest signal from DB, timeline JSON, or audit API */
|
||||||
|
const displayOrdinal = request ? Math.max(dbOrdinal, timelineMaxOrdinal, auditMaxOrdinal, 1) : 1;
|
||||||
|
const workflowProgressMismatch =
|
||||||
|
Boolean(request) &&
|
||||||
|
Math.max(timelineMaxOrdinal, auditMaxOrdinal) > dbOrdinal &&
|
||||||
|
(timelineEntries.length > 0 || auditLogs.length > 0);
|
||||||
|
|
||||||
|
const currentStageConfig = request
|
||||||
|
? workflowStages[Math.min(Math.max(dbOrdinal, 1), workflowStages.length) - 1]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const rawProgressPct = Math.min(100, Math.max(0, Number(request?.progressPercentage) || 0));
|
||||||
|
const allWorkflowComplete =
|
||||||
|
request?.status === 'Completed' ||
|
||||||
|
request?.currentStage === 'Completed' ||
|
||||||
|
displayOrdinal >= workflowStages.length + 1;
|
||||||
|
const timelineProgressPct = allWorkflowComplete
|
||||||
|
? 100
|
||||||
|
: displayOrdinal > 1
|
||||||
|
? Math.min(100, Math.round(((displayOrdinal - 1) / workflowStages.length) * 100))
|
||||||
|
: 0;
|
||||||
|
const displayProgressPct = allWorkflowComplete ? 100 : Math.max(rawProgressPct, timelineProgressPct);
|
||||||
|
|
||||||
|
const missingRequiredDocs = request
|
||||||
|
? requiredDocuments.filter((doc) => !request.documents?.some((d: any) =>
|
||||||
|
d.type === doc || (d.name && d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0]))
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const pendingVerificationDocs = request
|
||||||
|
? requiredDocuments.filter((doc) => {
|
||||||
|
const matched = request.documents?.filter((d: any) =>
|
||||||
|
d.type === doc || (d.name && d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0]))
|
||||||
|
) || [];
|
||||||
|
return matched.length > 0 && !matched.some((d: any) => d.status === 'Verified');
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
// Helper to find assigned reviewer for a stage
|
// Helper to find assigned reviewer for a stage
|
||||||
const getAssignedReviewer = (stageName: string) => {
|
const getAssignedReviewer = (stageName: string) => {
|
||||||
if (!request || !request.participants || request.participants.length === 0) return null;
|
if (!request || !request.participants || request.participants.length === 0) return null;
|
||||||
@ -196,9 +350,6 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
return participant.user?.fullName || participant.user?.name || participant.user?.role || null;
|
return participant.user?.fullName || participant.user?.name || participant.user?.role || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const currentStageIndex = getCurrentStageIndex();
|
|
||||||
const currentStageConfig = workflowStages[currentStageIndex - 1];
|
|
||||||
|
|
||||||
// Visibility logic for Approve/Reject buttons
|
// Visibility logic for Approve/Reject buttons
|
||||||
const canUserAction = () => {
|
const canUserAction = () => {
|
||||||
if (!request || !currentUser) return false;
|
if (!request || !currentUser) return false;
|
||||||
@ -208,12 +359,25 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
if (isAdmin) return true;
|
if (isAdmin) return true;
|
||||||
|
|
||||||
// Check if user's role matches the role required for the current stage
|
// Check if user's role matches the role required for the current stage
|
||||||
return currentUser.role === currentStageConfig?.role || currentUser.role === currentStageConfig?.role;
|
return Boolean(currentStageConfig?.role && currentUser.role === currentStageConfig.role);
|
||||||
};
|
};
|
||||||
|
|
||||||
const showActions = canUserAction() && request.status !== 'Completed' && request.status !== 'Rejected';
|
const showActions =
|
||||||
|
canUserAction() &&
|
||||||
|
request.status !== 'Completed' &&
|
||||||
|
request.status !== 'Rejected' &&
|
||||||
|
request.status !== 'Revoked';
|
||||||
|
|
||||||
const handleAction = (type: 'approve' | 'reject' | 'hold') => {
|
const canSendBack =
|
||||||
|
showActions &&
|
||||||
|
request.currentStage &&
|
||||||
|
request.currentStage !== 'ASM Review' &&
|
||||||
|
request.currentStage !== 'Rejected';
|
||||||
|
const canRevoke = showActions && ['ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Super Admin'].includes(currentUser?.role || '');
|
||||||
|
const requiresDocGate = request?.currentStage === 'NBH Approval' || request?.currentStage === 'Legal Clearance';
|
||||||
|
const canApprove = showActions && (!requiresDocGate || (missingRequiredDocs.length === 0 && pendingVerificationDocs.length === 0));
|
||||||
|
|
||||||
|
const handleAction = (type: 'approve' | 'reject' | 'hold' | 'send_back' | 'revoke') => {
|
||||||
setActionType(type);
|
setActionType(type);
|
||||||
setIsActionDialogOpen(true);
|
setIsActionDialogOpen(true);
|
||||||
};
|
};
|
||||||
@ -223,22 +387,36 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
const action = actionType === 'approve' ? 'APPROVE' : actionType === 'reject' ? 'REJECT' : 'HOLD';
|
const actionMap: Record<string, string> = {
|
||||||
|
approve: 'APPROVE',
|
||||||
|
reject: 'REJECT',
|
||||||
|
hold: 'HOLD',
|
||||||
|
send_back: 'SEND_BACK',
|
||||||
|
revoke: 'REVOKE'
|
||||||
|
};
|
||||||
|
const action = actionMap[actionType] || 'APPROVE';
|
||||||
const response = await API.updateRelocationRequest(requestId, action, { remarks: comments }) as any;
|
const response = await API.updateRelocationRequest(requestId, action, { remarks: comments }) as any;
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
toast.success(`Request ${actionType}d successfully`);
|
const verb =
|
||||||
|
actionType === 'send_back'
|
||||||
|
? 'sent back'
|
||||||
|
: actionType === 'revoke'
|
||||||
|
? 'revoked'
|
||||||
|
: `${actionType}d`;
|
||||||
|
toast.success(`Request ${verb} successfully`);
|
||||||
setIsActionDialogOpen(false);
|
setIsActionDialogOpen(false);
|
||||||
setComments('');
|
setComments('');
|
||||||
fetchRequestDetails();
|
fetchRequestDetails();
|
||||||
|
fetchAuditLogs();
|
||||||
// If moving to NBH Clearance EOR, fetch the checklist
|
// If moving to NBH Clearance EOR, fetch the checklist
|
||||||
if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE') {
|
if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE' && request?.id) {
|
||||||
fetchEorChecklist();
|
fetchEorChecklist(request.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit action error:', error);
|
console.error('Submit action error:', error);
|
||||||
toast.error('Failed to submit action');
|
toast.error(getApiErrorMessage(error, 'Failed to submit action'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -266,10 +444,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
setIsUploadDialogOpen(false);
|
setIsUploadDialogOpen(false);
|
||||||
setSelectedFile(null);
|
setSelectedFile(null);
|
||||||
fetchRequestDetails(true);
|
fetchRequestDetails(true);
|
||||||
|
fetchAuditLogs();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload document error:', error);
|
console.error('Upload document error:', error);
|
||||||
toast.error('Failed to upload document');
|
toast.error(getApiErrorMessage(error, 'Failed to upload document'));
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
@ -281,10 +460,25 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
toast.success('Document verified successfully');
|
toast.success('Document verified successfully');
|
||||||
fetchRequestDetails(true); // Silent refresh
|
fetchRequestDetails(true); // Silent refresh
|
||||||
|
fetchAuditLogs();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Verify document error:', error);
|
console.error('Verify document error:', error);
|
||||||
toast.error('Failed to verify document');
|
toast.error(getApiErrorMessage(error, 'Failed to verify document'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectDocument = async (documentId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await API.rejectRelocationDocument(requestId, documentId, { remarks: 'Rejected by reviewer' }) as any;
|
||||||
|
if (response.data.success) {
|
||||||
|
toast.success('Document rejected successfully');
|
||||||
|
fetchRequestDetails(true);
|
||||||
|
fetchAuditLogs();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Reject document error:', error);
|
||||||
|
toast.error(getApiErrorMessage(error, 'Failed to reject document'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -404,7 +598,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<p className="text-slate-600 text-sm mb-1">Request Information</p>
|
<p className="text-slate-600 text-sm mb-1">Request Information</p>
|
||||||
<p className="text-slate-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</p>
|
<p className="text-slate-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</p>
|
||||||
<p className="text-slate-600 text-sm">By: {request.dealer?.fullName}</p>
|
<p className="text-slate-600 text-sm">By: {request.dealer?.fullName}</p>
|
||||||
<p className="text-slate-900 text-sm mt-2">Current Stage: {request.currentStage.replace(/_/g, ' ')}</p>
|
<p className="text-slate-900 text-sm mt-2">
|
||||||
|
Current Stage: {String(request.currentStage || '').replace(/_/g, ' ')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -440,52 +636,98 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-slate-900">Overall Progress</span>
|
<span className="text-slate-900">Overall Progress</span>
|
||||||
<span className="text-slate-600">{request.progressPercentage}%</span>
|
<span className="text-slate-600">{displayProgressPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
|
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-amber-600 transition-all duration-500"
|
className="h-full bg-amber-600 transition-all duration-500"
|
||||||
style={{ width: `${request.progressPercentage}%` }}
|
style={{ width: `${displayProgressPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Workflow Stages */}
|
{workflowProgressMismatch && (
|
||||||
|
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||||
|
<span className="font-medium">Progress sync:</span> Timeline and/or audit history show more
|
||||||
|
workflow progress than the stored current stage ({String(request.currentStage)}). The step list
|
||||||
|
below follows that history; approve/reject still use the official current stage only.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900">Progress Timeline</h3>
|
||||||
|
<CardDescription className="mt-1">
|
||||||
|
Track the relocation approval process — activity recorded at each stage appears below that step.
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow stages + per-stage timeline (same pattern as ResignationDetails progress tab) */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{workflowStages.map((stage: any, index: number) => {
|
{workflowStages.map((stage: any, index: number) => {
|
||||||
const isCompleted = index < currentStageIndex - 1;
|
const isCompleted = allWorkflowComplete || index < displayOrdinal - 1;
|
||||||
const isCurrent = index === currentStageIndex - 1;
|
const isCurrent = !allWorkflowComplete && index === displayOrdinal - 1;
|
||||||
|
const stageTimelineEntries = getRelocationTimelineEntriesForStage(
|
||||||
|
timelineEntries,
|
||||||
|
stage,
|
||||||
|
index
|
||||||
|
);
|
||||||
|
const timelineEntry =
|
||||||
|
stageTimelineEntries.length > 0
|
||||||
|
? stageTimelineEntries[stageTimelineEntries.length - 1]
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={stage.id} className="flex items-start gap-4">
|
<div key={stage.id} className="flex items-start gap-4">
|
||||||
{/* 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 ${isCompleted ? 'bg-green-100' :
|
<div
|
||||||
isCurrent ? 'bg-amber-100' :
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
'bg-slate-100'
|
isCompleted
|
||||||
}`}>
|
? 'bg-green-100 text-green-600'
|
||||||
|
: isCurrent
|
||||||
|
? 'bg-amber-100 text-amber-600'
|
||||||
|
: 'bg-slate-100 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{isCompleted ? (
|
{isCompleted ? (
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
<CheckCircle2 className="w-5 h-5" />
|
||||||
) : isCurrent ? (
|
) : isCurrent ? (
|
||||||
<Clock className="w-5 h-5 text-amber-600" />
|
<Clock className="w-5 h-5" />
|
||||||
) : (
|
) : (
|
||||||
<AlertCircle className="w-5 h-5 text-slate-400" />
|
<span className="text-xs font-semibold">{stage.id}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{index < workflowStages.length - 1 && (
|
{index < workflowStages.length - 1 && (
|
||||||
<div className={`w-0.5 h-12 ${isCompleted ? 'bg-green-300' : 'bg-slate-200'
|
<div
|
||||||
}`} />
|
className={`w-0.5 h-16 ${isCompleted ? 'bg-green-300' : 'bg-slate-200'}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stage Info */}
|
<div
|
||||||
<div className={`flex-1 pb-8 ${isCurrent ? 'bg-amber-50 -ml-4 pl-4 pr-4 py-3 rounded-lg border border-amber-200' : ''}`}>
|
className={`flex-1 pb-8 ${
|
||||||
<div className="flex items-center justify-between">
|
isCurrent
|
||||||
|
? 'bg-amber-50 -ml-4 pl-4 pr-4 py-3 rounded-lg border border-amber-200'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 className={`${isCurrent ? 'text-amber-900' : 'text-slate-900'}`}>
|
<h4
|
||||||
|
className={
|
||||||
|
isCompleted
|
||||||
|
? 'text-green-700'
|
||||||
|
: isCurrent
|
||||||
|
? 'text-amber-900'
|
||||||
|
: 'text-slate-900'
|
||||||
|
}
|
||||||
|
>
|
||||||
{stage.name}
|
{stage.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}>
|
<p
|
||||||
|
className={`text-sm ${
|
||||||
|
isCurrent ? 'text-amber-700' : 'text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
Responsible: {stage.role}
|
Responsible: {stage.role}
|
||||||
</p>
|
</p>
|
||||||
{getAssignedReviewer(stage.name) && (
|
{getAssignedReviewer(stage.name) && (
|
||||||
@ -494,14 +736,55 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Badge className={
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
isCompleted ? 'bg-green-100 text-green-700 border-green-300' :
|
<Badge
|
||||||
isCurrent ? 'bg-amber-100 text-amber-700 border-amber-300' :
|
className={
|
||||||
'bg-slate-100 text-slate-500 border-slate-300'
|
isCompleted
|
||||||
}>
|
? 'bg-green-100 text-green-700 border-green-300'
|
||||||
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'}
|
: isCurrent
|
||||||
</Badge>
|
? 'bg-amber-100 text-amber-700 border-amber-300'
|
||||||
|
: 'bg-slate-100 text-slate-500 border-slate-300'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'}
|
||||||
|
</Badge>
|
||||||
|
{timelineEntry && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-slate-600">
|
||||||
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
|
<span>{formatDateTime(timelineEntry.timestamp || timelineEntry.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{timelineEntry && (
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
|
||||||
|
{timelineEntry.user || 'System'}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-[10px] text-slate-500 italic">
|
||||||
|
{timelineEntry.action || 'Update'}
|
||||||
|
</span>
|
||||||
|
{timelineEntry.targetStage &&
|
||||||
|
timelineEntry.targetStage !== timelineEntry.stage && (
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
→ {String(timelineEntry.targetStage).replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
|
||||||
|
{timelineEntry.remarks ||
|
||||||
|
timelineEntry.comments ||
|
||||||
|
'No remarks provided.'}
|
||||||
|
</div>
|
||||||
|
{stageTimelineEntries.length > 1 && (
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
{stageTimelineEntries.length} events at this stage; showing the latest.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -678,15 +961,27 @@ 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' && currentUser?.role !== 'Dealer' && (
|
||||||
<Button
|
<>
|
||||||
size="sm"
|
<Button
|
||||||
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1"
|
size="sm"
|
||||||
onClick={() => handleVerifyDocument(doc.id)}
|
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1"
|
||||||
title="Verify Document"
|
onClick={() => handleVerifyDocument(doc.id)}
|
||||||
>
|
title="Verify Document"
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
>
|
||||||
Verify
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
</Button>
|
Verify
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={() => handleRejectDocument(doc.id)}
|
||||||
|
title="Reject Document"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4" />
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@ -712,6 +1007,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="text-slate-900">EOR Readiness Checklist</h4>
|
<h4 className="text-slate-900">EOR Readiness Checklist</h4>
|
||||||
<p className="text-slate-600 text-sm">Verify new location infrastructure and statutory compliances</p>
|
<p className="text-slate-600 text-sm">Verify new location infrastructure and statutory compliances</p>
|
||||||
|
<p className="text-slate-500 text-xs mt-1 max-w-2xl">
|
||||||
|
When document types match a checklist line, proofs from the Documents tab are linked here automatically (same files; no separate EOR upload required).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{eorChecklist && (
|
{eorChecklist && (
|
||||||
<Badge className={getStatusColor(eorChecklist.status)}>
|
<Badge className={getStatusColor(eorChecklist.status)}>
|
||||||
@ -734,7 +1032,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<p className="text-slate-500 text-sm mb-4">
|
<p className="text-slate-500 text-sm mb-4">
|
||||||
The EOR checklist will be automatically initiated once the request reaches the final clearance stage.
|
The EOR checklist will be automatically initiated once the request reaches the final clearance stage.
|
||||||
</p>
|
</p>
|
||||||
<Button variant="outline" onClick={fetchEorChecklist}>
|
<Button variant="outline" onClick={() => fetchEorChecklist(request?.id)}>
|
||||||
Try Refreshing
|
Try Refreshing
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
@ -753,7 +1051,14 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{eorChecklist.items?.map((item: any) => (
|
{(!eorChecklist.items || eorChecklist.items.length === 0) ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-slate-500 py-8 text-sm">
|
||||||
|
No checklist rows returned. Use "Try Refreshing" above or reload the page; rows are created when the request enters NBH Clearance with EOR.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
eorChecklist.items.map((item: any) => (
|
||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<input
|
<input
|
||||||
@ -773,14 +1078,36 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
{item.description}
|
{item.description}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col items-start gap-1">
|
||||||
{item.proofDocumentId ? (
|
{item.proofDocumentId && item.proofDocument ? (
|
||||||
<Button size="sm" variant="ghost" className="h-7 text-blue-600">
|
<>
|
||||||
<Eye className="w-3.5 h-3.5 mr-1" />
|
<span className="text-xs text-slate-600 truncate max-w-[220px]" title={item.proofDocument.fileName}>
|
||||||
View
|
{item.proofDocument.fileName}
|
||||||
</Button>
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 text-blue-600 px-0"
|
||||||
|
onClick={() =>
|
||||||
|
handlePreviewDocument({
|
||||||
|
name: item.proofDocument.fileName,
|
||||||
|
url: item.proofDocument.filePath,
|
||||||
|
type: item.proofDocument.documentType,
|
||||||
|
uploadedOn: item.proofDocument.updatedAt || item.proofDocument.createdAt,
|
||||||
|
mimeType: item.proofDocument.mimeType
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Eye className="w-3.5 h-3.5 mr-1" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : item.proofDocumentId ? (
|
||||||
|
<span className="text-xs text-amber-700">Proof linked (refresh if file details are missing)</span>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 text-slate-400"
|
className="h-7 text-slate-400"
|
||||||
@ -793,7 +1120,8 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
@ -804,7 +1132,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<Button
|
<Button
|
||||||
className="bg-green-600 hover:bg-green-700"
|
className="bg-green-600 hover:bg-green-700"
|
||||||
onClick={handleSubmitEorAudit}
|
onClick={handleSubmitEorAudit}
|
||||||
disabled={isSubmittingEor || !eorChecklist.items?.every((i: any) => i.isCompliant)}
|
disabled={
|
||||||
|
isSubmittingEor ||
|
||||||
|
!eorChecklist.items?.length ||
|
||||||
|
!eorChecklist.items.every((i: any) => i.isCompliant)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmittingEor ? (
|
{isSubmittingEor ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
@ -846,7 +1178,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-slate-900">{entry.stage || entry.action}</h4>
|
<h4 className="text-slate-900">
|
||||||
|
{entry.stage || entry.details?.stage || entry.details?.targetStage || entry.action}
|
||||||
|
</h4>
|
||||||
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
|
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className={getStatusColor(entry.action)}>
|
<Badge className={getStatusColor(entry.action)}>
|
||||||
@ -889,10 +1223,10 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
|
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-amber-600 transition-all duration-300"
|
className="h-full bg-amber-600 transition-all duration-300"
|
||||||
style={{ width: `${request.progressPercentage}%` }}
|
style={{ width: `${displayProgressPct}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-slate-900">{request.progressPercentage}%</span>
|
<span className="text-slate-900">{displayProgressPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -913,7 +1247,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<Button
|
<Button
|
||||||
className="w-full bg-green-600 hover:bg-green-700"
|
className="w-full bg-green-600 hover:bg-green-700"
|
||||||
onClick={() => handleAction('approve')}
|
onClick={() => handleAction('approve')}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting || !canApprove}
|
||||||
>
|
>
|
||||||
{isSubmitting && actionType === 'approve' ? (
|
{isSubmitting && actionType === 'approve' ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
@ -922,6 +1256,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
)}
|
)}
|
||||||
Approve Request
|
Approve Request
|
||||||
</Button>
|
</Button>
|
||||||
|
{!canApprove && (
|
||||||
|
<p className="text-xs text-amber-700">
|
||||||
|
Approval is blocked until mandatory documents are uploaded and verified for this stage.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@ -937,6 +1276,41 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
Reject Request
|
Reject Request
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{canSendBack && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-amber-400 text-amber-900 hover:bg-amber-50"
|
||||||
|
onClick={() => handleAction('send_back')}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && actionType === 'send_back' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Reply className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Send Back
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-red-300 text-red-800 hover:bg-red-50"
|
||||||
|
onClick={() => handleAction('revoke')}
|
||||||
|
disabled={isSubmitting || !canRevoke}
|
||||||
|
>
|
||||||
|
{isSubmitting && actionType === 'revoke' ? (
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Ban className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Revoke Request
|
||||||
|
</Button>
|
||||||
|
{!canRevoke && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Revoke is restricted to ZBH, DD Lead, DD Head, NBH, Legal Admin, and Super Admin.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border-t border-slate-200 pt-3 mt-3" />
|
<div className="border-t border-slate-200 pt-3 mt-3" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -994,18 +1368,28 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{actionType === 'approve' ? 'Approve Request' :
|
{actionType === 'approve'
|
||||||
actionType === 'reject' ? 'Reject Request' :
|
? 'Approve Request'
|
||||||
'Put Request on Hold'}
|
: actionType === 'reject'
|
||||||
|
? 'Reject Request'
|
||||||
|
: actionType === 'send_back'
|
||||||
|
? 'Send Back Request'
|
||||||
|
: actionType === 'revoke'
|
||||||
|
? 'Revoke Request'
|
||||||
|
: 'Put Request on Hold'}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Please provide comments for this action. This will be recorded in the audit trail.
|
{actionType === 'send_back' || actionType === 'revoke'
|
||||||
|
? 'Remarks are required and will be recorded in Work Notes and the audit trail.'
|
||||||
|
: 'Please provide comments for this action. This will be recorded in the audit trail.'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmitAction} className="space-y-4">
|
<form onSubmit={handleSubmitAction} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="comments" >Comments *</Label>
|
<Label htmlFor="comments">
|
||||||
|
{actionType === 'send_back' || actionType === 'revoke' ? 'Remarks *' : 'Comments *'}
|
||||||
|
</Label>
|
||||||
<div className="space-y-2" />
|
<div className="space-y-2" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@ -1013,7 +1397,13 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
id="comments"
|
id="comments"
|
||||||
value={comments}
|
value={comments}
|
||||||
onChange={(e) => setComments(e.target.value)}
|
onChange={(e) => setComments(e.target.value)}
|
||||||
placeholder="Enter your comments..."
|
placeholder={
|
||||||
|
actionType === 'send_back'
|
||||||
|
? 'Explain what needs to be corrected at the previous stage…'
|
||||||
|
: actionType === 'revoke'
|
||||||
|
? 'Document why this relocation request is being revoked…'
|
||||||
|
: 'Enter your comments...'
|
||||||
|
}
|
||||||
rows={4}
|
rows={4}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
@ -1030,18 +1420,30 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
|||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
className={
|
className={
|
||||||
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' :
|
actionType === 'approve'
|
||||||
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' :
|
? 'bg-green-600 hover:bg-green-700'
|
||||||
'bg-amber-600 hover:bg-amber-700'
|
: actionType === 'reject'
|
||||||
|
? 'bg-red-600 hover:bg-red-700'
|
||||||
|
: actionType === 'send_back'
|
||||||
|
? 'bg-amber-600 hover:bg-amber-700'
|
||||||
|
: actionType === 'revoke'
|
||||||
|
? 'bg-red-700 hover:bg-red-800'
|
||||||
|
: 'bg-amber-600 hover:bg-amber-700'
|
||||||
}
|
}
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
) : null}
|
) : null}
|
||||||
{actionType === 'approve' ? 'Approve' :
|
{actionType === 'approve'
|
||||||
actionType === 'reject' ? 'Reject' :
|
? 'Approve'
|
||||||
'Put on Hold'}
|
: actionType === 'reject'
|
||||||
|
? 'Reject'
|
||||||
|
: actionType === 'send_back'
|
||||||
|
? 'Send Back'
|
||||||
|
: actionType === 'revoke'
|
||||||
|
? 'Revoke'
|
||||||
|
: 'Put on Hold'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -18,7 +18,7 @@ interface RelocationRequestPageProps {
|
|||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
if (status === 'Completed' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
|
if (status === 'Completed' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
|
||||||
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
||||||
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
|
if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
|
||||||
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
|
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
|
||||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
};
|
};
|
||||||
@ -58,7 +58,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'In Progress',
|
title: 'In Progress',
|
||||||
value: requests.filter((r: any) => r.status !== 'Completed' && r.status !== 'Closed' && !r.status.includes('Rejected')).length,
|
value: requests.filter((r: any) => r.status !== 'Completed' && r.status !== 'Closed' && !r.status.includes('Rejected') && !r.status.includes('Revoked')).length,
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
color: 'bg-yellow-500',
|
color: 'bg-yellow-500',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,7 +21,8 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Info,
|
Info,
|
||||||
Clock as ClockIcon
|
Clock as ClockIcon,
|
||||||
|
Activity,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '../ui/badge';
|
import { Badge } from '../ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -55,6 +56,54 @@ interface WorkNote {
|
|||||||
attachments?: Attachment[];
|
attachments?: Attachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sortNotesChronological = (arr: WorkNote[]) =>
|
||||||
|
[...arr].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||||
|
|
||||||
|
const normalizeWorknoteAuthor = (n: any): WorkNote['author'] => ({
|
||||||
|
name: n?.author?.name || n?.author?.fullName || 'System',
|
||||||
|
email: n?.author?.email || '',
|
||||||
|
role: String(n?.author?.role || n?.author?.roleCode || '')
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Workflow-generated rows (approvals, send-back, revoke, etc.) — not composer messages. */
|
||||||
|
const ACTIVITY_NOTE_TYPES = new Set(['internal', 'workflow', 'system', 'audit', 'status']);
|
||||||
|
|
||||||
|
const isActivityLogNote = (note: WorkNote) =>
|
||||||
|
ACTIVITY_NOTE_TYPES.has(String(note.noteType || '').toLowerCase());
|
||||||
|
|
||||||
|
const activityLogBadgeLabel = (note: WorkNote) => {
|
||||||
|
const t = String(note.noteType || '').toLowerCase();
|
||||||
|
if (t === 'workflow') return 'Workflow activity';
|
||||||
|
if (t === 'internal') return 'Review / approval';
|
||||||
|
if (t === 'system') return 'System';
|
||||||
|
if (t === 'audit') return 'Audit';
|
||||||
|
if (t === 'status') return 'Status update';
|
||||||
|
return 'Activity';
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Short label for compact activity row */
|
||||||
|
/** Uppercase pill label (e.g. APPROVAL) — matches relocation-style worknote activity rows. */
|
||||||
|
const activityLogPillCategoryUpper = (note: WorkNote) => {
|
||||||
|
const t = String(note.noteType || '').toLowerCase();
|
||||||
|
if (t === 'internal') return 'APPROVAL';
|
||||||
|
if (t === 'workflow') return 'WORKFLOW';
|
||||||
|
if (t === 'system') return 'SYSTEM';
|
||||||
|
if (t === 'audit') return 'AUDIT';
|
||||||
|
if (t === 'status') return 'STATUS';
|
||||||
|
return 'ACTIVITY';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNoteTimestamp = (createdAt: string) =>
|
||||||
|
createdAt
|
||||||
|
? new Date(createdAt).toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: '';
|
||||||
|
|
||||||
// Participant interface for mentions
|
// Participant interface for mentions
|
||||||
|
|
||||||
interface WorkNotesPageProps {
|
interface WorkNotesPageProps {
|
||||||
@ -106,6 +155,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||||
const [participantSearch, setParticipantSearch] = useState('');
|
const [participantSearch, setParticipantSearch] = useState('');
|
||||||
|
const [messageSearch, setMessageSearch] = useState('');
|
||||||
|
|
||||||
const { socket } = useSocket();
|
const { socket } = useSocket();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@ -189,22 +239,21 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Participants list for mentions:', participantsList.map(p => ({ id: p.id, name: p.name })));
|
|
||||||
|
|
||||||
const fetchNotes = async () => {
|
const fetchNotes = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const res: any = await worknoteService.getWorknotes(requestId, requestType);
|
const res: any = await worknoteService.getWorknotes(requestId, requestType);
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setNotes(res.data.map((n: any) => ({
|
const mapped: WorkNote[] = res.data.map((n: any) => ({
|
||||||
id: n.id,
|
id: n.id,
|
||||||
noteText: n.noteText,
|
noteText: n.noteText,
|
||||||
noteType: n.noteType,
|
noteType: n.noteType,
|
||||||
createdAt: n.createdAt,
|
createdAt: n.createdAt,
|
||||||
userId: n.userId,
|
userId: n.userId,
|
||||||
author: n.author || { name: 'System', email: '', role: 'system' },
|
author: n.author ? normalizeWorknoteAuthor(n) : { name: 'System', email: '', role: 'system' },
|
||||||
attachments: n.attachments || []
|
attachments: n.attachments || []
|
||||||
})));
|
}));
|
||||||
|
setNotes(sortNotesChronological(mapped));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch notes error:', error);
|
console.error('Fetch notes error:', error);
|
||||||
@ -221,13 +270,20 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
if (socket) {
|
if (socket) {
|
||||||
socket.emit('join_room', requestId);
|
socket.emit('join_room', requestId);
|
||||||
|
|
||||||
socket.on('new_worknote', (newNote: any) => {
|
socket.on('new_worknote', (raw: any) => {
|
||||||
|
const newNote: WorkNote = {
|
||||||
|
id: raw.id,
|
||||||
|
noteText: raw.noteText,
|
||||||
|
noteType: raw.noteType,
|
||||||
|
createdAt: raw.createdAt,
|
||||||
|
userId: raw.userId,
|
||||||
|
author: raw.author ? normalizeWorknoteAuthor(raw) : { name: 'System', email: '', role: 'system' },
|
||||||
|
attachments: raw.attachments || []
|
||||||
|
};
|
||||||
setNotes(prev => {
|
setNotes(prev => {
|
||||||
// 1. Check for exact ID match (real vs real or real vs replaced temp)
|
|
||||||
const isDuplicate = prev.some(n => n.id === newNote.id);
|
const isDuplicate = prev.some(n => n.id === newNote.id);
|
||||||
if (isDuplicate) return prev;
|
if (isDuplicate) return prev;
|
||||||
|
|
||||||
// 2. Check for optimistic match (matching text and author from a very recent temp note)
|
|
||||||
const optimisticMatchIndex = prev.findIndex(n =>
|
const optimisticMatchIndex = prev.findIndex(n =>
|
||||||
n.id.startsWith('temp-') &&
|
n.id.startsWith('temp-') &&
|
||||||
n.noteText === newNote.noteText &&
|
n.noteText === newNote.noteText &&
|
||||||
@ -235,14 +291,12 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (optimisticMatchIndex !== -1) {
|
if (optimisticMatchIndex !== -1) {
|
||||||
// Replace the temp note with the real one from the socket
|
|
||||||
const newNotes = [...prev];
|
const newNotes = [...prev];
|
||||||
newNotes[optimisticMatchIndex] = newNote;
|
newNotes[optimisticMatchIndex] = newNote;
|
||||||
return newNotes;
|
return sortNotesChronological(newNotes);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. New message from someone else
|
return sortNotesChronological([...prev, newNote]);
|
||||||
return [newNote, ...prev];
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -403,26 +457,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
|
|
||||||
participantsList.forEach(p => {
|
participantsList.forEach(p => {
|
||||||
if (p.id && p.name) {
|
if (p.id && p.name) {
|
||||||
// Simplified regex: Look for @ followed by the name, case-insensitive
|
|
||||||
// We use a more standard boundary check
|
|
||||||
const escapedName = p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const escapedName = p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
const mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi');
|
const mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi');
|
||||||
|
|
||||||
if (processedMessage.match(mentionRegex)) {
|
if (processedMessage.match(mentionRegex)) {
|
||||||
console.log(`Mention found for ${p.name} (${p.id})`);
|
|
||||||
mentionedUserIds.push(p.id);
|
mentionedUserIds.push(p.id);
|
||||||
processedMessage = processedMessage.replace(mentionRegex, `@[${p.name}](user:${p.id})`);
|
processedMessage = processedMessage.replace(mentionRegex, `@[${p.name}](user:${p.id})`);
|
||||||
} else {
|
|
||||||
console.log(`No match for participant: ${p.name} in message: "${processedMessage}"`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Final processed message:', processedMessage);
|
|
||||||
console.log('Mentioned user IDs:', mentionedUserIds);
|
|
||||||
|
|
||||||
console.log('Final processed message for API:', processedMessage);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Optimistic update
|
// Optimistic update
|
||||||
const tempId = `temp-${Date.now()}`;
|
const tempId = `temp-${Date.now()}`;
|
||||||
@ -441,7 +485,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
attachments: optimisticAttachments
|
attachments: optimisticAttachments
|
||||||
};
|
};
|
||||||
|
|
||||||
setNotes(prev => [tempNote, ...prev]);
|
setNotes(prev => sortNotesChronological([...prev, tempNote]));
|
||||||
|
|
||||||
const res: any = await worknoteService.addWorknote({
|
const res: any = await worknoteService.addWorknote({
|
||||||
requestId: requestId,
|
requestId: requestId,
|
||||||
@ -453,8 +497,17 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
// Replace optimistic update with actual data from server
|
const saved = res.data;
|
||||||
setNotes(prev => prev.map(n => n.id === tempId ? res.data : n));
|
const normalized: WorkNote = {
|
||||||
|
id: saved.id,
|
||||||
|
noteText: saved.noteText,
|
||||||
|
noteType: saved.noteType,
|
||||||
|
createdAt: saved.createdAt,
|
||||||
|
userId: saved.userId,
|
||||||
|
author: saved.author ? normalizeWorknoteAuthor(saved) : { name: 'System', email: '', role: 'system' },
|
||||||
|
attachments: saved.attachments || []
|
||||||
|
};
|
||||||
|
setNotes(prev => sortNotesChronological(prev.map(n => (n.id === tempId ? normalized : n))));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Send message error:', error);
|
console.error('Send message error:', error);
|
||||||
@ -492,6 +545,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const messageQuery = messageSearch.trim().toLowerCase();
|
||||||
|
const displayNotes = messageQuery
|
||||||
|
? notes.filter(
|
||||||
|
(n) =>
|
||||||
|
(n.noteText || '').toLowerCase().includes(messageQuery) ||
|
||||||
|
(n.author?.name || '').toLowerCase().includes(messageQuery) ||
|
||||||
|
(n.noteType || '').toLowerCase().includes(messageQuery)
|
||||||
|
)
|
||||||
|
: notes;
|
||||||
|
|
||||||
const filteredParticipants = participantsList.filter(p => {
|
const filteredParticipants = participantsList.filter(p => {
|
||||||
// Filter by name query
|
// Filter by name query
|
||||||
const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase());
|
const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase());
|
||||||
@ -580,87 +643,156 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
{/* Main Chat Engine */}
|
{/* Main Chat Engine */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 bg-white min-h-0 relative">
|
<div className="flex-1 flex flex-col min-w-0 bg-white min-h-0 relative">
|
||||||
<div className="flex-1 overflow-y-auto px-6 py-4 custom-scrollbar bg-slate-50 relative z-0">
|
<div className="flex-1 overflow-y-auto px-6 py-4 custom-scrollbar bg-slate-50 relative z-0">
|
||||||
<div className={`max-w-4xl mx-auto space-y-6 flex flex-col py-4 ${mode === 'modal' ? '' : 'px-4'}`}>
|
<div className={`max-w-4xl mx-auto flex flex-col py-4 gap-4 ${mode === 'modal' ? '' : 'px-4'}`}>
|
||||||
{[...notes].reverse().map((note) => {
|
<div className="sticky top-0 z-[1] -mx-1 px-1 pb-1 bg-slate-50/95 backdrop-blur-sm">
|
||||||
const isMe = (note?.author?.email && currentUser?.email && note.author.email.toLowerCase() === currentUser.email.toLowerCase()) ||
|
<div className="relative">
|
||||||
(note?.userId && currentUser?.id && note.userId === currentUser.id) ||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
||||||
note.id.startsWith('temp-');
|
<Input
|
||||||
|
type="search"
|
||||||
return (
|
value={messageSearch}
|
||||||
<div key={note.id} className={`flex w-full ${isMe ? 'justify-end' : 'justify-start'}`}>
|
onChange={(e) => setMessageSearch(e.target.value)}
|
||||||
<div className={`flex gap-3 max-w-[85%] ${isMe ? 'flex-row-reverse' : ''}`}>
|
placeholder="Search messages..."
|
||||||
{/* Avatar */}
|
className="pl-9 h-10 bg-white border-slate-200 rounded-xl text-sm shadow-sm"
|
||||||
<Avatar className="w-10 h-10 flex-shrink-0 mt-1">
|
aria-label="Search messages"
|
||||||
<AvatarFallback className={`${getAvatarColor(note?.author?.name || 'System')} text-white`}>
|
/>
|
||||||
{getInitials(note?.author?.name || 'S')}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
|
|
||||||
{/* Message Content */}
|
|
||||||
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
|
|
||||||
<div className={`flex items-center gap-2 mb-1 px-1 ${isMe ? 'flex-row-reverse text-right' : 'text-left'}`}>
|
|
||||||
<span className="text-slate-900 font-medium text-sm">{isMe ? 'You' : (note?.author?.name || 'Unknown')}</span>
|
|
||||||
<span className="text-slate-400 text-[10px] uppercase">
|
|
||||||
{(note?.author?.role && note.author.role !== '0' && note.author.role !== '') ? `(${note.author.role})` : ''}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-400 text-[10px]">
|
|
||||||
{note.createdAt ? new Date(note.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={`rounded-2xl border px-4 py-2.5 shadow-sm relative ${isMe
|
|
||||||
? 'bg-blue-50 border-blue-100 text-slate-800 rounded-tr-none'
|
|
||||||
: 'bg-white border-slate-200 text-slate-700 rounded-tl-none'
|
|
||||||
}`}>
|
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
|
||||||
{renderMessageWithMentions(note.noteText)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{note.attachments && note.attachments.length > 0 && (
|
|
||||||
<div className="mt-2 space-y-2 border-t border-slate-100 pt-2">
|
|
||||||
{note.attachments.map(file => {
|
|
||||||
const isImage = file.mimeType.startsWith('image/');
|
|
||||||
return (
|
|
||||||
<div key={file.id} className="flex items-center gap-2">
|
|
||||||
{isImage ? (
|
|
||||||
<div className="rounded-lg overflow-hidden border border-slate-100 max-w-[200px]">
|
|
||||||
<img
|
|
||||||
src={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
|
|
||||||
alt={file.fileName}
|
|
||||||
className="w-full h-auto cursor-pointer"
|
|
||||||
onClick={() => setPreviewFile(file)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : file.mimeType === 'application/pdf' ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setPreviewFile(file)}
|
|
||||||
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-3 h-3" />
|
|
||||||
{file.fileName} (Preview)
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<a
|
|
||||||
href={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
|
||||||
>
|
|
||||||
<Paperclip className="w-3 h-3" />
|
|
||||||
{file.fileName}
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})}
|
{displayNotes.map((note) => {
|
||||||
|
if (isActivityLogNote(note)) {
|
||||||
|
const who = note.author?.name || 'System';
|
||||||
|
const role =
|
||||||
|
note.author?.role && note.author.role !== '0' && note.author.role !== ''
|
||||||
|
? note.author.role
|
||||||
|
: '';
|
||||||
|
return (
|
||||||
|
<div key={note.id} className="flex w-full justify-center px-1 py-1 sm:px-2">
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
title={activityLogBadgeLabel(note)}
|
||||||
|
className="flex w-full max-w-[min(100%,42rem)] items-start gap-2.5 rounded-xl border border-slate-200/90 bg-slate-100/80 px-3 py-2.5 shadow-sm"
|
||||||
|
>
|
||||||
|
<Activity
|
||||||
|
className="mt-0.5 h-4 w-4 shrink-0 text-purple-600"
|
||||||
|
strokeWidth={2.5}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 pt-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
|
||||||
|
{activityLogPillCategoryUpper(note)}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 text-left">
|
||||||
|
<p className="text-sm leading-snug text-slate-800">
|
||||||
|
<span className="font-semibold text-slate-900">{who}</span>
|
||||||
|
{role ? (
|
||||||
|
<span className="text-xs font-normal text-slate-500"> · {role}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="font-normal text-slate-600"> — </span>
|
||||||
|
<span className="font-normal text-slate-700">{renderMessageWithMentions(note.noteText)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<time
|
||||||
|
className="shrink-0 whitespace-nowrap pt-0.5 text-right text-[10px] leading-tight text-slate-400 tabular-nums sm:text-[11px]"
|
||||||
|
dateTime={note.createdAt}
|
||||||
|
>
|
||||||
|
{formatNoteTimestamp(note.createdAt)}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMe =
|
||||||
|
(note?.author?.email &&
|
||||||
|
currentUser?.email &&
|
||||||
|
note.author.email.toLowerCase() === currentUser.email.toLowerCase()) ||
|
||||||
|
(note?.userId && currentUser?.id && String(note.userId) === String(currentUser.id)) ||
|
||||||
|
note.id.startsWith('temp-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={note.id} className={`flex w-full ${isMe ? 'justify-end' : 'justify-start'}`}>
|
||||||
|
<div className={`flex gap-3 max-w-[min(85%,36rem)] ${isMe ? 'flex-row-reverse' : ''}`}>
|
||||||
|
<Avatar className="w-10 h-10 flex-shrink-0 mt-1">
|
||||||
|
<AvatarFallback className={`${getAvatarColor(note?.author?.name || 'System')} text-white`}>
|
||||||
|
{getInitials(note?.author?.name || 'S')}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
<div className={`flex flex-col min-w-0 ${isMe ? 'items-end' : 'items-start'}`}>
|
||||||
|
<div
|
||||||
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 mb-1 px-1 ${
|
||||||
|
isMe ? 'flex-row-reverse text-right' : 'text-left'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-slate-900 font-medium text-sm">
|
||||||
|
{isMe ? 'You' : note?.author?.name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 text-[10px] uppercase tracking-wide">
|
||||||
|
{note?.author?.role && note.author.role !== '0' && note.author.role !== ''
|
||||||
|
? `(${note.author.role})`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-400 text-[10px] tabular-nums">
|
||||||
|
{formatNoteTimestamp(note.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`rounded-2xl border px-4 py-2.5 shadow-sm relative text-left ${
|
||||||
|
isMe
|
||||||
|
? 'bg-blue-50 border-blue-100 text-slate-800 rounded-tr-none'
|
||||||
|
: 'bg-white border-slate-200 text-slate-700 rounded-tl-none'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
|
||||||
|
{renderMessageWithMentions(note.noteText)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{note.attachments && note.attachments.length > 0 && (
|
||||||
|
<div className="mt-2 space-y-2 border-t border-slate-100 pt-2">
|
||||||
|
{note.attachments.map((file) => {
|
||||||
|
const isImage = file.mimeType.startsWith('image/');
|
||||||
|
return (
|
||||||
|
<div key={file.id} className="flex items-center gap-2">
|
||||||
|
{isImage ? (
|
||||||
|
<div className="rounded-lg overflow-hidden border border-slate-100 max-w-[200px]">
|
||||||
|
<img
|
||||||
|
src={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
|
||||||
|
alt={file.fileName}
|
||||||
|
className="w-full h-auto cursor-pointer"
|
||||||
|
onClick={() => setPreviewFile(file)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : file.mimeType === 'application/pdf' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPreviewFile(file)}
|
||||||
|
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{file.fileName} (Preview)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
<Paperclip className="w-3 h-3" />
|
||||||
|
{file.fileName}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{notes.length === 0 && !isLoading && (
|
{notes.length === 0 && !isLoading && (
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
@ -670,6 +802,12 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{notes.length > 0 && displayNotes.length === 0 && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center text-slate-500 text-sm">
|
||||||
|
No messages match "{messageSearch.trim()}". Clear the search box to see the full thread.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<span className="text-slate-500">Loading notes...</span>
|
<span className="text-slate-500">Loading notes...</span>
|
||||||
@ -830,7 +968,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Sidebar - Participants */}
|
{/* Right Sidebar - Participants */}
|
||||||
{isSidebarOpen && (
|
{isSidebarOpen && (
|
||||||
|
|||||||
@ -152,7 +152,8 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
|||||||
placeholder="Enter your password"
|
placeholder="Enter your password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pr-10"
|
className="no-native-password-reveal w-full pr-10"
|
||||||
|
autoComplete="current-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -13,9 +13,11 @@ import { User as UserType } from '../../lib/mock-data';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { dealerService } from '../../services/dealer.service';
|
import { dealerService } from '../../services/dealer.service';
|
||||||
import { formatDateTime } from '../ui/utils';
|
import { formatDateTime } from '../ui/utils';
|
||||||
|
import { API } from '../../api/API';
|
||||||
|
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
||||||
|
|
||||||
interface DealerConstitutionalChangePageProps {
|
interface DealerConstitutionalChangePageProps {
|
||||||
currentUser: UserType | null;
|
currentUser?: UserType | null;
|
||||||
onViewDetails?: (id: string) => void;
|
onViewDetails?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,9 +28,7 @@ const getStatusColor = (status: string) => {
|
|||||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
};
|
};
|
||||||
|
|
||||||
const constitutionTypes = ['Proprietorship', 'Partnership', 'LLP', 'Pvt Ltd'];
|
export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitutionalChangePageProps) {
|
||||||
|
|
||||||
export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: DealerConstitutionalChangePageProps) {
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [currentConstitution, setCurrentConstitution] = useState('');
|
const [currentConstitution, setCurrentConstitution] = useState('');
|
||||||
const [proposedConstitution, setProposedConstitution] = useState('');
|
const [proposedConstitution, setProposedConstitution] = useState('');
|
||||||
@ -40,6 +40,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [profile, setProfile] = useState<any>(null);
|
const [profile, setProfile] = useState<any>(null);
|
||||||
|
const [structureTargets, setStructureTargets] = useState<{ value: string; label: string }[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
fetchData();
|
||||||
@ -48,12 +49,19 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const dashboard = await dealerService.getDashboardData();
|
const [dashboard, constitutionalRes, metaRes] = await Promise.all([
|
||||||
const constitutionalRes = await dealerService.getConstitutionalChanges();
|
dealerService.getDashboardData(),
|
||||||
|
dealerService.getConstitutionalChanges(),
|
||||||
|
API.getConstitutionalChangeMeta() as any
|
||||||
|
]);
|
||||||
|
|
||||||
setProfile(dashboard.profile);
|
setProfile(dashboard.profile);
|
||||||
setCurrentConstitution(dashboard.profile?.constitutionType || 'Proprietorship');
|
const normalizedCurrent = normalizeDealerProfileConstitution(dashboard.profile?.constitutionType);
|
||||||
|
setCurrentConstitution(normalizedCurrent);
|
||||||
setRequests(constitutionalRes.requests || []);
|
setRequests(constitutionalRes.requests || []);
|
||||||
|
if (metaRes.data?.success && Array.isArray(metaRes.data.structureTargets)) {
|
||||||
|
setStructureTargets(metaRes.data.structureTargets);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch constitutional data error:', error);
|
console.error('Fetch constitutional data error:', error);
|
||||||
toast.error('Failed to load requests');
|
toast.error('Failed to load requests');
|
||||||
@ -85,7 +93,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
|||||||
const payload = {
|
const payload = {
|
||||||
currentConstitution,
|
currentConstitution,
|
||||||
changeType: proposedConstitution,
|
changeType: proposedConstitution,
|
||||||
reason,
|
reason: reason.trim(),
|
||||||
newPartnersDetails: newPartners,
|
newPartnersDetails: newPartners,
|
||||||
shareholdingPattern
|
shareholdingPattern
|
||||||
};
|
};
|
||||||
@ -100,9 +108,10 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
|||||||
setReason('');
|
setReason('');
|
||||||
setNewPartners('');
|
setNewPartners('');
|
||||||
setShareholdingPattern('');
|
setShareholdingPattern('');
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('Submit constitutional change error:', error);
|
console.error('Submit constitutional change error:', error);
|
||||||
toast.error('Failed to submit constitutional change request');
|
const msg = error?.response?.data?.message || 'Failed to submit constitutional change request';
|
||||||
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -202,10 +211,12 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
|||||||
<SelectValue placeholder="Select new constitution" />
|
<SelectValue placeholder="Select new constitution" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{constitutionTypes
|
{structureTargets
|
||||||
.filter(type => type !== currentConstitution)
|
.filter((o) => o.value !== currentConstitution)
|
||||||
.map(type => (
|
.map((o) => (
|
||||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@ -240,7 +251,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Shareholding Pattern */}
|
{/* Shareholding Pattern */}
|
||||||
{(proposedConstitution === 'Pvt Ltd' || proposedConstitution === 'LLP') && (
|
{(proposedConstitution === 'Private Limited' || proposedConstitution === 'LLP') && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="shareholdingPattern">Proposed Shareholding Pattern</Label>
|
<Label htmlFor="shareholdingPattern">Proposed Shareholding Pattern</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@ -23,10 +23,13 @@ interface DealerRelocationPageProps {
|
|||||||
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.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';
|
||||||
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
|
if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
|
||||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getApiErrorMessage = (error: any, fallback: string) =>
|
||||||
|
error?.response?.data?.message || error?.data?.message || error?.message || fallback;
|
||||||
|
|
||||||
export function DealerRelocationPage({ currentUser, onViewDetails }: DealerRelocationPageProps) {
|
export function DealerRelocationPage({ currentUser, onViewDetails }: DealerRelocationPageProps) {
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [selectedOutlet, setSelectedOutlet] = useState<any | null>(null);
|
const [selectedOutlet, setSelectedOutlet] = useState<any | null>(null);
|
||||||
@ -69,7 +72,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
|
|||||||
setRequests(relocationRes.requests || []);
|
setRequests(relocationRes.requests || []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch relocation data error:', error);
|
console.error('Fetch relocation data error:', error);
|
||||||
toast.error('Failed to load outlets and requests');
|
toast.error(getApiErrorMessage(error, 'Failed to load outlets and requests'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -90,6 +93,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
|
|||||||
setDistricts(districtsData);
|
setDistricts(districtsData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch master data error:', error);
|
console.error('Fetch master data error:', error);
|
||||||
|
toast.error(getApiErrorMessage(error, 'Failed to load master data'));
|
||||||
} finally {
|
} finally {
|
||||||
setMasterDataLoading(false);
|
setMasterDataLoading(false);
|
||||||
}
|
}
|
||||||
@ -165,7 +169,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
|
|||||||
setReason('');
|
setReason('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Submit relocation error:', error);
|
console.error('Submit relocation error:', error);
|
||||||
toast.error('Failed to submit relocation request');
|
toast.error(getApiErrorMessage(error, 'Failed to submit relocation request'));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
61
src/lib/constitutional-change.ts
Normal file
61
src/lib/constitutional-change.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Keep in sync with Dealer_Onboarding_Backend/src/common/utils/constitutionalNormalize.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRIVATE_LIMITED = 'Private Limited';
|
||||||
|
const LLP = 'LLP';
|
||||||
|
const LLP_CONVERSION = 'LLP Conversion';
|
||||||
|
const PARTNERSHIP = 'Partnership';
|
||||||
|
const PARTNERSHIP_CHANGE = 'Partnership Change';
|
||||||
|
const PROPRIETORSHIP = 'Proprietorship';
|
||||||
|
const DIRECTOR_CHANGE = 'Director Change';
|
||||||
|
const OWNERSHIP_TRANSFER = 'Ownership Transfer';
|
||||||
|
const COMPANY_FORMATION = 'Company Formation';
|
||||||
|
|
||||||
|
const ALL: string[] = [
|
||||||
|
PROPRIETORSHIP,
|
||||||
|
PARTNERSHIP,
|
||||||
|
LLP_CONVERSION,
|
||||||
|
LLP,
|
||||||
|
PRIVATE_LIMITED,
|
||||||
|
COMPANY_FORMATION,
|
||||||
|
OWNERSHIP_TRANSFER,
|
||||||
|
PARTNERSHIP_CHANGE,
|
||||||
|
DIRECTOR_CHANGE
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isRegisteredConstitutionalChangeType(value: string): boolean {
|
||||||
|
return ALL.includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeToConstitutionalChangeType(raw: string | null | undefined): string | null {
|
||||||
|
const s = String(raw || '').trim();
|
||||||
|
if (!s) return null;
|
||||||
|
if (isRegisteredConstitutionalChangeType(s)) return s;
|
||||||
|
const compact = s
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\./g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
if (
|
||||||
|
(compact.includes('private') && (compact.includes('ltd') || compact.includes('limited'))) ||
|
||||||
|
compact === 'pvt ltd' ||
|
||||||
|
compact === 'pvtltd'
|
||||||
|
) {
|
||||||
|
return PRIVATE_LIMITED;
|
||||||
|
}
|
||||||
|
if (compact.includes('llp') && compact.includes('conversion')) return LLP_CONVERSION;
|
||||||
|
if (compact.includes('llp')) return LLP;
|
||||||
|
if (compact.includes('partnership') && compact.includes('change')) return PARTNERSHIP_CHANGE;
|
||||||
|
if (compact.includes('partnership')) return PARTNERSHIP;
|
||||||
|
if (compact.includes('proprietorship') || compact === 'sole proprietorship') return PROPRIETORSHIP;
|
||||||
|
if (compact.includes('director')) return DIRECTOR_CHANGE;
|
||||||
|
if (compact.includes('ownership') && compact.includes('transfer')) return OWNERSHIP_TRANSFER;
|
||||||
|
if (compact.includes('company') && compact.includes('formation')) return COMPANY_FORMATION;
|
||||||
|
const exact = ALL.find((v) => v.toLowerCase() === s.toLowerCase());
|
||||||
|
return exact || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDealerProfileConstitution(raw: string | null | undefined): string {
|
||||||
|
return normalizeToConstitutionalChangeType(raw) || PROPRIETORSHIP;
|
||||||
|
}
|
||||||
@ -209,4 +209,59 @@ html {
|
|||||||
.custom-scrollbar {
|
.custom-scrollbar {
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
scrollbar-color: #e2e8f0 transparent;
|
scrollbar-color: #e2e8f0 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thin, light horizontal scrollbar (e.g. tab strips with overflow-x) */
|
||||||
|
.custom-scrollbar-x::-webkit-scrollbar {
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-x::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-x::-webkit-scrollbar-thumb {
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-x::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-x {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #e2e8f0 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Extra-thin, light vertical scrollbar (e.g. modals) */
|
||||||
|
.custom-scrollbar-slim::-webkit-scrollbar {
|
||||||
|
width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-slim::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-slim::-webkit-scrollbar-thumb {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-slim::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar-slim {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #f1f5f9 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Password fields with a custom show/hide toggle: hide native reveal (Edge/IE + Chromium). */
|
||||||
|
.no-native-password-reveal::-ms-reveal {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-native-password-reveal::-ms-clear {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user