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}`),
|
||||
|
||||
// Dealer & Outlets
|
||||
getDealers: () => client.get('/dealer'),
|
||||
getDealers: (params?: { onboarded?: string }) => client.get('/dealer', { params }),
|
||||
createDealer: (data: any) => client.post('/dealer', data),
|
||||
getDealerById: (id: string) => client.get(`/dealer/${id}`),
|
||||
updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data),
|
||||
@ -180,8 +180,11 @@ export const API = {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
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'),
|
||||
getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'),
|
||||
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
|
||||
createConstitutionalChange: (data: any) => client.post('/constitutional-change', 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 = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
@ -442,7 +462,7 @@ export const ApplicationDetails = () => {
|
||||
const fetchAuditLogs = async () => {
|
||||
setAuditLoading(true);
|
||||
try {
|
||||
const logs = await auditService.getAuditLogs('application', application.id);
|
||||
const logs = await auditService.getAuditLogs('application', application.id, 1, 100);
|
||||
setAuditLogs(Array.isArray(logs) ? logs : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch audit logs', error);
|
||||
@ -730,6 +750,7 @@ export const ApplicationDetails = () => {
|
||||
|
||||
// Reset form
|
||||
setKtMatrixScores({});
|
||||
setKtMatrixSelectedValues({});
|
||||
setKtMatrixRemarks('');
|
||||
await fetchInterviews();
|
||||
await fetchApplication(); // Refresh application status and progress
|
||||
@ -1205,7 +1226,28 @@ export const ApplicationDetails = () => {
|
||||
{
|
||||
id: 8,
|
||||
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,
|
||||
description: 'Letter of Intent approval',
|
||||
evaluators: Array.from(new Set((application.participants || [])
|
||||
@ -1217,7 +1259,26 @@ export const ApplicationDetails = () => {
|
||||
{
|
||||
id: 9,
|
||||
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,
|
||||
description: 'Security verification',
|
||||
documentsUploaded: 3
|
||||
@ -1225,10 +1286,27 @@ export const ApplicationDetails = () => {
|
||||
{
|
||||
id: 10,
|
||||
name: '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' :
|
||||
application.status === 'LOI Issued' ? 'active' : 'pending'
|
||||
),
|
||||
status: getStageStatus('LOI Issue', () => {
|
||||
if (
|
||||
[
|
||||
'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,
|
||||
description: 'Letter of Intent issued',
|
||||
documentsUploaded: 1
|
||||
@ -1446,6 +1524,7 @@ export const ApplicationDetails = () => {
|
||||
case 'FDD Verification':
|
||||
newStatus = 'LOI In Progress'; break;
|
||||
case 'LOI In Progress':
|
||||
newStatus = 'Security Details'; break;
|
||||
case 'Security Details':
|
||||
case 'Payment Pending':
|
||||
newStatus = 'LOI Issued'; break;
|
||||
@ -1773,7 +1852,14 @@ export const ApplicationDetails = () => {
|
||||
// Centralized Permissions Utility (Consolidates 500 lines of fragmented logic)
|
||||
const getApplicationPermissions = () => {
|
||||
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
|
||||
@ -2491,7 +2577,7 @@ export const ApplicationDetails = () => {
|
||||
<Card>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<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">
|
||||
<TabsTrigger value="questionnaire" className="min-w-[120px]">Questionnaire</TabsTrigger>
|
||||
<TabsTrigger value="progress" className="min-w-[80px]">Progress</TabsTrigger>
|
||||
@ -3400,41 +3486,63 @@ export const ApplicationDetails = () => {
|
||||
|
||||
{/* Audit Trail Tab */}
|
||||
<TabsContent value="audit">
|
||||
<ScrollArea className="h-96">
|
||||
<div className="space-y-4">
|
||||
<ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50">
|
||||
<div className="space-y-2.5 p-3 pr-4">
|
||||
{auditLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div>
|
||||
<span className="ml-2 text-slate-500">Loading audit trail...</span>
|
||||
<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" />
|
||||
<span className="ml-2 text-sm text-slate-500">Loading audit trail…</span>
|
||||
</div>
|
||||
) : 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.
|
||||
</div>
|
||||
) : (
|
||||
auditLogs.map((log: any) => (
|
||||
<div key={log.id} className="flex gap-4 p-3 hover:bg-slate-50 rounded-lg">
|
||||
<div className="w-2 h-2 bg-amber-600 rounded-full mt-2 flex-shrink-0"></div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-slate-900 font-medium">{log.description || log.action}</p>
|
||||
<span className="text-slate-500 text-sm whitespace-nowrap ml-4">
|
||||
{formatDateTime(log.timestamp)}
|
||||
<div
|
||||
key={log.id}
|
||||
className="rounded-lg border border-slate-200/90 bg-white p-3 text-sm shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
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>
|
||||
<p className="text-slate-600 mt-1">by {log.userName || 'System'}</p>
|
||||
{log.remarks && (
|
||||
<p className="mt-2 text-red-600 text-sm font-bold bg-red-50 p-2 rounded border border-red-100 italic">
|
||||
"{log.remarks}"
|
||||
<time
|
||||
className="shrink-0 text-xs tabular-nums text-slate-400"
|
||||
dateTime={log.timestamp}
|
||||
>
|
||||
{formatDateTime(log.timestamp)}
|
||||
</time>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-slate-800">
|
||||
{log.description || '—'}
|
||||
</p>
|
||||
)}
|
||||
{log.changes && log.changes.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{log.changes.map((change: string, idx: number) => (
|
||||
<p key={idx} className="text-slate-500 text-sm">{change}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<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>
|
||||
))
|
||||
@ -3509,9 +3617,40 @@ export const ApplicationDetails = () => {
|
||||
{permissions.isLoaLocked && (
|
||||
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800">
|
||||
<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">
|
||||
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>
|
||||
</Alert>
|
||||
)}
|
||||
@ -3537,7 +3676,6 @@ export const ApplicationDetails = () => {
|
||||
)}
|
||||
|
||||
{permissions.canApprove && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700 font-bold"
|
||||
onClick={() => setShowApproveModal(true)}
|
||||
@ -3545,7 +3683,9 @@ export const ApplicationDetails = () => {
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
{['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permissions.canReject && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="w-full font-bold"
|
||||
@ -3554,7 +3694,6 @@ export const ApplicationDetails = () => {
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{permissions.showDecisionMessage && (
|
||||
@ -4187,120 +4326,83 @@ export const ApplicationDetails = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* KT Matrix Modal */}
|
||||
{/* KT Matrix — Level 1 */}
|
||||
<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">
|
||||
{/* Ultra-Simple Header */}
|
||||
<div className="px-8 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50 shrink-0">
|
||||
<div>
|
||||
<DialogTitle className="text-base font-bold text-slate-900 leading-tight">KT Matrix Assessment</DialogTitle>
|
||||
<p className="text-slate-400 text-[11px] font-medium tracking-tight">Evaluate technical capability for {application.name}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-xs font-bold text-slate-600 mb-1">{Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} Completed</div>
|
||||
<Progress value={(Object.keys(ktMatrixSelectedValues).length / KT_MATRIX_CRITERIA.length) * 100} className="w-28 h-1.5 bg-slate-100" />
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<DialogHeader className="shrink-0 space-y-2 border-b px-5 py-4 text-left">
|
||||
<DialogTitle className="text-base">KT matrix</DialogTitle>
|
||||
<DialogDescription className="text-sm leading-relaxed">
|
||||
Level 1 interview · {application.name}
|
||||
<span className="mt-1 block text-xs text-muted-foreground">
|
||||
{Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} criteria answered
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto bg-white">
|
||||
<div className="p-8 max-w-3xl mx-auto">
|
||||
{/* Question List - Minimalist Style */}
|
||||
<div className="divide-y divide-slate-100 border-x border-t border-slate-100 rounded-t-xl overflow-hidden shadow-sm">
|
||||
<div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||||
<div className="space-y-6">
|
||||
{KT_MATRIX_CRITERIA.map((criterion, idx) => (
|
||||
<div key={criterion.name} className="p-5 hover:bg-slate-50/50 transition-colors">
|
||||
<div className="flex justify-between items-start gap-4 mb-4">
|
||||
<h4 className="text-sm font-semibold text-slate-800 leading-snug">
|
||||
<span className="text-slate-400 mr-2 tabular-nums">{idx + 1}.</span>
|
||||
{criterion.name}
|
||||
</h4>
|
||||
<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">
|
||||
WT: {criterion.weight}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{criterion.options.map((option) => {
|
||||
const isSelected = ktMatrixSelectedValues[criterion.name] === option.value;
|
||||
return (
|
||||
<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"
|
||||
)}
|
||||
<div key={criterion.name} className="space-y-2">
|
||||
<Label
|
||||
htmlFor={`kt-matrix-${idx}`}
|
||||
className="block text-sm font-medium leading-relaxed text-foreground"
|
||||
>
|
||||
{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>
|
||||
<span className="text-muted-foreground">{idx + 1}.</span> {criterion.name}{' '}
|
||||
<span className="font-normal text-muted-foreground">({criterion.weight}%)</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={ktMatrixSelectedValues[criterion.name] ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
<div className="space-y-2 border-t border-border pt-6">
|
||||
<Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">
|
||||
Notes <span className="font-normal text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder="Record observations, strengths or concerns..."
|
||||
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"
|
||||
id="kt-matrix-remarks"
|
||||
placeholder="Optional remarks…"
|
||||
className="min-h-[96px] resize-y text-sm leading-relaxed"
|
||||
value={ktMatrixRemarks}
|
||||
onChange={(e) => setKtMatrixRemarks(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ultra-Simple Summary Line */}
|
||||
<div className="mt-8 p-6 bg-slate-900 rounded-2xl flex items-center justify-between text-white shadow-xl shadow-slate-200">
|
||||
<div className="space-y-1">
|
||||
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Composite Assessment Score</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn(
|
||||
"w-2.5 h-2.5 rounded-full shadow-sm",
|
||||
Number(calculateKTScore()) >= 60 ? "bg-green-500" :
|
||||
Number(calculateKTScore()) >= 40 ? "bg-amber-500" : "bg-red-500"
|
||||
)} />
|
||||
<span className="text-xs font-bold text-slate-200">
|
||||
{Number(calculateKTScore()) >= 60 ? "Strong Profile" :
|
||||
Number(calculateKTScore()) >= 40 ? "Needs Review" : "Low Alignment"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div className="flex shrink-0 flex-col gap-4 border-t px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Weighted total{' '}
|
||||
<span className="font-semibold tabular-nums text-foreground">{calculateKTScore()}</span>
|
||||
<span className="text-muted-foreground"> / 100</span>
|
||||
</p>
|
||||
<div className="flex gap-2 sm:shrink-0">
|
||||
<Button variant="outline" onClick={() => setShowKTMatrixModal(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<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'}
|
||||
{isSubmittingKT ? 'Saving…' : 'Submit'}
|
||||
</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>
|
||||
</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 { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
|
||||
import { Badge } from '../ui/badge';
|
||||
@ -8,8 +8,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { User as UserType } from '../../lib/mock-data';
|
||||
import { toast } from 'sonner';
|
||||
import { API } from '../../api/API';
|
||||
@ -39,6 +38,7 @@ const workflowStages = [
|
||||
const documentRequirements: Record<string, number[]> = {
|
||||
'Partnership': [1, 2, 3, 4, 8, 9, 10, 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],
|
||||
'Proprietorship': [1, 2, 3, 10, 16]
|
||||
};
|
||||
@ -76,26 +76,57 @@ const getTypeColor = (type: string) => {
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === 'Completed' || status === 'Verified') return 'bg-green-100 text-green-700 border-green-300';
|
||||
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress' || status === 'Submitted') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
|
||||
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
|
||||
const s = String(status || '');
|
||||
if (s === 'Completed' || s === 'Verified' || s === 'APPROVED' || s === 'COMPLETED' || s === 'CREATED' || /^DOCUMENT/i.test(s)) {
|
||||
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';
|
||||
};
|
||||
|
||||
/** 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 input = String(value || '').trim().toLowerCase();
|
||||
if (!input) return '';
|
||||
if (input.includes('proprietor')) return 'Proprietorship';
|
||||
if (input.includes('partner')) return 'Partnership';
|
||||
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;
|
||||
};
|
||||
|
||||
export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) {
|
||||
const navigate = useNavigate();
|
||||
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 [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||
const [selectedDocType, setSelectedDocType] = useState<number | null>(null);
|
||||
@ -107,15 +138,15 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isActionLoading, setIsActionLoading] = 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(() => {
|
||||
fetchRequestDetails();
|
||||
fetchAuditLogs();
|
||||
}, [requestId]);
|
||||
|
||||
const fetchAuditLogs = async () => {
|
||||
const fetchAuditLogs = async (entityId: string) => {
|
||||
if (!entityId) return;
|
||||
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) {
|
||||
setAuditLogs(response.data.data || []);
|
||||
}
|
||||
@ -124,12 +155,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRequestDetails = async () => {
|
||||
const fetchRequestDetails = async (opts?: { silent?: boolean }) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (!opts?.silent) setIsLoading(true);
|
||||
const response = await API.getConstitutionalChangeById(requestId) as any;
|
||||
if (response.data.success) {
|
||||
setRequest(response.data.request);
|
||||
const reqData = response.data.request;
|
||||
setRequest(reqData);
|
||||
await fetchAuditLogs(reqData?.id || requestId);
|
||||
} else {
|
||||
toast.error('Failed to fetch request details');
|
||||
}
|
||||
@ -137,10 +170,28 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
console.error('Fetch request details error:', error);
|
||||
toast.error('Error loading request details');
|
||||
} 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) {
|
||||
return (
|
||||
<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")
|
||||
const normalizedChangeType = normalizeConstitutionType(request.changeType);
|
||||
const requiredDocs = documentRequirements[normalizedChangeType] || [];
|
||||
const uploadedDocNumbers = new Set(
|
||||
(request.documents || [])
|
||||
.map((doc: any) => Number(doc?.docNumber))
|
||||
.filter((num: number) => !Number.isNaN(num) && num > 0)
|
||||
);
|
||||
|
||||
const findUploadedForDocNum = (docNum: number) => {
|
||||
const docs = request.documents || [];
|
||||
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
|
||||
const getCurrentStageIndex = () => {
|
||||
if (request.currentStage === 'Rejected' || request.currentStage === 'Revoked') return -1;
|
||||
const stageMap: Record<string, number> = {
|
||||
'Submitted': 1,
|
||||
'ASM Review': 2,
|
||||
@ -186,6 +249,15 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
};
|
||||
|
||||
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 aliases: Record<string, string[]> = {
|
||||
@ -211,40 +283,70 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
// Centralized Permissions Utility (Fixes security gap where buttons showed for everyone)
|
||||
const getConstitutionalPermissions = () => {
|
||||
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 status = request.status;
|
||||
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);
|
||||
|
||||
// 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.
|
||||
* The next gate is ASM — there is no second “dealer” action on this row. ASM Approve moves to `ASM Review`.
|
||||
*/
|
||||
const atSubmittedDbStage = currentStage === 'Submitted';
|
||||
const isCurrentlyAssigned =
|
||||
currentUser.roleCode === 'SUPER_ADMIN' ||
|
||||
(atSubmittedDbStage && (userRole === 'ASM' || currentUser.roleCode === 'ASM')) ||
|
||||
(!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'))
|
||||
);
|
||||
(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 {
|
||||
canApprove: isCurrentlyAssigned && !isFinalState,
|
||||
canReject: isCurrentlyAssigned && !isFinalState,
|
||||
canHold: isCurrentlyAssigned && !isFinalState,
|
||||
canSendBack: canSendBackOrRevoke,
|
||||
canRevoke: canSendBackOrRevoke,
|
||||
isFinalState
|
||||
};
|
||||
};
|
||||
|
||||
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);
|
||||
setIsActionDialogOpen(true);
|
||||
};
|
||||
@ -252,20 +354,33 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
const handleSubmitAction = async (e: React.FormEvent) => {
|
||||
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 {
|
||||
setIsActionLoading(true);
|
||||
const action = actionType === 'approve' ? 'approve' : actionType === 'reject' ? 'reject' : 'hold';
|
||||
const response = await API.updateConstitutionalChange(requestId, action, {
|
||||
const actionLabel =
|
||||
actionType === 'approve' ? 'Approve' :
|
||||
actionType === 'reject' ? 'Reject' :
|
||||
actionType === 'sendBack' ? 'Send Back' :
|
||||
'Revoke';
|
||||
const response = await API.updateConstitutionalChange(requestId, actionLabel, {
|
||||
comments
|
||||
}) as any;
|
||||
|
||||
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`);
|
||||
setIsActionDialogOpen(false);
|
||||
setComments('');
|
||||
fetchRequestDetails();
|
||||
fetchAuditLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit action error:', error);
|
||||
@ -303,7 +418,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
setIsUploadDialogOpen(false);
|
||||
setSelectedDocType(null);
|
||||
setUploadFile(null);
|
||||
fetchRequestDetails();
|
||||
await fetchRequestDetails({ silent: true });
|
||||
} else {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@ -432,11 +583,11 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</CardContent>
|
||||
@ -474,11 +625,35 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
</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">
|
||||
{workflowStages.map((stage, index) => {
|
||||
const isCompleted = index < currentStageIndex - 1;
|
||||
const isCurrent = index === currentStageIndex - 1;
|
||||
{workflowTerminalNegative ? (
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm text-slate-600">
|
||||
{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 explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks;
|
||||
|
||||
@ -514,7 +689,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
{stage.name}
|
||||
</h4>
|
||||
<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>
|
||||
</div>
|
||||
<Badge className={
|
||||
@ -543,7 +725,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@ -577,26 +760,22 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Document Type</Label>
|
||||
<Select
|
||||
value={selectedDocType ? String(selectedDocType) : ''}
|
||||
onValueChange={(value) => setSelectedDocType(Number(value))}
|
||||
<select
|
||||
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"
|
||||
value={selectedDocType != null ? String(selectedDocType) : ''}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setSelectedDocType(v ? Number(v) : null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full mt-1">
|
||||
<SelectValue placeholder="Select document type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<option value="">Select document type</option>
|
||||
{requiredDocs.map((docNum) => (
|
||||
<SelectItem key={docNum} value={String(docNum)}>
|
||||
<span className="flex w-full items-center justify-between">
|
||||
<span>{documentNames[docNum]}</span>
|
||||
{uploadedDocNumbers.has(docNum) && (
|
||||
<CheckCircle2 className="w-4 h-4 text-green-600" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<option key={docNum} value={String(docNum)}>
|
||||
{isDocTypeUploaded(docNum) ? '✅ ' : ''}
|
||||
{documentNames[docNum]}
|
||||
</option>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Upload File</Label>
|
||||
@ -620,31 +799,39 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{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 (
|
||||
<div
|
||||
key={docNum}
|
||||
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">
|
||||
{uploaded ? (
|
||||
{isRejected ? (
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
) : ok ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
<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]}
|
||||
</p>
|
||||
{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>
|
||||
{uploaded ? (
|
||||
<Badge className="bg-green-100 text-green-700 border-green-300">
|
||||
<Badge className={getStatusColor(uploaded.status)}>
|
||||
{uploaded.status}
|
||||
</Badge>
|
||||
) : (
|
||||
@ -704,7 +891,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
<Button size="sm" variant="outline" className="h-8 w-8 p-0" title="Download document">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
{doc.status !== 'Verified' && currentUser?.role !== 'Dealer' && (
|
||||
{doc.status !== 'Verified' && doc.status !== 'Rejected' && currentUser?.role !== 'Dealer' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
@ -712,6 +900,19 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
>
|
||||
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>
|
||||
</TableCell>
|
||||
@ -733,17 +934,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
{/* History Tab */}
|
||||
<TabsContent value="history" className="mt-0">
|
||||
<div className="space-y-4">
|
||||
{auditLogs.map((entry: any, index: number) => (
|
||||
<div key={index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0">
|
||||
{historyEntries.map((entry: any, index: number) => {
|
||||
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 ${
|
||||
(entry.action)?.toLowerCase().includes('approve') || (entry.action)?.toLowerCase().includes('complete') ? 'bg-green-100' :
|
||||
(entry.action)?.toLowerCase().includes('pending') || (entry.action)?.toLowerCase().includes('progress') || (entry.action)?.toLowerCase().includes('update') ? 'bg-amber-100' :
|
||||
pres.variant === 'success' ? 'bg-green-100' :
|
||||
pres.variant === 'danger' ? 'bg-red-100' :
|
||||
pres.variant === 'pending' ? 'bg-amber-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" />
|
||||
) : (
|
||||
) : 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-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
@ -752,16 +960,17 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
<h4 className="text-slate-900">{entry.stage || entry.action}</h4>
|
||||
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
|
||||
</div>
|
||||
<Badge className={getStatusColor(entry.action)}>
|
||||
{entry.action}
|
||||
<Badge className={getStatusColor(pres.badge)}>
|
||||
{pres.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
<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>
|
||||
))}
|
||||
{auditLogs.length === 0 && (
|
||||
);
|
||||
})}
|
||||
{historyEntries.length === 0 && (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
No history found
|
||||
</div>
|
||||
@ -821,11 +1030,43 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
) : (
|
||||
<AlertCircle className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
Reject Request
|
||||
Reject proposal
|
||||
</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">
|
||||
<p className="text-slate-500 text-xs px-4">
|
||||
{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">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-blue- blue-700 hover:bg-blue-50"
|
||||
onClick={() => navigate(`/worknotes/constitutional-change/${requestId}`, {
|
||||
className="w-full border-blue-700 text-blue-800 hover:bg-blue-50"
|
||||
onClick={() => navigate(`/worknotes/constitutional/${request?.id || requestId}`, {
|
||||
state: {
|
||||
requestType: 'constitutional',
|
||||
applicationName: request?.outlet?.name || 'Constitutional Change',
|
||||
registrationNumber: requestId || '',
|
||||
registrationNumber: request?.requestId || requestId || '',
|
||||
participants: request?.participants || []
|
||||
}
|
||||
})}
|
||||
@ -859,25 +1101,30 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{actionType === 'approve' ? 'Approve Request' :
|
||||
actionType === 'reject' ? 'Reject Request' :
|
||||
'Put Request on Hold'}
|
||||
{actionType === 'approve' ? 'Approve request' :
|
||||
actionType === 'reject' ? 'Reject proposal' :
|
||||
actionType === 'sendBack' ? 'Send back to previous stage' :
|
||||
'Revoke request'}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmitAction} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="comments">Comments *</Label>
|
||||
<Label htmlFor="comments">
|
||||
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="comments"
|
||||
value={comments}
|
||||
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}
|
||||
required
|
||||
required={actionType !== 'approve'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -894,7 +1141,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
className={
|
||||
actionType === 'approve' ? 'bg-green-600 hover:bg-green-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}
|
||||
>
|
||||
@ -906,7 +1154,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
) : (
|
||||
actionType === 'approve' ? 'Approve' :
|
||||
actionType === 'reject' ? 'Reject' :
|
||||
'Put on Hold'
|
||||
actionType === 'sendBack' ? 'Send back' :
|
||||
'Revoke'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@ -914,6 +1163,40 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,7 +5,6 @@ import { Button } from '../ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
@ -14,17 +13,18 @@ import { User as UserType } from '../../lib/mock-data';
|
||||
import { toast } from 'sonner';
|
||||
import { API } from '../../api/API';
|
||||
import { formatDateTime } from '../ui/utils';
|
||||
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
||||
|
||||
interface ConstitutionalChangePageProps {
|
||||
currentUser: UserType | null;
|
||||
currentUser?: UserType | null;
|
||||
onViewDetails: (id: string) => void;
|
||||
}
|
||||
|
||||
// Document requirements mapping
|
||||
// Document requirements mapping (keys = DB `changeType` ENUM values)
|
||||
const documentRequirements: Record<string, number[]> = {
|
||||
'Partnership': [1, 2, 3, 4, 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]
|
||||
};
|
||||
|
||||
@ -60,15 +60,24 @@ const getTypeColor = (type: string) => {
|
||||
switch(type) {
|
||||
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 'LLP': return 'bg-indigo-100 text-indigo-700 border-indigo-300';
|
||||
case 'Pvt Ltd': return 'bg-cyan-100 text-cyan-700 border-cyan-300';
|
||||
case 'LLP':
|
||||
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';
|
||||
}
|
||||
};
|
||||
|
||||
export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChangePageProps) {
|
||||
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 [targetType, setTargetType] = useState('');
|
||||
const [reason, setReason] = useState('');
|
||||
@ -76,11 +85,43 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
const [requests, setRequests] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [dialogDataLoading, setDialogDataLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
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 () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
@ -96,33 +137,39 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
}
|
||||
};
|
||||
|
||||
const handleDealerCodeChange = async (code: string) => {
|
||||
setDealerCode(code);
|
||||
if (code.length >= 5) {
|
||||
try {
|
||||
const response = await API.getOutletByCode(code) as any;
|
||||
if (response.data.success && response.data.outlet) {
|
||||
const outlet = response.data.outlet;
|
||||
const handleDealerUserSelect = (dealerUserId: string) => {
|
||||
setSelectedDealerUserId(dealerUserId);
|
||||
setSelectedOutletId('');
|
||||
setTargetType('');
|
||||
setRequiredDocs([]);
|
||||
if (!dealerUserId) {
|
||||
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({
|
||||
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'
|
||||
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: '—'
|
||||
});
|
||||
toast.success('Dealer details loaded successfully');
|
||||
} else {
|
||||
setDealerData(null);
|
||||
}
|
||||
} catch (error) {
|
||||
setDealerData(null);
|
||||
}
|
||||
} else {
|
||||
setDealerData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetTypeChange = (type: string) => {
|
||||
@ -133,13 +180,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
const handleSubmitRequest = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!dealerData) {
|
||||
toast.error('Please enter a valid dealer code');
|
||||
if (!dealerData?.dealerUserId) {
|
||||
toast.error('Please select a dealer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (outletsForDealer.length > 1 && !selectedOutletId) {
|
||||
toast.error('Please select an outlet for this dealer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetType) {
|
||||
toast.error('Please select target dealership type');
|
||||
toast.error('Please select proposed constitution type');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -149,17 +201,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const payload = {
|
||||
outletId: dealerData.id,
|
||||
forDealerUserId: dealerData.dealerUserId,
|
||||
outletId: selectedOutletId || undefined,
|
||||
changeType: targetType,
|
||||
description: reason,
|
||||
newEntityDetails: {}
|
||||
reason: reason.trim(),
|
||||
currentConstitution: dealerData.currentType
|
||||
};
|
||||
|
||||
const response = await API.createConstitutionalChange(payload) as any;
|
||||
@ -169,15 +222,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
fetchRequests();
|
||||
|
||||
// Reset form
|
||||
setDealerCode('');
|
||||
setSelectedDealerUserId('');
|
||||
setSelectedOutletId('');
|
||||
setOutletsForDealer([]);
|
||||
setDealerData(null);
|
||||
setTargetType('');
|
||||
setReason('');
|
||||
setRequiredDocs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
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 {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@ -245,33 +301,77 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmitRequest} className="space-y-4">
|
||||
{/* Dealer Code */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dealerCode">Dealer Code *</Label>
|
||||
<Input
|
||||
id="dealerCode"
|
||||
placeholder="Enter dealer code (e.g., DL-MH-001)"
|
||||
value={dealerCode}
|
||||
onChange={(e) => handleDealerCodeChange(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{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">
|
||||
<Label htmlFor="dealerUser">Dealer *</Label>
|
||||
<Select
|
||||
value={selectedDealerUserId}
|
||||
onValueChange={handleDealerUserSelect}
|
||||
disabled={dialogDataLoading}
|
||||
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>
|
||||
|
||||
{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 */}
|
||||
{dealerData && (
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="text-slate-900">Dealer Details</h3>
|
||||
<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>
|
||||
<span className="text-slate-600">Dealer Name:</span>
|
||||
<p className="text-slate-900">{dealerData.dealerName}</p>
|
||||
</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>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-600">Location:</span>
|
||||
<span className="text-slate-600">Address:</span>
|
||||
<p className="text-slate-900">{dealerData.address}</p>
|
||||
</div>
|
||||
<div>
|
||||
@ -279,31 +379,30 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
<p className="text-slate-900">{dealerData.gst}</p>
|
||||
</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)}>
|
||||
{dealerData.currentType}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-600">Region/Zone:</span>
|
||||
<p className="text-slate-900">{dealerData.region} / {dealerData.zone}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Target Dealership Type */}
|
||||
{/* Proposed constitution — ENUM-aligned options from API */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="targetType">Target Dealership Type *</Label>
|
||||
<Label htmlFor="targetType">Proposed constitution *</Label>
|
||||
<Select value={targetType} onValueChange={handleTargetTypeChange} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select target dealership type" />
|
||||
<SelectValue placeholder="Select proposed constitution" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Proprietorship">Proprietorship</SelectItem>
|
||||
<SelectItem value="Partnership">Partnership</SelectItem>
|
||||
<SelectItem value="LLP">LLP (Limited Liability Partnership)</SelectItem>
|
||||
<SelectItem value="Pvt Ltd">Pvt Ltd (Private Limited)</SelectItem>
|
||||
{structureTargets
|
||||
.filter((opt) => opt.value !== dealerData?.currentType)
|
||||
.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{dealerData && targetType && dealerData.currentType === targetType && (
|
||||
@ -350,7 +449,14 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
<Button
|
||||
type="submit"
|
||||
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 ? (
|
||||
<>
|
||||
@ -444,8 +550,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}>
|
||||
{request.outlet?.type || 'Proprietorship'}
|
||||
<Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
|
||||
{request.currentConstitution || '—'}
|
||||
</Badge>
|
||||
<ArrowRight className="w-4 h-4 text-slate-400" />
|
||||
<Badge className={getTypeColor(request.changeType)}>
|
||||
@ -522,8 +628,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}>
|
||||
{request.outlet?.type || 'Proprietorship'}
|
||||
<Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
|
||||
{request.currentConstitution || '—'}
|
||||
</Badge>
|
||||
<ArrowRight className="w-4 h-4 text-slate-400" />
|
||||
<Badge className={getTypeColor(request.changeType)}>
|
||||
@ -597,8 +703,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}>
|
||||
{request.outlet?.type || 'Proprietorship'}
|
||||
<Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
|
||||
{request.currentConstitution || '—'}
|
||||
</Badge>
|
||||
<ArrowRight className="w-4 h-4 text-slate-400" />
|
||||
<Badge className={getTypeColor(request.changeType)}>
|
||||
@ -677,8 +783,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}>
|
||||
{request.outlet?.type || 'Proprietorship'}
|
||||
<Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
|
||||
{request.currentConstitution || '—'}
|
||||
</Badge>
|
||||
<ArrowRight className="w-4 h-4 text-slate-400" />
|
||||
<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 { formatDateTime } from '../ui/utils';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
|
||||
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' }
|
||||
];
|
||||
|
||||
/** 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
|
||||
const requiredDocuments = [
|
||||
'Property documents for new location',
|
||||
@ -55,11 +131,27 @@ const requiredDocuments = [
|
||||
const getStatusColor = (status: string) => {
|
||||
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('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';
|
||||
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) {
|
||||
const navigate = useNavigate();
|
||||
const [request, setRequest] = useState<any>(null);
|
||||
@ -67,7 +159,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = 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 [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
|
||||
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 {
|
||||
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) {
|
||||
setEorChecklist(response.data.data);
|
||||
}
|
||||
@ -125,7 +218,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
}
|
||||
} catch (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) {
|
||||
console.error('Submit EOR audit error:', error);
|
||||
toast.error('Failed to submit EOR audit');
|
||||
toast.error(getApiErrorMessage(error, 'Failed to submit EOR audit'));
|
||||
} finally {
|
||||
setIsSubmittingEor(false);
|
||||
}
|
||||
@ -155,33 +248,94 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
if (!isSilent) setIsLoading(true);
|
||||
const response = await API.getRelocationRequestById(requestId) as any;
|
||||
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 = response.data.request.currentStage;
|
||||
if (currentStage === 'NBH_CLEARANCE_EOR' || currentStage === 'NBH Clearance with EOR' || response.data.request.status === 'Completed') {
|
||||
fetchEorChecklist();
|
||||
const currentStage = req.currentStage;
|
||||
if (
|
||||
currentStage === 'NBH_CLEARANCE_EOR' ||
|
||||
currentStage === 'NBH Clearance with EOR' ||
|
||||
req.status === 'Completed'
|
||||
) {
|
||||
fetchEorChecklist(req.id);
|
||||
}
|
||||
}
|
||||
} catch (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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate current stage index based on request data
|
||||
const getCurrentStageIndex = () => {
|
||||
if (!request) return 0;
|
||||
const stageIndex = workflowStages.findIndex(s =>
|
||||
s.key === request.currentStage ||
|
||||
s.name === request.currentStage ||
|
||||
s.name === (request.currentStage?.replace(/_/g, ' ') || '')
|
||||
/**
|
||||
* 1-based ordinal from persisted record (currentStage / status) — used for Approve/Reject RBAC only.
|
||||
*/
|
||||
const getDbStageOrdinal = () => {
|
||||
if (!request) return 1;
|
||||
if (request.status === 'Completed' || request.currentStage === 'Completed') {
|
||||
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
|
||||
const getAssignedReviewer = (stageName: string) => {
|
||||
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;
|
||||
};
|
||||
|
||||
const currentStageIndex = getCurrentStageIndex();
|
||||
const currentStageConfig = workflowStages[currentStageIndex - 1];
|
||||
|
||||
// Visibility logic for Approve/Reject buttons
|
||||
const canUserAction = () => {
|
||||
if (!request || !currentUser) return false;
|
||||
@ -208,12 +359,25 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
if (isAdmin) return true;
|
||||
|
||||
// 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);
|
||||
setIsActionDialogOpen(true);
|
||||
};
|
||||
@ -223,22 +387,36 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
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);
|
||||
setComments('');
|
||||
fetchRequestDetails();
|
||||
fetchAuditLogs();
|
||||
// If moving to NBH Clearance EOR, fetch the checklist
|
||||
if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE') {
|
||||
fetchEorChecklist();
|
||||
if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE' && request?.id) {
|
||||
fetchEorChecklist(request.id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit action error:', error);
|
||||
toast.error('Failed to submit action');
|
||||
toast.error(getApiErrorMessage(error, 'Failed to submit action'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@ -266,10 +444,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
setIsUploadDialogOpen(false);
|
||||
setSelectedFile(null);
|
||||
fetchRequestDetails(true);
|
||||
fetchAuditLogs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload document error:', error);
|
||||
toast.error('Failed to upload document');
|
||||
toast.error(getApiErrorMessage(error, 'Failed to upload document'));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
@ -281,10 +460,25 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
if (response.data.success) {
|
||||
toast.success('Document verified successfully');
|
||||
fetchRequestDetails(true); // Silent refresh
|
||||
fetchAuditLogs();
|
||||
}
|
||||
} catch (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-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</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>
|
||||
|
||||
@ -440,52 +636,98 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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 className="h-3 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-600 transition-all duration-500"
|
||||
style={{ width: `${request.progressPercentage}%` }}
|
||||
style={{ width: `${displayProgressPct}%` }}
|
||||
/>
|
||||
</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">
|
||||
{workflowStages.map((stage: any, index: number) => {
|
||||
const isCompleted = index < currentStageIndex - 1;
|
||||
const isCurrent = index === currentStageIndex - 1;
|
||||
const isCompleted = allWorkflowComplete || index < displayOrdinal - 1;
|
||||
const isCurrent = !allWorkflowComplete && index === displayOrdinal - 1;
|
||||
const stageTimelineEntries = getRelocationTimelineEntriesForStage(
|
||||
timelineEntries,
|
||||
stage,
|
||||
index
|
||||
);
|
||||
const timelineEntry =
|
||||
stageTimelineEntries.length > 0
|
||||
? stageTimelineEntries[stageTimelineEntries.length - 1]
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={stage.id} className="flex items-start gap-4">
|
||||
{/* Status Icon */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isCompleted ? 'bg-green-100' :
|
||||
isCurrent ? 'bg-amber-100' :
|
||||
'bg-slate-100'
|
||||
}`}>
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
isCompleted
|
||||
? 'bg-green-100 text-green-600'
|
||||
: isCurrent
|
||||
? 'bg-amber-100 text-amber-600'
|
||||
: 'bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600" />
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
) : 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>
|
||||
{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>
|
||||
|
||||
{/* Stage Info */}
|
||||
<div className={`flex-1 pb-8 ${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">
|
||||
<div
|
||||
className={`flex-1 pb-8 ${
|
||||
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>
|
||||
<h4 className={`${isCurrent ? 'text-amber-900' : 'text-slate-900'}`}>
|
||||
<h4
|
||||
className={
|
||||
isCompleted
|
||||
? 'text-green-700'
|
||||
: isCurrent
|
||||
? 'text-amber-900'
|
||||
: 'text-slate-900'
|
||||
}
|
||||
>
|
||||
{stage.name}
|
||||
</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}
|
||||
</p>
|
||||
{getAssignedReviewer(stage.name) && (
|
||||
@ -494,14 +736,55 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Badge className={
|
||||
isCompleted ? 'bg-green-100 text-green-700 border-green-300' :
|
||||
isCurrent ? 'bg-amber-100 text-amber-700 border-amber-300' :
|
||||
'bg-slate-100 text-slate-500 border-slate-300'
|
||||
}>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<Badge
|
||||
className={
|
||||
isCompleted
|
||||
? 'bg-green-100 text-green-700 border-green-300'
|
||||
: isCurrent
|
||||
? '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>
|
||||
|
||||
{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>
|
||||
);
|
||||
@ -678,6 +961,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
{doc.status === 'Pending Verification' && currentUser?.role !== 'Dealer' && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1"
|
||||
@ -687,6 +971,17 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
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>
|
||||
</TableCell>
|
||||
@ -712,6 +1007,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<div>
|
||||
<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-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>
|
||||
{eorChecklist && (
|
||||
<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">
|
||||
The EOR checklist will be automatically initiated once the request reaches the final clearance stage.
|
||||
</p>
|
||||
<Button variant="outline" onClick={fetchEorChecklist}>
|
||||
<Button variant="outline" onClick={() => fetchEorChecklist(request?.id)}>
|
||||
Try Refreshing
|
||||
</Button>
|
||||
</>
|
||||
@ -753,7 +1051,14 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<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}>
|
||||
<TableCell>
|
||||
<input
|
||||
@ -773,14 +1078,36 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
{item.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.proofDocumentId ? (
|
||||
<Button size="sm" variant="ghost" className="h-7 text-blue-600">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
{item.proofDocumentId && item.proofDocument ? (
|
||||
<>
|
||||
<span className="text-xs text-slate-600 truncate max-w-[220px]" title={item.proofDocument.fileName}>
|
||||
{item.proofDocument.fileName}
|
||||
</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
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-slate-400"
|
||||
@ -793,7 +1120,8 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@ -804,7 +1132,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={handleSubmitEorAudit}
|
||||
disabled={isSubmittingEor || !eorChecklist.items?.every((i: any) => i.isCompliant)}
|
||||
disabled={
|
||||
isSubmittingEor ||
|
||||
!eorChecklist.items?.length ||
|
||||
!eorChecklist.items.every((i: any) => i.isCompliant)
|
||||
}
|
||||
>
|
||||
{isSubmittingEor ? (
|
||||
<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 items-start justify-between">
|
||||
<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>
|
||||
</div>
|
||||
<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="h-full bg-amber-600 transition-all duration-300"
|
||||
style={{ width: `${request.progressPercentage}%` }}
|
||||
style={{ width: `${displayProgressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-slate-900">{request.progressPercentage}%</span>
|
||||
<span className="text-slate-900">{displayProgressPct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -913,7 +1247,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<Button
|
||||
className="w-full bg-green-600 hover:bg-green-700"
|
||||
onClick={() => handleAction('approve')}
|
||||
disabled={isSubmitting}
|
||||
disabled={isSubmitting || !canApprove}
|
||||
>
|
||||
{isSubmitting && actionType === 'approve' ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
@ -922,6 +1256,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
)}
|
||||
Approve Request
|
||||
</Button>
|
||||
{!canApprove && (
|
||||
<p className="text-xs text-amber-700">
|
||||
Approval is blocked until mandatory documents are uploaded and verified for this stage.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
@ -937,6 +1276,41 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
Reject Request
|
||||
</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" />
|
||||
</>
|
||||
)}
|
||||
@ -994,18 +1368,28 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{actionType === 'approve' ? 'Approve Request' :
|
||||
actionType === 'reject' ? 'Reject Request' :
|
||||
'Put Request on Hold'}
|
||||
{actionType === 'approve'
|
||||
? 'Approve Request'
|
||||
: actionType === 'reject'
|
||||
? 'Reject Request'
|
||||
: actionType === 'send_back'
|
||||
? 'Send Back Request'
|
||||
: actionType === 'revoke'
|
||||
? 'Revoke Request'
|
||||
: 'Put Request on Hold'}
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmitAction} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="comments" >Comments *</Label>
|
||||
<Label htmlFor="comments">
|
||||
{actionType === 'send_back' || actionType === 'revoke' ? 'Remarks *' : 'Comments *'}
|
||||
</Label>
|
||||
<div className="space-y-2" />
|
||||
|
||||
</div>
|
||||
@ -1013,7 +1397,13 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
id="comments"
|
||||
value={comments}
|
||||
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}
|
||||
required
|
||||
/>
|
||||
@ -1030,18 +1420,30 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
|
||||
<Button
|
||||
type="submit"
|
||||
className={
|
||||
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' :
|
||||
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' :
|
||||
'bg-amber-600 hover:bg-amber-700'
|
||||
actionType === 'approve'
|
||||
? 'bg-green-600 hover:bg-green-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}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{actionType === 'approve' ? 'Approve' :
|
||||
actionType === 'reject' ? 'Reject' :
|
||||
'Put on Hold'}
|
||||
{actionType === 'approve'
|
||||
? 'Approve'
|
||||
: actionType === 'reject'
|
||||
? 'Reject'
|
||||
: actionType === 'send_back'
|
||||
? 'Send Back'
|
||||
: actionType === 'revoke'
|
||||
? 'Revoke'
|
||||
: 'Put on Hold'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
|
||||
@ -18,7 +18,7 @@ interface RelocationRequestPageProps {
|
||||
const getStatusColor = (status: string) => {
|
||||
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('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';
|
||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||
};
|
||||
@ -58,7 +58,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
|
||||
},
|
||||
{
|
||||
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,
|
||||
color: 'bg-yellow-500',
|
||||
},
|
||||
|
||||
@ -21,7 +21,8 @@ import {
|
||||
Search,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Clock as ClockIcon
|
||||
Clock as ClockIcon,
|
||||
Activity,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import {
|
||||
@ -55,6 +56,54 @@ interface WorkNote {
|
||||
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
|
||||
|
||||
interface WorkNotesPageProps {
|
||||
@ -106,6 +155,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
const [participantSearch, setParticipantSearch] = useState('');
|
||||
const [messageSearch, setMessageSearch] = useState('');
|
||||
|
||||
const { socket } = useSocket();
|
||||
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 () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res: any = await worknoteService.getWorknotes(requestId, requestType);
|
||||
if (res.success) {
|
||||
setNotes(res.data.map((n: any) => ({
|
||||
const mapped: WorkNote[] = res.data.map((n: any) => ({
|
||||
id: n.id,
|
||||
noteText: n.noteText,
|
||||
noteType: n.noteType,
|
||||
createdAt: n.createdAt,
|
||||
userId: n.userId,
|
||||
author: n.author || { name: 'System', email: '', role: 'system' },
|
||||
author: n.author ? normalizeWorknoteAuthor(n) : { name: 'System', email: '', role: 'system' },
|
||||
attachments: n.attachments || []
|
||||
})));
|
||||
}));
|
||||
setNotes(sortNotesChronological(mapped));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch notes error:', error);
|
||||
@ -221,13 +270,20 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
if (socket) {
|
||||
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 => {
|
||||
// 1. Check for exact ID match (real vs real or real vs replaced temp)
|
||||
const isDuplicate = prev.some(n => n.id === newNote.id);
|
||||
if (isDuplicate) return prev;
|
||||
|
||||
// 2. Check for optimistic match (matching text and author from a very recent temp note)
|
||||
const optimisticMatchIndex = prev.findIndex(n =>
|
||||
n.id.startsWith('temp-') &&
|
||||
n.noteText === newNote.noteText &&
|
||||
@ -235,14 +291,12 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
);
|
||||
|
||||
if (optimisticMatchIndex !== -1) {
|
||||
// Replace the temp note with the real one from the socket
|
||||
const newNotes = [...prev];
|
||||
newNotes[optimisticMatchIndex] = newNote;
|
||||
return newNotes;
|
||||
return sortNotesChronological(newNotes);
|
||||
}
|
||||
|
||||
// 3. New message from someone else
|
||||
return [newNote, ...prev];
|
||||
return sortNotesChronological([...prev, newNote]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -403,26 +457,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
|
||||
participantsList.forEach(p => {
|
||||
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 mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi');
|
||||
|
||||
if (processedMessage.match(mentionRegex)) {
|
||||
console.log(`Mention found for ${p.name} (${p.id})`);
|
||||
mentionedUserIds.push(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 {
|
||||
// Optimistic update
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
@ -441,7 +485,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
attachments: optimisticAttachments
|
||||
};
|
||||
|
||||
setNotes(prev => [tempNote, ...prev]);
|
||||
setNotes(prev => sortNotesChronological([...prev, tempNote]));
|
||||
|
||||
const res: any = await worknoteService.addWorknote({
|
||||
requestId: requestId,
|
||||
@ -453,8 +497,17 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
});
|
||||
|
||||
if (res.success && res.data) {
|
||||
// Replace optimistic update with actual data from server
|
||||
setNotes(prev => prev.map(n => n.id === tempId ? res.data : n));
|
||||
const saved = res.data;
|
||||
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) {
|
||||
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 => {
|
||||
// Filter by name query
|
||||
const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase());
|
||||
@ -580,45 +643,113 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
{/* Main Chat Engine */}
|
||||
<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={`max-w-4xl mx-auto space-y-6 flex flex-col py-4 ${mode === 'modal' ? '' : 'px-4'}`}>
|
||||
{[...notes].reverse().map((note) => {
|
||||
const isMe = (note?.author?.email && currentUser?.email && note.author.email.toLowerCase() === currentUser.email.toLowerCase()) ||
|
||||
(note?.userId && currentUser?.id && note.userId === currentUser.id) ||
|
||||
<div className={`max-w-4xl mx-auto flex flex-col py-4 gap-4 ${mode === 'modal' ? '' : 'px-4'}`}>
|
||||
<div className="sticky top-0 z-[1] -mx-1 px-1 pb-1 bg-slate-50/95 backdrop-blur-sm">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
|
||||
<Input
|
||||
type="search"
|
||||
value={messageSearch}
|
||||
onChange={(e) => setMessageSearch(e.target.value)}
|
||||
placeholder="Search messages..."
|
||||
className="pl-9 h-10 bg-white border-slate-200 rounded-xl text-sm shadow-sm"
|
||||
aria-label="Search messages"
|
||||
/>
|
||||
</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-[85%] ${isMe ? 'flex-row-reverse' : ''}`}>
|
||||
{/* Avatar */}
|
||||
<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>
|
||||
|
||||
{/* 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})` : ''}
|
||||
<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]">
|
||||
{note.createdAt ? new Date(note.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
|
||||
<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 ${isMe
|
||||
<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">
|
||||
}`}
|
||||
>
|
||||
<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 => {
|
||||
{note.attachments.map((file) => {
|
||||
const isImage = file.mimeType.startsWith('image/');
|
||||
return (
|
||||
<div key={file.id} className="flex items-center gap-2">
|
||||
@ -633,6 +764,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
</div>
|
||||
) : file.mimeType === 'application/pdf' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewFile(file)}
|
||||
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
@ -670,6 +802,12 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
|
||||
</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 && (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<span className="text-slate-500">Loading notes...</span>
|
||||
|
||||
@ -152,7 +152,8 @@ export function LoginPage({ onLogin }: LoginPageProps) {
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
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}
|
||||
/>
|
||||
<button
|
||||
|
||||
@ -13,9 +13,11 @@ import { User as UserType } from '../../lib/mock-data';
|
||||
import { toast } from 'sonner';
|
||||
import { dealerService } from '../../services/dealer.service';
|
||||
import { formatDateTime } from '../ui/utils';
|
||||
import { API } from '../../api/API';
|
||||
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
|
||||
|
||||
interface DealerConstitutionalChangePageProps {
|
||||
currentUser: UserType | null;
|
||||
currentUser?: UserType | null;
|
||||
onViewDetails?: (id: string) => void;
|
||||
}
|
||||
|
||||
@ -26,9 +28,7 @@ const getStatusColor = (status: string) => {
|
||||
return 'bg-slate-100 text-slate-700 border-slate-300';
|
||||
};
|
||||
|
||||
const constitutionTypes = ['Proprietorship', 'Partnership', 'LLP', 'Pvt Ltd'];
|
||||
|
||||
export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: DealerConstitutionalChangePageProps) {
|
||||
export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitutionalChangePageProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [currentConstitution, setCurrentConstitution] = useState('');
|
||||
const [proposedConstitution, setProposedConstitution] = useState('');
|
||||
@ -40,6 +40,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [profile, setProfile] = useState<any>(null);
|
||||
const [structureTargets, setStructureTargets] = useState<{ value: string; label: string }[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@ -48,12 +49,19 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const dashboard = await dealerService.getDashboardData();
|
||||
const constitutionalRes = await dealerService.getConstitutionalChanges();
|
||||
const [dashboard, constitutionalRes, metaRes] = await Promise.all([
|
||||
dealerService.getDashboardData(),
|
||||
dealerService.getConstitutionalChanges(),
|
||||
API.getConstitutionalChangeMeta() as any
|
||||
]);
|
||||
|
||||
setProfile(dashboard.profile);
|
||||
setCurrentConstitution(dashboard.profile?.constitutionType || 'Proprietorship');
|
||||
const normalizedCurrent = normalizeDealerProfileConstitution(dashboard.profile?.constitutionType);
|
||||
setCurrentConstitution(normalizedCurrent);
|
||||
setRequests(constitutionalRes.requests || []);
|
||||
if (metaRes.data?.success && Array.isArray(metaRes.data.structureTargets)) {
|
||||
setStructureTargets(metaRes.data.structureTargets);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fetch constitutional data error:', error);
|
||||
toast.error('Failed to load requests');
|
||||
@ -85,7 +93,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
||||
const payload = {
|
||||
currentConstitution,
|
||||
changeType: proposedConstitution,
|
||||
reason,
|
||||
reason: reason.trim(),
|
||||
newPartnersDetails: newPartners,
|
||||
shareholdingPattern
|
||||
};
|
||||
@ -100,9 +108,10 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
||||
setReason('');
|
||||
setNewPartners('');
|
||||
setShareholdingPattern('');
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
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 {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@ -202,10 +211,12 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
||||
<SelectValue placeholder="Select new constitution" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{constitutionTypes
|
||||
.filter(type => type !== currentConstitution)
|
||||
.map(type => (
|
||||
<SelectItem key={type} value={type}>{type}</SelectItem>
|
||||
{structureTargets
|
||||
.filter((o) => o.value !== currentConstitution)
|
||||
.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@ -240,7 +251,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
|
||||
)}
|
||||
|
||||
{/* Shareholding Pattern */}
|
||||
{(proposedConstitution === 'Pvt Ltd' || proposedConstitution === 'LLP') && (
|
||||
{(proposedConstitution === 'Private Limited' || proposedConstitution === 'LLP') && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="shareholdingPattern">Proposed Shareholding Pattern</Label>
|
||||
<Textarea
|
||||
|
||||
@ -23,10 +23,13 @@ interface DealerRelocationPageProps {
|
||||
const getStatusColor = (status: string) => {
|
||||
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('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';
|
||||
};
|
||||
|
||||
const getApiErrorMessage = (error: any, fallback: string) =>
|
||||
error?.response?.data?.message || error?.data?.message || error?.message || fallback;
|
||||
|
||||
export function DealerRelocationPage({ currentUser, onViewDetails }: DealerRelocationPageProps) {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [selectedOutlet, setSelectedOutlet] = useState<any | null>(null);
|
||||
@ -69,7 +72,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
|
||||
setRequests(relocationRes.requests || []);
|
||||
} catch (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 {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -90,6 +93,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
|
||||
setDistricts(districtsData);
|
||||
} catch (error) {
|
||||
console.error('Fetch master data error:', error);
|
||||
toast.error(getApiErrorMessage(error, 'Failed to load master data'));
|
||||
} finally {
|
||||
setMasterDataLoading(false);
|
||||
}
|
||||
@ -165,7 +169,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
|
||||
setReason('');
|
||||
} catch (error) {
|
||||
console.error('Submit relocation error:', error);
|
||||
toast.error('Failed to submit relocation request');
|
||||
toast.error(getApiErrorMessage(error, 'Failed to submit relocation request'));
|
||||
} finally {
|
||||
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;
|
||||
}
|
||||
@ -210,3 +210,58 @@ html {
|
||||
scrollbar-width: thin;
|
||||
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