major chnges made in all modules lin worknote nline history audit log enhncement progress bar improvement aross differnt modules

This commit is contained in:
laxmanhalaki 2026-04-15 11:01:02 +05:30
parent d3bdea8318
commit 8ea748fde6
12 changed files with 1738 additions and 572 deletions

View File

@ -97,7 +97,7 @@ export const API = {
deleteUser: (id: string) => client.delete(`/admin/users/${id}`), deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
// Dealer & Outlets // Dealer & Outlets
getDealers: () => client.get('/dealer'), getDealers: (params?: { onboarded?: string }) => client.get('/dealer', { params }),
createDealer: (data: any) => client.post('/dealer', data), createDealer: (data: any) => client.post('/dealer', data),
getDealerById: (id: string) => client.get(`/dealer/${id}`), getDealerById: (id: string) => client.get(`/dealer/${id}`),
updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data), updateDealer: (id: string, data: any) => client.put(`/dealer/${id}`, data),
@ -180,8 +180,11 @@ export const API = {
headers: { 'Content-Type': 'multipart/form-data' } headers: { 'Content-Type': 'multipart/form-data' }
}), }),
verifyRelocationDocument: (id: string, documentId: string) => client.post(`/relocation/${id}/documents/${documentId}/verify`), verifyRelocationDocument: (id: string, documentId: string) => client.post(`/relocation/${id}/documents/${documentId}/verify`),
rejectRelocationDocument: (id: string, documentId: string, data?: any) =>
client.post(`/relocation/${id}/documents/${documentId}/reject`, data || {}),
getConstitutionalChanges: () => client.get('/constitutional-change'), getConstitutionalChanges: () => client.get('/constitutional-change'),
getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'),
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`), getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data), createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
updateConstitutionalChange: (id: string, action: string, data?: any) => client.post(`/constitutional-change/${id}/action`, { action, ...data }), updateConstitutionalChange: (id: string, action: string, data?: any) => client.post(`/constitutional-change/${id}/action`, { action, ...data }),

View File

@ -255,6 +255,26 @@ const KT_MATRIX_CRITERIA = [
} }
]; ];
function auditLogActionBadgeClass(action: string): string {
const a = String(action || '').toUpperCase();
if (a.includes('REJECT') || a.includes('DELET') || a.includes('DISQUALIF')) {
return 'border-red-200 bg-red-50/90 text-red-800';
}
if (a === 'CREATED' || a.includes('APPROV') || a.includes('COMPLETE')) {
return 'border-emerald-200 bg-emerald-50/90 text-emerald-900';
}
if (a.includes('DOCUMENT') || a.includes('UPLOAD') || a.includes('ATTACHMENT')) {
return 'border-sky-200 bg-sky-50/80 text-sky-900';
}
if (a.includes('PAYMENT') || a.includes('SECURITY') || a.includes('DEPOSIT')) {
return 'border-violet-200 bg-violet-50/80 text-violet-900';
}
if (a.includes('FDD') || a.includes('QUESTIONNAIRE') || a.includes('INTERVIEW')) {
return 'border-amber-200 bg-amber-50/80 text-amber-900';
}
return 'border-slate-200 bg-slate-50 text-slate-700';
}
export const ApplicationDetails = () => { export const ApplicationDetails = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@ -442,7 +462,7 @@ export const ApplicationDetails = () => {
const fetchAuditLogs = async () => { const fetchAuditLogs = async () => {
setAuditLoading(true); setAuditLoading(true);
try { try {
const logs = await auditService.getAuditLogs('application', application.id); const logs = await auditService.getAuditLogs('application', application.id, 1, 100);
setAuditLogs(Array.isArray(logs) ? logs : []); setAuditLogs(Array.isArray(logs) ? logs : []);
} catch (error) { } catch (error) {
console.error('Failed to fetch audit logs', error); console.error('Failed to fetch audit logs', error);
@ -730,6 +750,7 @@ export const ApplicationDetails = () => {
// Reset form // Reset form
setKtMatrixScores({}); setKtMatrixScores({});
setKtMatrixSelectedValues({});
setKtMatrixRemarks(''); setKtMatrixRemarks('');
await fetchInterviews(); await fetchInterviews();
await fetchApplication(); // Refresh application status and progress await fetchApplication(); // Refresh application status and progress
@ -1205,7 +1226,28 @@ export const ApplicationDetails = () => {
{ {
id: 8, id: 8,
name: 'LOI Approval', name: 'LOI Approval',
status: getStageStatus('LOI Approval', () => ['Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOI In Progress' ? 'active' : 'pending'), status: getStageStatus('LOI Approval', () =>
[
'Security Details',
'Payment Pending',
'LOI Issued',
'Statutory LOI Ack',
'Dealer Code Generation',
'Architecture Work',
'Statutory Work',
'LOA Pending',
'LOA Issued',
'EOR In Progress',
'EOR Complete',
'Inauguration',
'Approved',
'Onboarded',
].includes(application.status)
? 'completed'
: application.status === 'LOI In Progress'
? 'active'
: 'pending',
),
date: application.loiApprovalDate, date: application.loiApprovalDate,
description: 'Letter of Intent approval', description: 'Letter of Intent approval',
evaluators: Array.from(new Set((application.participants || []) evaluators: Array.from(new Set((application.participants || [])
@ -1217,7 +1259,26 @@ export const ApplicationDetails = () => {
{ {
id: 9, id: 9,
name: 'Security Details', name: 'Security Details',
status: getStageStatus('Security Details', () => ['LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Payment Pending' ? 'active' : 'pending'), status: getStageStatus('Security Details', () =>
[
'LOI Issued',
'Statutory LOI Ack',
'Dealer Code Generation',
'Architecture Work',
'Statutory Work',
'LOA Pending',
'LOA Issued',
'EOR In Progress',
'EOR Complete',
'Inauguration',
'Approved',
'Onboarded',
].includes(application.status)
? 'completed'
: application.status === 'Security Details' || application.status === 'Payment Pending'
? 'active'
: 'pending',
),
date: application.securityDetailsDate, date: application.securityDetailsDate,
description: 'Security verification', description: 'Security verification',
documentsUploaded: 3 documentsUploaded: 3
@ -1225,10 +1286,27 @@ export const ApplicationDetails = () => {
{ {
id: 10, id: 10,
name: 'LOI Issue', name: 'LOI Issue',
status: getStageStatus('LOI Issue', () => status: getStageStatus('LOI Issue', () => {
['Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : if (
application.status === 'LOI Issued' ? 'active' : 'pending' [
), 'Statutory LOI Ack',
'Dealer Code Generation',
'Architecture Work',
'Statutory Work',
'LOA Pending',
'LOA Issued',
'EOR In Progress',
'EOR Complete',
'Inauguration',
'Approved',
'Onboarded',
].includes(application.status)
) {
return 'completed';
}
if (application.status === 'LOI Issued') return 'active';
return 'pending';
}),
date: application.loiIssueDate, date: application.loiIssueDate,
description: 'Letter of Intent issued', description: 'Letter of Intent issued',
documentsUploaded: 1 documentsUploaded: 1
@ -1446,6 +1524,7 @@ export const ApplicationDetails = () => {
case 'FDD Verification': case 'FDD Verification':
newStatus = 'LOI In Progress'; break; newStatus = 'LOI In Progress'; break;
case 'LOI In Progress': case 'LOI In Progress':
newStatus = 'Security Details'; break;
case 'Security Details': case 'Security Details':
case 'Payment Pending': case 'Payment Pending':
newStatus = 'LOI Issued'; break; newStatus = 'LOI Issued'; break;
@ -1773,7 +1852,14 @@ export const ApplicationDetails = () => {
// Centralized Permissions Utility (Consolidates 500 lines of fragmented logic) // Centralized Permissions Utility (Consolidates 500 lines of fragmented logic)
const getApplicationPermissions = () => { const getApplicationPermissions = () => {
if (!application || !currentUser) { if (!application || !currentUser) {
return { canApprove: false, canReject: false, canSchedule: false, canAssign: false, isLoaLocked: false, showDecisionMessage: false }; return {
canApprove: false,
canReject: false,
canSchedule: false,
canAssign: false,
isLoaLocked: false,
showDecisionMessage: false,
};
} }
// 1. Core Flags // 1. Core Flags
@ -2491,7 +2577,7 @@ export const ApplicationDetails = () => {
<Card> <Card>
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<CardHeader className="pb-4 px-4 sm:px-6"> <CardHeader className="pb-4 px-4 sm:px-6">
<div className="overflow-x-auto scrollbar-hide -mx-4 px-4 sm:-mx-6 sm:px-6"> <div className="overflow-x-auto custom-scrollbar-x -mx-4 px-4 sm:-mx-6 sm:px-6">
<TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1"> <TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1">
<TabsTrigger value="questionnaire" className="min-w-[120px]">Questionnaire</TabsTrigger> <TabsTrigger value="questionnaire" className="min-w-[120px]">Questionnaire</TabsTrigger>
<TabsTrigger value="progress" className="min-w-[80px]">Progress</TabsTrigger> <TabsTrigger value="progress" className="min-w-[80px]">Progress</TabsTrigger>
@ -3400,41 +3486,63 @@ export const ApplicationDetails = () => {
{/* Audit Trail Tab */} {/* Audit Trail Tab */}
<TabsContent value="audit"> <TabsContent value="audit">
<ScrollArea className="h-96"> <ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50">
<div className="space-y-4"> <div className="space-y-2.5 p-3 pr-4">
{auditLoading ? ( {auditLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-10">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600"></div> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600" />
<span className="ml-2 text-slate-500">Loading audit trail...</span> <span className="ml-2 text-sm text-slate-500">Loading audit trail</span>
</div> </div>
) : auditLogs.length === 0 ? ( ) : auditLogs.length === 0 ? (
<div className="text-center py-8 text-slate-500"> <div className="rounded-lg border border-dashed border-slate-200 bg-white py-10 text-center text-sm text-slate-500">
No audit logs recorded yet for this application. No audit logs recorded yet for this application.
</div> </div>
) : ( ) : (
auditLogs.map((log: any) => ( auditLogs.map((log: any) => (
<div key={log.id} className="flex gap-4 p-3 hover:bg-slate-50 rounded-lg"> <div
<div className="w-2 h-2 bg-amber-600 rounded-full mt-2 flex-shrink-0"></div> key={log.id}
<div className="flex-1"> className="rounded-lg border border-slate-200/90 bg-white p-3 text-sm shadow-sm"
<div className="flex items-start justify-between"> >
<p className="text-slate-900 font-medium">{log.description || log.action}</p> <div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<span className="text-slate-500 text-sm whitespace-nowrap ml-4"> <div className="flex min-w-0 flex-wrap items-center gap-2">
{formatDateTime(log.timestamp)} <Badge
</span> variant="outline"
className={cn(
'shrink-0 text-[10px] font-semibold uppercase tracking-wide',
auditLogActionBadgeClass(log.action)
)}
>
{String(log.action || 'EVENT').replace(/_/g, ' ')}
</Badge>
{log.stage ? (
<span
className="max-w-[200px] truncate text-[11px] text-slate-500"
title={log.stage}
>
{log.stage}
</span>
) : null}
</div> </div>
<p className="text-slate-600 mt-1">by {log.userName || 'System'}</p> <time
{log.remarks && ( className="shrink-0 text-xs tabular-nums text-slate-400"
<p className="mt-2 text-red-600 text-sm font-bold bg-red-50 p-2 rounded border border-red-100 italic"> dateTime={log.timestamp}
"{log.remarks}" >
</p> {formatDateTime(log.timestamp)}
)} </time>
{log.changes && log.changes.length > 0 && ( </div>
<div className="mt-1 space-y-0.5"> <p className="mt-2 text-[13px] leading-relaxed text-slate-800">
{log.changes.map((change: string, idx: number) => ( {log.description || '—'}
<p key={idx} className="text-slate-500 text-sm">{change}</p> </p>
))} <div className="mt-2 flex items-center gap-1.5 text-xs text-slate-500">
</div> <User className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
)} <span className="min-w-0 truncate">
<span className="font-medium text-slate-600">
{log.userName || 'System'}
</span>
{log.userEmail ? (
<span className="text-slate-400"> · {log.userEmail}</span>
) : null}
</span>
</div> </div>
</div> </div>
)) ))
@ -3509,9 +3617,40 @@ export const ApplicationDetails = () => {
{permissions.isLoaLocked && ( {permissions.isLoaLocked && (
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800"> <Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800">
<Lock className="w-4 h-4 text-amber-600" /> <Lock className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-semibold">Stage Locked</AlertTitle> <AlertTitle className="text-amber-900 font-semibold">LOA approval locked</AlertTitle>
<AlertDescription className="text-amber-800"> <AlertDescription className="text-amber-800">
First Fill (15L) must be verified by Finance before LOA Approval can proceed. <span className="font-medium">First Fill</span> (later-stage payment) must be verified by Finance
before LOA approval can proceed. This is separate from the initial security deposit before LOI Issued.
</AlertDescription>
</Alert>
)}
{getDeposit('FIRST_FILL')?.status === 'Verified' &&
application.status !== 'LOA Pending' &&
!['LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded', 'Rejected'].includes(
application.status,
) && (
<Alert className="mb-4 border-violet-200 bg-violet-50/90 text-violet-950">
<Info className="h-4 w-4 text-violet-700" />
<AlertTitle className="font-semibold">First Fill verified on file</AlertTitle>
<AlertDescription className="text-sm text-violet-900/90 leading-relaxed">
Finance has verified the <span className="font-medium">First Fill</span> payment. The application
status was <span className="font-medium">not</span> changed until you reach{' '}
<span className="font-medium">LOA Pending</span>. When you get there, LOA approval will not be
blocked by payment (same pattern as recording the initial security deposit before the LOI
security step).
</AlertDescription>
</Alert>
)}
{['Security Details', 'Payment Pending'].includes(application.status) && (
<Alert className="mb-4 border-sky-200 bg-sky-50/90 text-sky-900">
<Info className="h-4 w-4 text-sky-700" />
<AlertTitle className="text-sky-950 font-semibold">Security Details review</AlertTitle>
<AlertDescription className="text-sm text-sky-900/90 leading-relaxed">
Check the initial security deposit on the <span className="font-medium">Payments</span> tab (Finance
may have already marked it verified). When satisfied, use <span className="font-medium">Approve</span>{' '}
to move to <span className="font-medium">LOI Issued</span>.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@ -3537,24 +3676,24 @@ export const ApplicationDetails = () => {
)} )}
{permissions.canApprove && ( {permissions.canApprove && (
<> <Button
<Button className="w-full bg-green-600 hover:bg-green-700 font-bold"
className="w-full bg-green-600 hover:bg-green-700 font-bold" onClick={() => setShowApproveModal(true)}
onClick={() => setShowApproveModal(true)} >
> <CheckCircle className="w-4 h-4 mr-2" />
<CheckCircle className="w-4 h-4 mr-2" /> {['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'}
{['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'} </Button>
</Button> )}
<Button {permissions.canReject && (
variant="destructive" <Button
className="w-full font-bold" variant="destructive"
onClick={() => setShowRejectModal(true)} className="w-full font-bold"
> onClick={() => setShowRejectModal(true)}
<XCircle className="w-4 h-4 mr-2" /> >
Reject <XCircle className="w-4 h-4 mr-2" />
</Button> Reject
</> </Button>
)} )}
{permissions.showDecisionMessage && ( {permissions.showDecisionMessage && (
@ -4187,120 +4326,83 @@ export const ApplicationDetails = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* KT Matrix Modal */} {/* KT Matrix — Level 1 */}
<Dialog open={showKTMatrixModal} onOpenChange={setShowKTMatrixModal}> <Dialog open={showKTMatrixModal} onOpenChange={setShowKTMatrixModal}>
<DialogContent className="max-w-4xl h-[85vh] p-0 overflow-hidden flex flex-col gap-0 border-none shadow-2xl"> <DialogContent className="flex min-h-0 max-h-[90vh] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-lg">
{/* Ultra-Simple Header */} <DialogHeader className="shrink-0 space-y-2 border-b px-5 py-4 text-left">
<div className="px-8 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50 shrink-0"> <DialogTitle className="text-base">KT matrix</DialogTitle>
<div> <DialogDescription className="text-sm leading-relaxed">
<DialogTitle className="text-base font-bold text-slate-900 leading-tight">KT Matrix Assessment</DialogTitle> Level 1 interview · {application.name}
<p className="text-slate-400 text-[11px] font-medium tracking-tight">Evaluate technical capability for {application.name}</p> <span className="mt-1 block text-xs text-muted-foreground">
</div> {Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} criteria answered
<div className="text-right"> </span>
<div className="text-xs font-bold text-slate-600 mb-1">{Object.keys(ktMatrixSelectedValues).length} of {KT_MATRIX_CRITERIA.length} Completed</div> </DialogDescription>
<Progress value={(Object.keys(ktMatrixSelectedValues).length / KT_MATRIX_CRITERIA.length) * 100} className="w-28 h-1.5 bg-slate-100" /> </DialogHeader>
</div>
</div>
<div className="flex-1 overflow-y-auto bg-white"> <div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="p-8 max-w-3xl mx-auto"> <div className="space-y-6">
{/* Question List - Minimalist Style */} {KT_MATRIX_CRITERIA.map((criterion, idx) => (
<div className="divide-y divide-slate-100 border-x border-t border-slate-100 rounded-t-xl overflow-hidden shadow-sm"> <div key={criterion.name} className="space-y-2">
{KT_MATRIX_CRITERIA.map((criterion, idx) => ( <Label
<div key={criterion.name} className="p-5 hover:bg-slate-50/50 transition-colors"> htmlFor={`kt-matrix-${idx}`}
<div className="flex justify-between items-start gap-4 mb-4"> className="block text-sm font-medium leading-relaxed text-foreground"
<h4 className="text-sm font-semibold text-slate-800 leading-snug"> >
<span className="text-slate-400 mr-2 tabular-nums">{idx + 1}.</span> <span className="text-muted-foreground">{idx + 1}.</span> {criterion.name}{' '}
{criterion.name} <span className="font-normal text-muted-foreground">({criterion.weight}%)</span>
</h4> </Label>
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-widest bg-slate-50 px-2 py-0.5 rounded border border-slate-100"> <Select
WT: {criterion.weight}% value={ktMatrixSelectedValues[criterion.name] ?? undefined}
</span> onValueChange={(value) => {
</div> const option = criterion.options.find((o) => o.value === value);
if (option) handleKTMatrixChange(criterion.name, option.value, option.score);
}}
>
<SelectTrigger id={`kt-matrix-${idx}`} className="h-10 w-full text-left text-sm font-normal">
<SelectValue placeholder="Choose an option…" />
</SelectTrigger>
<SelectContent position="popper" className="max-h-72 w-[var(--radix-select-trigger-width)]">
{criterion.options.map((option) => (
<SelectItem key={option.value} value={option.value} className="py-2.5 text-sm leading-snug">
{option.label}{' '}
<span className="text-muted-foreground">({option.score})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
<div className="flex flex-wrap gap-2"> <div className="space-y-2 border-t border-border pt-6">
{criterion.options.map((option) => { <Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">
const isSelected = ktMatrixSelectedValues[criterion.name] === option.value; Notes <span className="font-normal text-muted-foreground">(optional)</span>
return ( </Label>
<div
key={option.value}
onClick={() => handleKTMatrixChange(criterion.name, option.value, option.score)}
className={cn(
"px-3 py-1.5 rounded-lg border text-[11px] font-bold cursor-pointer transition-all flex items-center gap-2 select-none",
isSelected
? "bg-slate-900 border-slate-900 text-white shadow-md"
: "bg-white border-slate-200 text-slate-500 hover:border-slate-400"
)}
>
{isSelected && <Check className="w-3 h-3 text-amber-400" />}
{option.label}
<span className={cn(
"ml-1 font-mono",
isSelected ? "text-slate-400" : "text-slate-300"
)}>
[{option.score}]
</span>
</div>
);
})}
</div>
</div>
))}
</div>
{/* Remarks Component */}
<div className="p-6 border border-slate-100 bg-slate-50 rounded-b-xl space-y-3">
<Label className="text-[10px] font-black text-slate-500 uppercase tracking-widest">Additional Evaluation Notes</Label>
<Textarea <Textarea
placeholder="Record observations, strengths or concerns..." id="kt-matrix-remarks"
className="min-h-[80px] text-sm resize-none border-slate-200 rounded-lg bg-white p-4 focus:ring-1 focus:ring-slate-400 transition-all font-medium" placeholder="Optional remarks…"
className="min-h-[96px] resize-y text-sm leading-relaxed"
value={ktMatrixRemarks} value={ktMatrixRemarks}
onChange={(e) => setKtMatrixRemarks(e.target.value)} onChange={(e) => setKtMatrixRemarks(e.target.value)}
/> />
</div> </div>
</div>
</div>
{/* Ultra-Simple Summary Line */} <div className="flex shrink-0 flex-col gap-4 border-t px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="mt-8 p-6 bg-slate-900 rounded-2xl flex items-center justify-between text-white shadow-xl shadow-slate-200"> <p className="text-sm text-muted-foreground">
<div className="space-y-1"> Weighted total{' '}
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Composite Assessment Score</p> <span className="font-semibold tabular-nums text-foreground">{calculateKTScore()}</span>
<div className="flex items-center gap-3"> <span className="text-muted-foreground"> / 100</span>
<div className={cn( </p>
"w-2.5 h-2.5 rounded-full shadow-sm", <div className="flex gap-2 sm:shrink-0">
Number(calculateKTScore()) >= 60 ? "bg-green-500" : <Button variant="outline" onClick={() => setShowKTMatrixModal(false)}>
Number(calculateKTScore()) >= 40 ? "bg-amber-500" : "bg-red-500" Cancel
)} /> </Button>
<span className="text-xs font-bold text-slate-200"> <Button
{Number(calculateKTScore()) >= 60 ? "Strong Profile" : onClick={handleSubmitKTMatrix}
Number(calculateKTScore()) >= 40 ? "Needs Review" : "Low Alignment"} disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length}
</span> >
</div> {isSubmittingKT ? 'Saving…' : 'Submit'}
</div> </Button>
<div className="text-right">
<div className="flex items-baseline justify-end gap-1">
<span className="text-4xl font-black italic tracking-tighter tabular-nums">{calculateKTScore()}</span>
<span className="text-slate-500 font-bold text-lg">/100</span>
</div>
</div>
</div>
{/* Compact Footer Actions */}
<div className="flex gap-3 mt-8 pb-10">
<Button
className="flex-[2] bg-slate-900 hover:bg-slate-800 text-white font-black rounded-xl h-12 shadow-lg"
onClick={handleSubmitKTMatrix}
disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length}
>
{isSubmittingKT ? 'Saving...' : 'Complete Evaluation'}
</Button>
<Button
variant="outline"
className="flex-1 rounded-xl text-slate-500 border-slate-200 font-bold h-12"
onClick={() => setShowKTMatrixModal(false)}
>
Close
</Button>
</div>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -1,4 +1,4 @@
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, ArrowRight, MessageSquare, Loader2 } from 'lucide-react'; import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, ArrowRight, MessageSquare, Loader2, Ban, Undo2 } from 'lucide-react';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
@ -8,8 +8,7 @@ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, D
import { Textarea } from '../ui/textarea'; import { Textarea } from '../ui/textarea';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { User as UserType } from '../../lib/mock-data'; import { User as UserType } from '../../lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '../../api/API'; import { API } from '../../api/API';
@ -39,6 +38,7 @@ const workflowStages = [
const documentRequirements: Record<string, number[]> = { const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16], 'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16], 'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16],
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16], 'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Proprietorship': [1, 2, 3, 10, 16] 'Proprietorship': [1, 2, 3, 10, 16]
}; };
@ -76,26 +76,57 @@ const getTypeColor = (type: string) => {
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
if (status === 'Completed' || status === 'Verified') return 'bg-green-100 text-green-700 border-green-300'; const s = String(status || '');
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress' || status === 'Submitted') return 'bg-yellow-100 text-yellow-700 border-yellow-300'; if (s === 'Completed' || s === 'Verified' || s === 'APPROVED' || s === 'COMPLETED' || s === 'CREATED' || /^DOCUMENT/i.test(s)) {
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300'; return 'bg-green-100 text-green-700 border-green-300';
}
if (s.includes('Revoked') || s === 'REVOKED') return 'bg-orange-100 text-orange-800 border-orange-300';
if (s.includes('Rejected') || s === 'REJECTED') return 'bg-red-100 text-red-700 border-red-300';
if (s === 'SENT BACK' || s.includes('Review') || s.includes('Pending') || s === 'In Progress' || s === 'Submitted') {
return 'bg-yellow-100 text-yellow-700 border-yellow-300';
}
if (s === 'UPDATED') return 'bg-slate-100 text-slate-700 border-slate-300';
return 'bg-slate-100 text-slate-700 border-slate-300'; return 'bg-slate-100 text-slate-700 border-slate-300';
}; };
/** Audit rows were stored as UPDATED for approvals; avoid treating "UPDATED" as pending via substring "update". */
const getConstitutionalHistoryPresentation = (entry: any) => {
const raw = String(entry.action || 'UPDATED').toUpperCase();
const details = entry.details || entry.newData || {};
const targetStage = details.targetStage as string | undefined;
const remarks = String(entry.remarks || '').toLowerCase();
if (raw === 'REJECTED') return { variant: 'danger' as const, badge: 'REJECTED' };
if (raw === 'CONSTITUTIONAL_REVOKED' || raw === 'REVOKED') return { variant: 'danger' as const, badge: 'REVOKED' };
if (raw === 'CONSTITUTIONAL_SENT_BACK') return { variant: 'pending' as const, badge: 'SENT BACK' };
if (raw === 'DOCUMENT_REJECTED') return { variant: 'danger' as const, badge: 'DOCUMENT REJECTED' };
if (raw === 'APPROVED' || raw === 'CREATED' || raw === 'DOCUMENT_UPLOADED' || raw === 'DOCUMENT_VERIFIED') {
return { variant: 'success' as const, badge: raw.replace(/_/g, ' ') };
}
if (raw === 'UPDATED') {
if (remarks.includes('send') && remarks.includes('back')) return { variant: 'pending' as const, badge: 'SENT BACK' };
if (remarks.includes('reject')) return { variant: 'danger' as const, badge: 'REJECTED' };
if (targetStage === 'Completed') return { variant: 'success' as const, badge: 'COMPLETED' };
if (targetStage) return { variant: 'success' as const, badge: 'APPROVED' };
return { variant: 'neutral' as const, badge: 'UPDATED' };
}
return { variant: 'neutral' as const, badge: raw.replace(/_/g, ' ') || 'EVENT' };
};
const normalizeConstitutionType = (value: string) => { const normalizeConstitutionType = (value: string) => {
const input = String(value || '').trim().toLowerCase(); const input = String(value || '').trim().toLowerCase();
if (!input) return ''; if (!input) return '';
if (input.includes('proprietor')) return 'Proprietorship'; if (input.includes('proprietor')) return 'Proprietorship';
if (input.includes('partner')) return 'Partnership'; if (input.includes('partner')) return 'Partnership';
if (input.includes('llp')) return 'LLP'; if (input.includes('llp')) return 'LLP';
if (input.includes('private') || input.includes('pvt')) return 'Pvt Ltd'; if (input.includes('private') || input.includes('pvt')) return 'Private Limited';
return value; return value;
}; };
export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) { export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }: ConstitutionalChangeDetailsProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false); const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold'>('approve'); const [actionType, setActionType] = useState<'approve' | 'reject' | 'sendBack' | 'revoke'>('approve');
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [selectedDocType, setSelectedDocType] = useState<number | null>(null); const [selectedDocType, setSelectedDocType] = useState<number | null>(null);
@ -107,15 +138,15 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isActionLoading, setIsActionLoading] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false);
const [isUploadingDoc, setIsUploadingDoc] = useState(false); const [isUploadingDoc, setIsUploadingDoc] = useState(false);
const [rejectDocDialogOpen, setRejectDocDialogOpen] = useState(false);
const [rejectDocIndex, setRejectDocIndex] = useState<number | null>(null);
const [rejectDocReason, setRejectDocReason] = useState('');
const [isRejectingDoc, setIsRejectingDoc] = useState(false);
useEffect(() => { const fetchAuditLogs = async (entityId: string) => {
fetchRequestDetails(); if (!entityId) return;
fetchAuditLogs();
}, [requestId]);
const fetchAuditLogs = async () => {
try { try {
const response: any = await API.getAuditLogs('constitutional_change', requestId); const response: any = await API.getAuditLogs('constitutional_change', entityId);
if (response.data && response.data.success) { if (response.data && response.data.success) {
setAuditLogs(response.data.data || []); setAuditLogs(response.data.data || []);
} }
@ -124,12 +155,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
} }
}; };
const fetchRequestDetails = async () => { const fetchRequestDetails = async (opts?: { silent?: boolean }) => {
try { try {
setIsLoading(true); if (!opts?.silent) setIsLoading(true);
const response = await API.getConstitutionalChangeById(requestId) as any; const response = await API.getConstitutionalChangeById(requestId) as any;
if (response.data.success) { if (response.data.success) {
setRequest(response.data.request); const reqData = response.data.request;
setRequest(reqData);
await fetchAuditLogs(reqData?.id || requestId);
} else { } else {
toast.error('Failed to fetch request details'); toast.error('Failed to fetch request details');
} }
@ -137,10 +170,28 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
console.error('Fetch request details error:', error); console.error('Fetch request details error:', error);
toast.error('Error loading request details'); toast.error('Error loading request details');
} finally { } finally {
setIsLoading(false); if (!opts?.silent) setIsLoading(false);
} }
}; };
useEffect(() => {
fetchRequestDetails();
}, [requestId]);
const historyEntries = useMemo(() => {
if (auditLogs.length > 0) return auditLogs;
const tl = (request?.timeline || []) as any[];
return tl.map((t: any, i: number) => ({
id: `timeline-${i}`,
action: String(t.action || 'UPDATED'),
description: t.remarks || t.action || 'Updated',
stage: t.stage || null,
userName: t.user || t.userName || 'System',
remarks: t.remarks,
timestamp: t.timestamp || t.createdAt
}));
}, [auditLogs, request?.timeline]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4"> <div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
@ -163,14 +214,26 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
// Get required documents for this request (normalized mapping handles values like "LLP Conversion") // Get required documents for this request (normalized mapping handles values like "LLP Conversion")
const normalizedChangeType = normalizeConstitutionType(request.changeType); const normalizedChangeType = normalizeConstitutionType(request.changeType);
const requiredDocs = documentRequirements[normalizedChangeType] || []; const requiredDocs = documentRequirements[normalizedChangeType] || [];
const uploadedDocNumbers = new Set(
(request.documents || []) const findUploadedForDocNum = (docNum: number) => {
.map((doc: any) => Number(doc?.docNumber)) const docs = request.documents || [];
.filter((num: number) => !Number.isNaN(num) && num > 0) const label = documentNames[docNum];
); return docs.find((d: any) => {
const n = Number(d?.docNumber);
if (!Number.isNaN(n) && n === docNum) return true;
if (label && typeof d?.name === 'string' && d.name.includes(label)) return true;
return false;
});
};
const isDocTypeUploaded = (docNum: number) => {
const u = findUploadedForDocNum(docNum);
return !!u && String(u.status || '') !== 'Rejected';
};
// Calculate current stage index mapping to backend stages // Calculate current stage index mapping to backend stages
const getCurrentStageIndex = () => { const getCurrentStageIndex = () => {
if (request.currentStage === 'Rejected' || request.currentStage === 'Revoked') return -1;
const stageMap: Record<string, number> = { const stageMap: Record<string, number> = {
'Submitted': 1, 'Submitted': 1,
'ASM Review': 2, 'ASM Review': 2,
@ -186,6 +249,15 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
}; };
const currentStageIndex = getCurrentStageIndex(); const currentStageIndex = getCurrentStageIndex();
/** When the request has reached terminal success, every workflow row (including the "Completed" step) should show as done — not "In Progress". */
const flowComplete =
request.currentStage === 'Completed' ||
(String(request.status || '') === 'Completed' && !['Rejected', 'Revoked'].includes(String(request.currentStage || '')));
/** SRS §12.2 — closed failure states: do not show misleading step progress. */
const workflowTerminalNegative =
['Rejected', 'Revoked'].includes(String(request.status || '')) ||
['Rejected', 'Revoked'].includes(String(request.currentStage || ''));
const getLatestStageTimelineEntry = (stageName: string) => { const getLatestStageTimelineEntry = (stageName: string) => {
const aliases: Record<string, string[]> = { const aliases: Record<string, string[]> = {
@ -211,61 +283,104 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
// Centralized Permissions Utility (Fixes security gap where buttons showed for everyone) // Centralized Permissions Utility (Fixes security gap where buttons showed for everyone)
const getConstitutionalPermissions = () => { const getConstitutionalPermissions = () => {
if (!request || !currentUser) { if (!request || !currentUser) {
return { canApprove: false, canReject: false, canHold: false, isFinalState: false }; return { canApprove: false, canReject: false, canSendBack: false, canRevoke: false, isFinalState: false };
} }
const currentStage = request.currentStage; const currentStage = request.currentStage;
const status = request.status; const status = request.status;
const userRole = currentUser.role; const userRole = currentUser.role;
const isFinalState = ['Completed', 'Rejected', 'Hold'].includes(status); const isFinalState = ['Completed', 'Rejected', 'Revoked'].includes(String(status || '')) ||
['Rejected', 'Revoked', 'Completed'].includes(String(currentStage || ''));
// Find stage definition
const stageDef = workflowStages.find(s => s.name === currentStage || s.key === currentStage); const stageDef = workflowStages.find(s => s.name === currentStage || s.key === currentStage);
// Role matching logic (Handles Role names from constants vs workflow mapping) /**
const isCurrentlyAssigned = currentUser.roleCode === 'SUPER_ADMIN' || ( * DB stage `Submitted` means the dealer (or internal on behalf) has already filed the request.
(stageDef?.role === 'ASM' && userRole === 'ASM') || * The next gate is ASM there is no second dealer action on this row. ASM Approve moves to `ASM Review`.
(stageDef?.role === 'ZM/RBM' && (userRole === 'DD-ZM' || userRole === 'RBM')) || */
(stageDef?.role === 'ZBH' && userRole === 'ZBH') || const atSubmittedDbStage = currentStage === 'Submitted';
(stageDef?.role === 'DD Lead' && userRole === 'DD Lead') || const isCurrentlyAssigned =
(stageDef?.role === 'DD Head' && userRole === 'DD Head') || currentUser.roleCode === 'SUPER_ADMIN' ||
(stageDef?.role === 'NBH' && userRole === 'NBH') || (atSubmittedDbStage && (userRole === 'ASM' || currentUser.roleCode === 'ASM')) ||
(stageDef?.role === 'Legal Team' && (userRole === 'Legal Admin')) (!atSubmittedDbStage &&
); !!(
(stageDef?.role === 'ASM' && userRole === 'ASM') ||
(stageDef?.role === 'ZM/RBM' && (userRole === 'DD-ZM' || userRole === 'RBM')) ||
(stageDef?.role === 'ZBH' && userRole === 'ZBH') ||
(stageDef?.role === 'DD Lead' && userRole === 'DD Lead') ||
(stageDef?.role === 'DD Head' && userRole === 'DD Head') ||
(stageDef?.role === 'NBH' && userRole === 'NBH') ||
(stageDef?.role === 'Legal Team' && userRole === 'Legal Admin')
));
/** SRS §12.2.5 — Send Back / Revoke for ZBH, DD Lead, DD Head, NBH (not at Legal-only step). */
const sendBackRevokeRoles = ['ZBH', 'DD Lead', 'DD Head', 'NBH'];
const canSendBackOrRevoke =
isCurrentlyAssigned &&
!isFinalState &&
(currentUser.roleCode === 'SUPER_ADMIN' || sendBackRevokeRoles.includes(userRole)) &&
currentStage !== 'Legal Review' &&
currentStage !== 'Submitted';
return { return {
canApprove: isCurrentlyAssigned && !isFinalState, canApprove: isCurrentlyAssigned && !isFinalState,
canReject: isCurrentlyAssigned && !isFinalState, canReject: isCurrentlyAssigned && !isFinalState,
canHold: isCurrentlyAssigned && !isFinalState, canSendBack: canSendBackOrRevoke,
canRevoke: canSendBackOrRevoke,
isFinalState isFinalState
}; };
}; };
const permissions = getConstitutionalPermissions(); const permissions = getConstitutionalPermissions();
const handleAction = (type: 'approve' | 'reject' | 'hold') => { const dealerProfile = request.dealer?.dealerProfile;
const onboardingApplication = dealerProfile?.application;
const approvedLoiRequest = (onboardingApplication?.loiRequests || []).find((r: any) =>
/approved/i.test(String(r?.status || ''))
);
const approvedLoaRequest = (onboardingApplication?.loaRequests || []).find((r: any) =>
/approved/i.test(String(r?.status || ''))
);
/** `dealers.loi_date` / `dealers.loa_date` (API), else approved LOI/LOA workflow timestamps */
const establishmentLoiDate = dealerProfile?.loiDate ?? approvedLoiRequest?.approvedAt;
const establishmentLoaDate = dealerProfile?.loaDate ?? approvedLoaRequest?.approvedAt;
const handleAction = (type: 'approve' | 'reject' | 'sendBack' | 'revoke') => {
setActionType(type); setActionType(type);
setIsActionDialogOpen(true); setIsActionDialogOpen(true);
}; };
const handleSubmitAction = async (e: React.FormEvent) => { const handleSubmitAction = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const remarksRequired = actionType === 'sendBack' || actionType === 'revoke';
if (remarksRequired && !String(comments || '').trim()) {
toast.error('Remarks are required for Send Back and Revoke (SRS §12.2.3).');
return;
}
try { try {
setIsActionLoading(true); setIsActionLoading(true);
const action = actionType === 'approve' ? 'approve' : actionType === 'reject' ? 'reject' : 'hold'; const actionLabel =
const response = await API.updateConstitutionalChange(requestId, action, { actionType === 'approve' ? 'Approve' :
actionType === 'reject' ? 'Reject' :
actionType === 'sendBack' ? 'Send Back' :
'Revoke';
const response = await API.updateConstitutionalChange(requestId, actionLabel, {
comments comments
}) as any; }) as any;
if (response.data.success) { if (response.data.success) {
const actionText = actionType === 'approve' ? 'approved' : actionType === 'reject' ? 'rejected' : 'put on hold'; const actionText =
actionType === 'approve' ? 'approved' :
actionType === 'reject' ? 'rejected' :
actionType === 'sendBack' ? 'sent back' :
'revoked';
toast.success(`Request ${actionText} successfully`); toast.success(`Request ${actionText} successfully`);
setIsActionDialogOpen(false); setIsActionDialogOpen(false);
setComments(''); setComments('');
fetchRequestDetails(); fetchRequestDetails();
fetchAuditLogs();
} }
} catch (error) { } catch (error) {
console.error('Submit action error:', error); console.error('Submit action error:', error);
@ -303,7 +418,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
setIsUploadDialogOpen(false); setIsUploadDialogOpen(false);
setSelectedDocType(null); setSelectedDocType(null);
setUploadFile(null); setUploadFile(null);
fetchRequestDetails(); await fetchRequestDetails({ silent: true });
} else { } else {
toast.error('Failed to upload document'); toast.error('Failed to upload document');
} }
@ -338,6 +453,42 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
} }
}; };
const submitRejectDocument = async () => {
if (rejectDocIndex == null || !String(rejectDocReason).trim()) {
toast.error('Please enter a rejection reason (SRS document verification).');
return;
}
try {
setIsRejectingDoc(true);
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
const updatedDocs = existingDocs.map((doc: any, index: number) => {
if (index !== rejectDocIndex) return doc;
return {
...doc,
status: 'Rejected',
rejectedOn: new Date().toISOString(),
rejectedBy: (currentUser as any)?.fullName || 'System',
rejectionReason: rejectDocReason.trim()
};
});
const response = await API.uploadConstitutionalDocuments(requestId, updatedDocs) as any;
if (response.data?.success) {
toast.success('Document marked as rejected');
setRejectDocDialogOpen(false);
setRejectDocIndex(null);
setRejectDocReason('');
await fetchRequestDetails({ silent: true });
} else {
toast.error('Failed to reject document');
}
} catch (error) {
console.error('Reject document error:', error);
toast.error('Failed to reject document');
} finally {
setIsRejectingDoc(false);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
@ -432,11 +583,11 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div> </div>
<div> <div>
<p className="text-slate-600 text-sm mb-1">LOI Date</p> <p className="text-slate-600 text-sm mb-1">LOI Date</p>
<p className="text-slate-900">{request.dealer?.dealerProfile?.loiDate ? formatDateTime(request.dealer.dealerProfile.loiDate, 'date') : 'N/A'}</p> <p className="text-slate-900">{establishmentLoiDate ? formatDateTime(establishmentLoiDate, 'date') : 'N/A'}</p>
</div> </div>
<div> <div>
<p className="text-slate-600 text-sm mb-1">LOA Date</p> <p className="text-slate-600 text-sm mb-1">LOA Date</p>
<p className="text-slate-900">{request.dealer?.dealerProfile?.loaDate ? formatDateTime(request.dealer.dealerProfile.loaDate, 'date') : 'N/A'}</p> <p className="text-slate-900">{establishmentLoaDate ? formatDateTime(establishmentLoaDate, 'date') : 'N/A'}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -474,11 +625,35 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div> </div>
</div> </div>
{/* Workflow Stages */} {/* Workflow Stages — SRS §12.2.8 style: Completed / In Progress / Pending */}
{workflowTerminalNegative && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
This request is closed as <strong>{String(request.status)}</strong>. The approval path below is for reference only.
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
{workflowStages.map((stage, index) => { {workflowTerminalNegative ? (
const isCompleted = index < currentStageIndex - 1; <ul className="list-disc space-y-1 pl-5 text-sm text-slate-600">
const isCurrent = index === currentStageIndex - 1; {workflowStages.map((stage) => (
<li key={stage.id}>
<span className="text-slate-900">{stage.name}</span> {stage.role}
</li>
))}
</ul>
) : (
workflowStages.map((stage, index) => {
/**
* While DB stage is still `Submitted`, the filing step is already done; the queue is at ASM.
* Show Submitted as completed and ASM Review as in progress (no extra dealer action).
*/
const atSubmittedGate = request.currentStage === 'Submitted';
const isCompleted =
flowComplete ||
index < currentStageIndex - 1 ||
(atSubmittedGate && index === 0);
const isCurrent =
!flowComplete &&
(atSubmittedGate ? index === 1 : index === currentStageIndex - 1);
const timelineEntry = getLatestStageTimelineEntry(stage.name); const timelineEntry = getLatestStageTimelineEntry(stage.name);
const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks; const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks;
@ -514,7 +689,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{stage.name} {stage.name}
</h4> </h4>
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}> <p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}>
Responsible: {stage.role} {atSubmittedGate && index === 0
? 'Dealer action: filing complete (no further step here).'
: `Responsible: ${stage.role}`}
{atSubmittedGate && index === 1 ? (
<span className="block mt-0.5 text-amber-800/90">
ASM approves to advance the request (first workflow action after submission).
</span>
) : null}
</p> </p>
</div> </div>
<Badge className={ <Badge className={
@ -543,7 +725,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div> </div>
</div> </div>
); );
})} })
)}
</div> </div>
</TabsContent> </TabsContent>
@ -577,26 +760,22 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>Document Type</Label> <Label>Document Type</Label>
<Select <select
value={selectedDocType ? String(selectedDocType) : ''} className="mt-1 flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2"
onValueChange={(value) => setSelectedDocType(Number(value))} value={selectedDocType != null ? String(selectedDocType) : ''}
onChange={(e) => {
const v = e.target.value;
setSelectedDocType(v ? Number(v) : null);
}}
> >
<SelectTrigger className="w-full mt-1"> <option value="">Select document type</option>
<SelectValue placeholder="Select document type" /> {requiredDocs.map((docNum) => (
</SelectTrigger> <option key={docNum} value={String(docNum)}>
<SelectContent> {isDocTypeUploaded(docNum) ? '✅ ' : ''}
{requiredDocs.map((docNum) => ( {documentNames[docNum]}
<SelectItem key={docNum} value={String(docNum)}> </option>
<span className="flex w-full items-center justify-between"> ))}
<span>{documentNames[docNum]}</span> </select>
{uploadedDocNumbers.has(docNum) && (
<CheckCircle2 className="w-4 h-4 text-green-600" />
)}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div> </div>
<div> <div>
<Label>Upload File</Label> <Label>Upload File</Label>
@ -620,31 +799,39 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{requiredDocs.map((docNum) => { {requiredDocs.map((docNum) => {
const uploaded = (request.documents || []).find((d: any) => d.docNumber === docNum || d.name?.includes(documentNames[docNum])); const uploaded = findUploadedForDocNum(docNum);
const isRejected = uploaded && String(uploaded.status) === 'Rejected';
const ok = uploaded && !isRejected;
return ( return (
<div <div
key={docNum} key={docNum}
className={`flex items-center justify-between p-3 rounded-lg border ${ className={`flex items-center justify-between p-3 rounded-lg border ${
uploaded ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200' isRejected ? 'bg-red-50 border-red-200' :
ok ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200'
}`} }`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{uploaded ? ( {isRejected ? (
<AlertCircle className="w-5 h-5 text-red-600" />
) : ok ? (
<CheckCircle2 className="w-5 h-5 text-green-600" /> <CheckCircle2 className="w-5 h-5 text-green-600" />
) : ( ) : (
<AlertCircle className="w-5 h-5 text-slate-400" /> <AlertCircle className="w-5 h-5 text-slate-400" />
)} )}
<div> <div>
<p className={uploaded ? 'text-green-900' : 'text-slate-900'}> <p className={isRejected ? 'text-red-900' : ok ? 'text-green-900' : 'text-slate-900'}>
{documentNames[docNum]} {documentNames[docNum]}
</p> </p>
{uploaded && ( {uploaded && (
<p className="text-green-700 text-sm">{uploaded.fileName || uploaded.name}</p> <p className={isRejected ? 'text-red-700 text-sm' : ok ? 'text-green-700 text-sm' : 'text-slate-600 text-sm'}>
{uploaded.fileName || uploaded.name}
{isRejected && uploaded.rejectionReason ? `${uploaded.rejectionReason}` : ''}
</p>
)} )}
</div> </div>
</div> </div>
{uploaded ? ( {uploaded ? (
<Badge className="bg-green-100 text-green-700 border-green-300"> <Badge className={getStatusColor(uploaded.status)}>
{uploaded.status} {uploaded.status}
</Badge> </Badge>
) : ( ) : (
@ -704,14 +891,28 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<Button size="sm" variant="outline" className="h-8 w-8 p-0" title="Download document"> <Button size="sm" variant="outline" className="h-8 w-8 p-0" title="Download document">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
{doc.status !== 'Verified' && currentUser?.role !== 'Dealer' && ( {doc.status !== 'Verified' && doc.status !== 'Rejected' && currentUser?.role !== 'Dealer' && (
<Button <>
size="sm" <Button
className="bg-green-600 hover:bg-green-700" size="sm"
onClick={() => handleVerifyDocument(doc, index)} className="bg-green-600 hover:bg-green-700"
> onClick={() => handleVerifyDocument(doc, index)}
Verify >
</Button> Verify
</Button>
<Button
size="sm"
variant="outline"
className="border-red-300 text-red-700 hover:bg-red-50"
onClick={() => {
setRejectDocIndex(index);
setRejectDocReason('');
setRejectDocDialogOpen(true);
}}
>
Reject
</Button>
</>
)} )}
</div> </div>
</TableCell> </TableCell>
@ -733,17 +934,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{/* History Tab */} {/* History Tab */}
<TabsContent value="history" className="mt-0"> <TabsContent value="history" className="mt-0">
<div className="space-y-4"> <div className="space-y-4">
{auditLogs.map((entry: any, index: number) => ( {historyEntries.map((entry: any, index: number) => {
<div key={index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0"> const pres = getConstitutionalHistoryPresentation(entry);
return (
<div key={entry.id || index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${ <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
(entry.action)?.toLowerCase().includes('approve') || (entry.action)?.toLowerCase().includes('complete') ? 'bg-green-100' : pres.variant === 'success' ? 'bg-green-100' :
(entry.action)?.toLowerCase().includes('pending') || (entry.action)?.toLowerCase().includes('progress') || (entry.action)?.toLowerCase().includes('update') ? 'bg-amber-100' : pres.variant === 'danger' ? 'bg-red-100' :
pres.variant === 'pending' ? 'bg-amber-100' :
'bg-slate-100' 'bg-slate-100'
}`}> }`}>
{(entry.action)?.toLowerCase().includes('approve') || (entry.action)?.toLowerCase().includes('complete') ? ( {pres.variant === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-green-600" /> <CheckCircle2 className="w-5 h-5 text-green-600" />
) : ( ) : pres.variant === 'danger' ? (
<AlertCircle className="w-5 h-5 text-red-600" />
) : pres.variant === 'pending' ? (
<Clock className="w-5 h-5 text-amber-600" /> <Clock className="w-5 h-5 text-amber-600" />
) : (
<Clock className="w-5 h-5 text-slate-500" />
)} )}
</div> </div>
<div className="flex-1"> <div className="flex-1">
@ -752,16 +960,17 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<h4 className="text-slate-900">{entry.stage || entry.action}</h4> <h4 className="text-slate-900">{entry.stage || entry.action}</h4>
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p> <p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
</div> </div>
<Badge className={getStatusColor(entry.action)}> <Badge className={getStatusColor(pres.badge)}>
{entry.action} {pres.badge}
</Badge> </Badge>
</div> </div>
<p className="text-slate-600 text-sm mt-2">{entry.description || entry.remarks || 'No remarks provided'}</p> <p className="text-slate-600 text-sm mt-2">{entry.description || entry.remarks || 'No remarks provided'}</p>
<p className="text-slate-500 text-sm mt-1">{formatDateTime(entry.timestamp)}</p> <p className="text-slate-500 text-sm mt-1">{formatDateTime(entry.timestamp || entry.createdAt)}</p>
</div> </div>
</div> </div>
))} );
{auditLogs.length === 0 && ( })}
{historyEntries.length === 0 && (
<div className="text-center py-8 text-slate-500"> <div className="text-center py-8 text-slate-500">
No history found No history found
</div> </div>
@ -821,11 +1030,43 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
) : ( ) : (
<AlertCircle className="w-4 h-4 mr-2" /> <AlertCircle className="w-4 h-4 mr-2" />
)} )}
Reject Request Reject proposal
</Button> </Button>
)} )}
{!permissions.canApprove && !permissions.canReject && ( {permissions.canSendBack && (
<Button
variant="outline"
className="w-full border-amber-300 text-amber-900 hover:bg-amber-50"
onClick={() => handleAction('sendBack')}
disabled={isActionLoading}
>
{isActionLoading && actionType === 'sendBack' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Undo2 className="w-4 h-4 mr-2" />
)}
Send back
</Button>
)}
{permissions.canRevoke && (
<Button
variant="outline"
className="w-full border-orange-300 text-orange-900 hover:bg-orange-50"
onClick={() => handleAction('revoke')}
disabled={isActionLoading}
>
{isActionLoading && actionType === 'revoke' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Ban className="w-4 h-4 mr-2" />
)}
Revoke request
</Button>
)}
{!permissions.canApprove && !permissions.canReject && !permissions.canSendBack && !permissions.canRevoke && (
<div className="text-center py-4 bg-slate-50 rounded-lg border border-dashed border-slate-200"> <div className="text-center py-4 bg-slate-50 rounded-lg border border-dashed border-slate-200">
<p className="text-slate-500 text-xs px-4"> <p className="text-slate-500 text-xs px-4">
{permissions.isFinalState ? 'This request is finalized.' : 'No actions available for your role at this stage.'} {permissions.isFinalState ? 'This request is finalized.' : 'No actions available for your role at this stage.'}
@ -836,11 +1077,12 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div className="border-t border-slate-200 pt-3 mt-3"> <div className="border-t border-slate-200 pt-3 mt-3">
<Button <Button
variant="outline" variant="outline"
className="w-full border-blue- blue-700 hover:bg-blue-50" className="w-full border-blue-700 text-blue-800 hover:bg-blue-50"
onClick={() => navigate(`/worknotes/constitutional-change/${requestId}`, { onClick={() => navigate(`/worknotes/constitutional/${request?.id || requestId}`, {
state: { state: {
requestType: 'constitutional',
applicationName: request?.outlet?.name || 'Constitutional Change', applicationName: request?.outlet?.name || 'Constitutional Change',
registrationNumber: requestId || '', registrationNumber: request?.requestId || requestId || '',
participants: request?.participants || [] participants: request?.participants || []
} }
})} })}
@ -859,25 +1101,30 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{actionType === 'approve' ? 'Approve Request' : {actionType === 'approve' ? 'Approve request' :
actionType === 'reject' ? 'Reject Request' : actionType === 'reject' ? 'Reject proposal' :
'Put Request on Hold'} actionType === 'sendBack' ? 'Send back to previous stage' :
'Revoke request'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Please provide comments for this action. This will be recorded in the audit trail. {actionType === 'sendBack' || actionType === 'revoke'
? 'SRS §12.2.3: remarks are mandatory and will be posted to Work Notes for Send Back / Revoke.'
: 'Comments will be recorded in the audit trail and work notes where applicable.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmitAction} className="space-y-4"> <form onSubmit={handleSubmitAction} className="space-y-4">
<div> <div>
<Label htmlFor="comments">Comments *</Label> <Label htmlFor="comments">
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'}
</Label>
<Textarea <Textarea
id="comments" id="comments"
value={comments} value={comments}
onChange={(e) => setComments(e.target.value)} onChange={(e) => setComments(e.target.value)}
placeholder="Enter your comments..." placeholder={actionType === 'sendBack' || actionType === 'revoke' ? 'Enter mandatory remarks for Work Notes…' : 'Enter your comments…'}
rows={4} rows={4}
required required={actionType !== 'approve'}
/> />
</div> </div>
@ -894,7 +1141,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
className={ className={
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' : actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' :
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' : actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' :
'bg-amber-600 hover:bg-amber-700' actionType === 'sendBack' ? 'bg-amber-600 hover:bg-amber-700' :
'bg-orange-600 hover:bg-orange-700'
} }
disabled={isActionLoading} disabled={isActionLoading}
> >
@ -906,7 +1154,8 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
) : ( ) : (
actionType === 'approve' ? 'Approve' : actionType === 'approve' ? 'Approve' :
actionType === 'reject' ? 'Reject' : actionType === 'reject' ? 'Reject' :
'Put on Hold' actionType === 'sendBack' ? 'Send back' :
'Revoke'
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@ -914,6 +1163,40 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={rejectDocDialogOpen} onOpenChange={setRejectDocDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject document</DialogTitle>
<DialogDescription>
Per SRS relocation-style verification states, mark this upload as Rejected and provide a reason for the dealer.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Label htmlFor="rejectReason">Rejection reason *</Label>
<Textarea
id="rejectReason"
rows={4}
value={rejectDocReason}
onChange={(e) => setRejectDocReason(e.target.value)}
placeholder="Explain what must be corrected or re-uploaded…"
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRejectDocDialogOpen(false)}>
Cancel
</Button>
<Button
type="button"
variant="destructive"
disabled={isRejectingDoc}
onClick={() => void submitRejectDocument()}
>
{isRejectingDoc ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Confirm reject'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -5,7 +5,6 @@ import { Button } from '../ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog';
import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
import { Textarea } from '../ui/textarea'; import { Textarea } from '../ui/textarea';
@ -14,17 +13,18 @@ import { User as UserType } from '../../lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '../../api/API'; import { API } from '../../api/API';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '../ui/utils';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
interface ConstitutionalChangePageProps { interface ConstitutionalChangePageProps {
currentUser: UserType | null; currentUser?: UserType | null;
onViewDetails: (id: string) => void; onViewDetails: (id: string) => void;
} }
// Document requirements mapping // Document requirements mapping (keys = DB `changeType` ENUM values)
const documentRequirements: Record<string, number[]> = { const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16], 'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 16], 'LLP': [1, 2, 3, 7, 8, 9, 10, 16],
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16], 'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Proprietorship': [1, 2, 3, 10, 16] 'Proprietorship': [1, 2, 3, 10, 16]
}; };
@ -60,15 +60,24 @@ const getTypeColor = (type: string) => {
switch(type) { switch(type) {
case 'Proprietorship': return 'bg-purple-100 text-purple-700 border-purple-300'; case 'Proprietorship': return 'bg-purple-100 text-purple-700 border-purple-300';
case 'Partnership': return 'bg-blue-100 text-blue-700 border-blue-300'; case 'Partnership': return 'bg-blue-100 text-blue-700 border-blue-300';
case 'LLP': return 'bg-indigo-100 text-indigo-700 border-indigo-300'; case 'LLP':
case 'Pvt Ltd': return 'bg-cyan-100 text-cyan-700 border-cyan-300'; case 'LLP Conversion':
return 'bg-indigo-100 text-indigo-700 border-indigo-300';
case 'Private Limited':
case 'Pvt Ltd':
return 'bg-cyan-100 text-cyan-700 border-cyan-300';
default: return 'bg-slate-100 text-slate-700 border-slate-300'; default: return 'bg-slate-100 text-slate-700 border-slate-300';
} }
}; };
export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChangePageProps) { export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChangePageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dealerCode, setDealerCode] = useState(''); const [structureTargets, setStructureTargets] = useState<{ value: string; label: string }[]>([]);
const [dealers, setDealers] = useState<any[]>([]);
const [allOutlets, setAllOutlets] = useState<any[]>([]);
const [selectedDealerUserId, setSelectedDealerUserId] = useState('');
const [selectedOutletId, setSelectedOutletId] = useState('');
const [outletsForDealer, setOutletsForDealer] = useState<any[]>([]);
const [dealerData, setDealerData] = useState<any>(null); const [dealerData, setDealerData] = useState<any>(null);
const [targetType, setTargetType] = useState(''); const [targetType, setTargetType] = useState('');
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
@ -76,11 +85,43 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
const [requests, setRequests] = useState<any[]>([]); const [requests, setRequests] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [dialogDataLoading, setDialogDataLoading] = useState(false);
useEffect(() => { useEffect(() => {
fetchRequests(); fetchRequests();
}, []); }, []);
useEffect(() => {
if (!isDialogOpen) return;
let cancelled = false;
(async () => {
try {
setDialogDataLoading(true);
const [metaRes, dealersRes, outletsRes] = await Promise.all([
API.getConstitutionalChangeMeta() as any,
API.getDealers({ onboarded: 'true' }) as any,
API.getOutlets() as any
]);
if (cancelled) return;
if (metaRes.data?.success && Array.isArray(metaRes.data.structureTargets)) {
setStructureTargets(metaRes.data.structureTargets);
}
const dealerRows = dealersRes.data?.data ?? dealersRes.data?.dealers ?? [];
setDealers(Array.isArray(dealerRows) ? dealerRows : []);
const outlets = outletsRes.data?.outlets ?? [];
setAllOutlets(Array.isArray(outlets) ? outlets : []);
} catch (e) {
console.error(e);
toast.error('Failed to load dealers or form options');
} finally {
if (!cancelled) setDialogDataLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [isDialogOpen]);
const fetchRequests = async () => { const fetchRequests = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
@ -96,33 +137,39 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
} }
}; };
const handleDealerCodeChange = async (code: string) => { const handleDealerUserSelect = (dealerUserId: string) => {
setDealerCode(code); setSelectedDealerUserId(dealerUserId);
if (code.length >= 5) { setSelectedOutletId('');
try { setTargetType('');
const response = await API.getOutletByCode(code) as any; setRequiredDocs([]);
if (response.data.success && response.data.outlet) { if (!dealerUserId) {
const outlet = response.data.outlet;
setDealerData({
id: outlet.id,
dealerName: outlet.name,
address: outlet.address,
dealershipName: outlet.name,
gst: outlet.gstNumber || 'N/A',
currentType: outlet.type || 'Proprietorship',
region: outlet.region || 'N/A',
zone: outlet.zone || 'N/A'
});
toast.success('Dealer details loaded successfully');
} else {
setDealerData(null);
}
} catch (error) {
setDealerData(null);
}
} else {
setDealerData(null); setDealerData(null);
setOutletsForDealer([]);
return;
} }
const row = dealers.find((d: any) => d.user?.id === dealerUserId);
if (!row?.user?.id) {
setDealerData(null);
setOutletsForDealer([]);
return;
}
const norm = normalizeDealerProfileConstitution(row.constitutionType);
const outs = allOutlets.filter((o: any) => String(o.dealerId) === String(dealerUserId));
setOutletsForDealer(outs);
if (outs.length === 1) {
setSelectedOutletId(outs[0].id);
}
setDealerData({
dealerUserId: row.user.id,
dealerName: row.businessName,
dealershipName: row.legalName || row.businessName,
address: row.registeredAddress || row.application?.preferredLocation || '—',
gst: row.gstNumber || 'N/A',
currentType: norm,
dealerCode: row.dealerCode?.dealerCode || '—',
region: '—',
zone: '—'
});
}; };
const handleTargetTypeChange = (type: string) => { const handleTargetTypeChange = (type: string) => {
@ -133,13 +180,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
const handleSubmitRequest = async (e: React.FormEvent) => { const handleSubmitRequest = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!dealerData) { if (!dealerData?.dealerUserId) {
toast.error('Please enter a valid dealer code'); toast.error('Please select a dealer');
return;
}
if (outletsForDealer.length > 1 && !selectedOutletId) {
toast.error('Please select an outlet for this dealer');
return; return;
} }
if (!targetType) { if (!targetType) {
toast.error('Please select target dealership type'); toast.error('Please select proposed constitution type');
return; return;
} }
@ -149,17 +201,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
} }
if (dealerData.currentType === targetType) { if (dealerData.currentType === targetType) {
toast.error('Target type cannot be same as current type'); toast.error('Proposed type cannot be the same as the current constitution');
return; return;
} }
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const payload = { const payload = {
outletId: dealerData.id, forDealerUserId: dealerData.dealerUserId,
outletId: selectedOutletId || undefined,
changeType: targetType, changeType: targetType,
description: reason, reason: reason.trim(),
newEntityDetails: {} currentConstitution: dealerData.currentType
}; };
const response = await API.createConstitutionalChange(payload) as any; const response = await API.createConstitutionalChange(payload) as any;
@ -169,15 +222,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
fetchRequests(); fetchRequests();
// Reset form // Reset form
setDealerCode(''); setSelectedDealerUserId('');
setSelectedOutletId('');
setOutletsForDealer([]);
setDealerData(null); setDealerData(null);
setTargetType(''); setTargetType('');
setReason(''); setReason('');
setRequiredDocs([]); setRequiredDocs([]);
} }
} catch (error) { } catch (error: any) {
console.error('Submit request error:', error); console.error('Submit request error:', error);
toast.error('Failed to submit request'); const msg = error?.response?.data?.message || 'Failed to submit request';
toast.error(msg);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -245,33 +301,77 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmitRequest} className="space-y-4"> <form onSubmit={handleSubmitRequest} className="space-y-4">
{/* Dealer Code */} {dialogDataLoading && (
<div className="flex items-center gap-2 text-sm text-slate-500 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
Loading dealers and options
</div>
)}
{/* Dealer (onboarded) — same POST as dealer self-service, with forDealerUserId */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dealerCode">Dealer Code *</Label> <Label htmlFor="dealerUser">Dealer *</Label>
<Input <Select
id="dealerCode" value={selectedDealerUserId}
placeholder="Enter dealer code (e.g., DL-MH-001)" onValueChange={handleDealerUserSelect}
value={dealerCode} disabled={dialogDataLoading}
onChange={(e) => handleDealerCodeChange(e.target.value)}
required required
/> >
<SelectTrigger id="dealerUser">
<SelectValue placeholder="Select dealer account" />
</SelectTrigger>
<SelectContent className="max-h-72">
{dealers
.filter((d: any) => d.user?.id)
.map((d: any) => (
<SelectItem key={d.user.id} value={d.user.id}>
{(d.dealerCode?.dealerCode || 'No code') + ' — ' + (d.businessName || d.legalName || 'Dealer')}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-slate-500">
Internal users create the request on behalf of the selected dealer; the workflow uses the same endpoint as dealer-initiated requests.
</p>
</div> </div>
{outletsForDealer.length > 1 && (
<div className="space-y-2">
<Label htmlFor="outletPick">Outlet *</Label>
<Select value={selectedOutletId} onValueChange={setSelectedOutletId} required>
<SelectTrigger id="outletPick">
<SelectValue placeholder="Select outlet" />
</SelectTrigger>
<SelectContent>
{outletsForDealer.map((o: any) => (
<SelectItem key={o.id} value={o.id}>
{o.code} {o.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Auto-populated Dealer Details */} {/* Auto-populated Dealer Details */}
{dealerData && ( {dealerData && (
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-3"> <div className="bg-slate-50 border border-slate-200 rounded-lg p-4 space-y-3">
<h3 className="text-slate-900">Dealer Details</h3> <h3 className="text-slate-900">Dealer Details</h3>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-slate-600">Dealer Code:</span>
<p className="text-slate-900">{dealerData.dealerCode}</p>
</div>
<div> <div>
<span className="text-slate-600">Dealer Name:</span> <span className="text-slate-600">Dealer Name:</span>
<p className="text-slate-900">{dealerData.dealerName}</p> <p className="text-slate-900">{dealerData.dealerName}</p>
</div> </div>
<div> <div>
<span className="text-slate-600">Dealership Name:</span> <span className="text-slate-600">Legal / display name:</span>
<p className="text-slate-900">{dealerData.dealershipName}</p> <p className="text-slate-900">{dealerData.dealershipName}</p>
</div> </div>
<div> <div>
<span className="text-slate-600">Location:</span> <span className="text-slate-600">Address:</span>
<p className="text-slate-900">{dealerData.address}</p> <p className="text-slate-900">{dealerData.address}</p>
</div> </div>
<div> <div>
@ -279,31 +379,30 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
<p className="text-slate-900">{dealerData.gst}</p> <p className="text-slate-900">{dealerData.gst}</p>
</div> </div>
<div> <div>
<span className="text-slate-600">Current Type:</span> <span className="text-slate-600">Current constitution (from profile):</span>
<Badge className={getTypeColor(dealerData.currentType)}> <Badge className={getTypeColor(dealerData.currentType)}>
{dealerData.currentType} {dealerData.currentType}
</Badge> </Badge>
</div> </div>
<div>
<span className="text-slate-600">Region/Zone:</span>
<p className="text-slate-900">{dealerData.region} / {dealerData.zone}</p>
</div>
</div> </div>
</div> </div>
)} )}
{/* Target Dealership Type */} {/* Proposed constitution — ENUM-aligned options from API */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="targetType">Target Dealership Type *</Label> <Label htmlFor="targetType">Proposed constitution *</Label>
<Select value={targetType} onValueChange={handleTargetTypeChange} required> <Select value={targetType} onValueChange={handleTargetTypeChange} required>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select target dealership type" /> <SelectValue placeholder="Select proposed constitution" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Proprietorship">Proprietorship</SelectItem> {structureTargets
<SelectItem value="Partnership">Partnership</SelectItem> .filter((opt) => opt.value !== dealerData?.currentType)
<SelectItem value="LLP">LLP (Limited Liability Partnership)</SelectItem> .map((opt) => (
<SelectItem value="Pvt Ltd">Pvt Ltd (Private Limited)</SelectItem> <SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
{dealerData && targetType && dealerData.currentType === targetType && ( {dealerData && targetType && dealerData.currentType === targetType && (
@ -350,7 +449,14 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
<Button <Button
type="submit" type="submit"
className="bg-amber-600 hover:bg-amber-700" className="bg-amber-600 hover:bg-amber-700"
disabled={!dealerData || !targetType || (dealerData && dealerData.currentType === targetType) || isSubmitting} disabled={
!dealerData ||
!targetType ||
(dealerData && dealerData.currentType === targetType) ||
(outletsForDealer.length > 1 && !selectedOutletId) ||
dialogDataLoading ||
isSubmitting
}
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@ -444,8 +550,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}> <Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
{request.outlet?.type || 'Proprietorship'} {request.currentConstitution || '—'}
</Badge> </Badge>
<ArrowRight className="w-4 h-4 text-slate-400" /> <ArrowRight className="w-4 h-4 text-slate-400" />
<Badge className={getTypeColor(request.changeType)}> <Badge className={getTypeColor(request.changeType)}>
@ -522,8 +628,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}> <Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
{request.outlet?.type || 'Proprietorship'} {request.currentConstitution || '—'}
</Badge> </Badge>
<ArrowRight className="w-4 h-4 text-slate-400" /> <ArrowRight className="w-4 h-4 text-slate-400" />
<Badge className={getTypeColor(request.changeType)}> <Badge className={getTypeColor(request.changeType)}>
@ -597,8 +703,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}> <Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
{request.outlet?.type || 'Proprietorship'} {request.currentConstitution || '—'}
</Badge> </Badge>
<ArrowRight className="w-4 h-4 text-slate-400" /> <ArrowRight className="w-4 h-4 text-slate-400" />
<Badge className={getTypeColor(request.changeType)}> <Badge className={getTypeColor(request.changeType)}>
@ -677,8 +783,8 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge className={getTypeColor(request.outlet?.type || 'Proprietorship')}> <Badge className={getTypeColor(request.currentConstitution || 'Proprietorship')}>
{request.outlet?.type || 'Proprietorship'} {request.currentConstitution || '—'}
</Badge> </Badge>
<ArrowRight className="w-4 h-4 text-slate-400" /> <ArrowRight className="w-4 h-4 text-slate-400" />
<Badge className={getTypeColor(request.changeType)}> <Badge className={getTypeColor(request.changeType)}>

View File

@ -1,8 +1,8 @@
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, Navigation, MapPin, MessageSquare, Loader2 } from 'lucide-react'; import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, Navigation, MapPin, MessageSquare, Loader2, Calendar, Reply, Ban } from 'lucide-react';
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '../ui/DocumentPreviewModal';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '../ui/utils';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table';
@ -36,6 +36,82 @@ const workflowStages = [
{ id: 10, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' } { id: 10, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' }
]; ];
/** Map API stage / status label to 1-based workflow row index; 0 = unknown; length+1 = finished */
function relocationStageLabelToOrdinal(label: string | null | undefined): number {
const raw = String(label || '')
.trim()
.replace(/\u00a0/g, ' ');
if (!raw || raw === 'Submitted') return 0;
const lower = raw.toLowerCase();
if (['completed', 'relocation complete', 'closed'].includes(lower)) {
return workflowStages.length + 1;
}
const pendingMatch = raw.match(/^pending\s+(.+)$/i);
const core = (pendingMatch ? pendingMatch[1] : raw).trim();
let idx = workflowStages.findIndex(
(s) => s.name === core || s.name === raw || s.key === core || s.key === raw
);
if (idx < 0) {
idx = workflowStages.findIndex(
(s) =>
core.toLowerCase().includes(s.name.toLowerCase()) ||
s.name.toLowerCase().includes(core.toLowerCase())
);
}
return idx >= 0 ? idx + 1 : 0;
}
/** Furthest workflow step implied by timeline entries (ignores document-only rows). */
function relocationTimelineMaxOrdinal(entries: any[]): number {
let max = 0;
for (const e of entries || []) {
const action = String(e?.action || '');
if (/document\s*verified/i.test(action)) continue;
if (/document/i.test(action) && /upload/i.test(action)) continue;
for (const lab of [e?.targetStage, e?.stage].filter(Boolean)) {
const o = relocationStageLabelToOrdinal(lab as string);
if (o > max) max = o;
}
}
return max;
}
/** Furthest stage implied by relocation audit rows (same idea as timeline; helps when timeline JSON is stale). */
function relocationAuditMaxOrdinal(logs: any[]): number {
let max = 0;
for (const log of logs || []) {
const action = String(log?.action || '');
if (action === 'DOCUMENT_UPLOADED' || action === 'DOCUMENT_VERIFIED') continue;
const d = log?.details || log?.newData || {};
for (const lab of [d.targetStage, d.stage, log?.stage].filter(Boolean)) {
const o = relocationStageLabelToOrdinal(String(lab));
if (o > max) max = o;
}
}
return max;
}
/** Timeline rows for a workflow stage (matches resignation: filter by `stage` at action time). */
function getRelocationTimelineEntriesForStage(
entries: any[],
stage: { name: string; key: string },
stageIndex: number
): any[] {
const list = Array.isArray(entries) ? [...entries] : [];
const filtered = list.filter((t: any) => {
const src = String(t?.stage || '').trim();
if (src === stage.name || src === stage.key) return true;
if (stageIndex === 0 && (src === 'Submitted' || src === 'Request submitted')) return true;
return false;
});
filtered.sort((a, b) => {
const ta = new Date(a?.timestamp || a?.createdAt || 0).getTime();
const tb = new Date(b?.timestamp || b?.createdAt || 0).getTime();
return ta - tb;
});
return filtered;
}
// Required documents configuration // Required documents configuration
const requiredDocuments = [ const requiredDocuments = [
'Property documents for new location', 'Property documents for new location',
@ -55,11 +131,27 @@ const requiredDocuments = [
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
if (status === 'Completed' || status === 'Verified' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300'; if (status === 'Completed' || status === 'Verified' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300'; if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300'; if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300'; if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
return 'bg-slate-100 text-slate-700 border-slate-300'; return 'bg-slate-100 text-slate-700 border-slate-300';
}; };
const getApiErrorMessage = (error: any, fallback: string) => {
const responseData = error?.response?.data || error?.data;
if (responseData?.readiness) {
const missing = responseData.readiness?.missingUploads || [];
const pending = responseData.readiness?.pendingVerification || [];
const details = [
missing.length ? `Missing: ${missing.join(', ')}` : '',
pending.length ? `Pending verification: ${pending.join(', ')}` : ''
]
.filter(Boolean)
.join(' | ');
return details ? `${responseData.message || fallback} (${details})` : (responseData.message || fallback);
}
return responseData?.message || error?.message || fallback;
};
export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) { export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [request, setRequest] = useState<any>(null); const [request, setRequest] = useState<any>(null);
@ -67,7 +159,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false); const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold'>('approve'); const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold' | 'send_back' | 'revoke'>('approve');
const [comments, setComments] = useState(''); const [comments, setComments] = useState('');
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false); const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [eorChecklist, setEorChecklist] = useState<any>(null); const [eorChecklist, setEorChecklist] = useState<any>(null);
@ -96,10 +188,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
} }
}; };
const fetchEorChecklist = async () => { const fetchEorChecklist = async (relocationUuid?: string) => {
try { try {
setIsEorLoading(true); setIsEorLoading(true);
const response = await API.getEorChecklistForRelocation(requestId) as any; const id = relocationUuid || request?.id || requestId;
const response = await API.getEorChecklistForRelocation(id) as any;
if (response.data.success) { if (response.data.success) {
setEorChecklist(response.data.data); setEorChecklist(response.data.data);
} }
@ -125,7 +218,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
} }
} catch (error) { } catch (error) {
console.error('Update EOR item error:', error); console.error('Update EOR item error:', error);
toast.error('Failed to update item'); toast.error(getApiErrorMessage(error, 'Failed to update item'));
} }
}; };
@ -144,7 +237,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
} }
} catch (error) { } catch (error) {
console.error('Submit EOR audit error:', error); console.error('Submit EOR audit error:', error);
toast.error('Failed to submit EOR audit'); toast.error(getApiErrorMessage(error, 'Failed to submit EOR audit'));
} finally { } finally {
setIsSubmittingEor(false); setIsSubmittingEor(false);
} }
@ -155,33 +248,94 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
if (!isSilent) setIsLoading(true); if (!isSilent) setIsLoading(true);
const response = await API.getRelocationRequestById(requestId) as any; const response = await API.getRelocationRequestById(requestId) as any;
if (response.data.success) { if (response.data.success) {
setRequest(response.data.request); const req = response.data.request;
setRequest(req);
// Auto-fetch EOR checklist if in the correct stage const currentStage = req.currentStage;
const currentStage = response.data.request.currentStage; if (
if (currentStage === 'NBH_CLEARANCE_EOR' || currentStage === 'NBH Clearance with EOR' || response.data.request.status === 'Completed') { currentStage === 'NBH_CLEARANCE_EOR' ||
fetchEorChecklist(); currentStage === 'NBH Clearance with EOR' ||
req.status === 'Completed'
) {
fetchEorChecklist(req.id);
} }
} }
} catch (error) { } catch (error) {
console.error('Fetch relocation request details error:', error); console.error('Fetch relocation request details error:', error);
toast.error('Failed to fetch request details'); toast.error(getApiErrorMessage(error, 'Failed to fetch request details'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// Calculate current stage index based on request data /**
const getCurrentStageIndex = () => { * 1-based ordinal from persisted record (currentStage / status) used for Approve/Reject RBAC only.
if (!request) return 0; */
const stageIndex = workflowStages.findIndex(s => const getDbStageOrdinal = () => {
s.key === request.currentStage || if (!request) return 1;
s.name === request.currentStage || if (request.status === 'Completed' || request.currentStage === 'Completed') {
s.name === (request.currentStage?.replace(/_/g, ' ') || '') return workflowStages.length + 1;
}
if (request.currentStage === 'Rejected' || request.status === 'Rejected') {
const tl = [...(request.timeline || [])].filter(Boolean).reverse();
for (const e of tl) {
const st = e.stage;
const idx = workflowStages.findIndex((s) => s.name === st);
if (idx >= 0) return idx + 1;
}
return 1;
}
const stageName = request.currentStage;
const idx = workflowStages.findIndex(
(s) =>
s.name === stageName ||
s.key === stageName ||
s.name.replace(/\s+/g, ' ') === String(stageName || '').replace(/\s+/g, ' ')
); );
return stageIndex !== -1 ? stageIndex + 1 : 1; return idx >= 0 ? idx + 1 : 1;
}; };
const timelineEntries = Array.isArray(request?.timeline) ? request.timeline : [];
const timelineMaxOrdinal = relocationTimelineMaxOrdinal(timelineEntries);
const auditMaxOrdinal = relocationAuditMaxOrdinal(auditLogs);
const dbOrdinal = request ? getDbStageOrdinal() : 1;
/** Workflow list + progress bar: use furthest signal from DB, timeline JSON, or audit API */
const displayOrdinal = request ? Math.max(dbOrdinal, timelineMaxOrdinal, auditMaxOrdinal, 1) : 1;
const workflowProgressMismatch =
Boolean(request) &&
Math.max(timelineMaxOrdinal, auditMaxOrdinal) > dbOrdinal &&
(timelineEntries.length > 0 || auditLogs.length > 0);
const currentStageConfig = request
? workflowStages[Math.min(Math.max(dbOrdinal, 1), workflowStages.length) - 1]
: undefined;
const rawProgressPct = Math.min(100, Math.max(0, Number(request?.progressPercentage) || 0));
const allWorkflowComplete =
request?.status === 'Completed' ||
request?.currentStage === 'Completed' ||
displayOrdinal >= workflowStages.length + 1;
const timelineProgressPct = allWorkflowComplete
? 100
: displayOrdinal > 1
? Math.min(100, Math.round(((displayOrdinal - 1) / workflowStages.length) * 100))
: 0;
const displayProgressPct = allWorkflowComplete ? 100 : Math.max(rawProgressPct, timelineProgressPct);
const missingRequiredDocs = request
? requiredDocuments.filter((doc) => !request.documents?.some((d: any) =>
d.type === doc || (d.name && d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0]))
))
: [];
const pendingVerificationDocs = request
? requiredDocuments.filter((doc) => {
const matched = request.documents?.filter((d: any) =>
d.type === doc || (d.name && d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0]))
) || [];
return matched.length > 0 && !matched.some((d: any) => d.status === 'Verified');
})
: [];
// Helper to find assigned reviewer for a stage // Helper to find assigned reviewer for a stage
const getAssignedReviewer = (stageName: string) => { const getAssignedReviewer = (stageName: string) => {
if (!request || !request.participants || request.participants.length === 0) return null; if (!request || !request.participants || request.participants.length === 0) return null;
@ -196,9 +350,6 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
return participant.user?.fullName || participant.user?.name || participant.user?.role || null; return participant.user?.fullName || participant.user?.name || participant.user?.role || null;
}; };
const currentStageIndex = getCurrentStageIndex();
const currentStageConfig = workflowStages[currentStageIndex - 1];
// Visibility logic for Approve/Reject buttons // Visibility logic for Approve/Reject buttons
const canUserAction = () => { const canUserAction = () => {
if (!request || !currentUser) return false; if (!request || !currentUser) return false;
@ -208,12 +359,25 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
if (isAdmin) return true; if (isAdmin) return true;
// Check if user's role matches the role required for the current stage // Check if user's role matches the role required for the current stage
return currentUser.role === currentStageConfig?.role || currentUser.role === currentStageConfig?.role; return Boolean(currentStageConfig?.role && currentUser.role === currentStageConfig.role);
}; };
const showActions = canUserAction() && request.status !== 'Completed' && request.status !== 'Rejected'; const showActions =
canUserAction() &&
request.status !== 'Completed' &&
request.status !== 'Rejected' &&
request.status !== 'Revoked';
const handleAction = (type: 'approve' | 'reject' | 'hold') => { const canSendBack =
showActions &&
request.currentStage &&
request.currentStage !== 'ASM Review' &&
request.currentStage !== 'Rejected';
const canRevoke = showActions && ['ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Super Admin'].includes(currentUser?.role || '');
const requiresDocGate = request?.currentStage === 'NBH Approval' || request?.currentStage === 'Legal Clearance';
const canApprove = showActions && (!requiresDocGate || (missingRequiredDocs.length === 0 && pendingVerificationDocs.length === 0));
const handleAction = (type: 'approve' | 'reject' | 'hold' | 'send_back' | 'revoke') => {
setActionType(type); setActionType(type);
setIsActionDialogOpen(true); setIsActionDialogOpen(true);
}; };
@ -223,22 +387,36 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
try { try {
setIsSubmitting(true); setIsSubmitting(true);
const action = actionType === 'approve' ? 'APPROVE' : actionType === 'reject' ? 'REJECT' : 'HOLD'; const actionMap: Record<string, string> = {
approve: 'APPROVE',
reject: 'REJECT',
hold: 'HOLD',
send_back: 'SEND_BACK',
revoke: 'REVOKE'
};
const action = actionMap[actionType] || 'APPROVE';
const response = await API.updateRelocationRequest(requestId, action, { remarks: comments }) as any; const response = await API.updateRelocationRequest(requestId, action, { remarks: comments }) as any;
if (response.data.success) { if (response.data.success) {
toast.success(`Request ${actionType}d successfully`); const verb =
actionType === 'send_back'
? 'sent back'
: actionType === 'revoke'
? 'revoked'
: `${actionType}d`;
toast.success(`Request ${verb} successfully`);
setIsActionDialogOpen(false); setIsActionDialogOpen(false);
setComments(''); setComments('');
fetchRequestDetails(); fetchRequestDetails();
fetchAuditLogs();
// If moving to NBH Clearance EOR, fetch the checklist // If moving to NBH Clearance EOR, fetch the checklist
if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE') { if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE' && request?.id) {
fetchEorChecklist(); fetchEorChecklist(request.id);
} }
} }
} catch (error) { } catch (error) {
console.error('Submit action error:', error); console.error('Submit action error:', error);
toast.error('Failed to submit action'); toast.error(getApiErrorMessage(error, 'Failed to submit action'));
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -266,10 +444,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
setIsUploadDialogOpen(false); setIsUploadDialogOpen(false);
setSelectedFile(null); setSelectedFile(null);
fetchRequestDetails(true); fetchRequestDetails(true);
fetchAuditLogs();
} }
} catch (error) { } catch (error) {
console.error('Upload document error:', error); console.error('Upload document error:', error);
toast.error('Failed to upload document'); toast.error(getApiErrorMessage(error, 'Failed to upload document'));
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
@ -281,10 +460,25 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
if (response.data.success) { if (response.data.success) {
toast.success('Document verified successfully'); toast.success('Document verified successfully');
fetchRequestDetails(true); // Silent refresh fetchRequestDetails(true); // Silent refresh
fetchAuditLogs();
} }
} catch (error) { } catch (error) {
console.error('Verify document error:', error); console.error('Verify document error:', error);
toast.error('Failed to verify document'); toast.error(getApiErrorMessage(error, 'Failed to verify document'));
}
};
const handleRejectDocument = async (documentId: string) => {
try {
const response = await API.rejectRelocationDocument(requestId, documentId, { remarks: 'Rejected by reviewer' }) as any;
if (response.data.success) {
toast.success('Document rejected successfully');
fetchRequestDetails(true);
fetchAuditLogs();
}
} catch (error) {
console.error('Reject document error:', error);
toast.error(getApiErrorMessage(error, 'Failed to reject document'));
} }
}; };
@ -404,7 +598,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<p className="text-slate-600 text-sm mb-1">Request Information</p> <p className="text-slate-600 text-sm mb-1">Request Information</p>
<p className="text-slate-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</p> <p className="text-slate-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</p>
<p className="text-slate-600 text-sm">By: {request.dealer?.fullName}</p> <p className="text-slate-600 text-sm">By: {request.dealer?.fullName}</p>
<p className="text-slate-900 text-sm mt-2">Current Stage: {request.currentStage.replace(/_/g, ' ')}</p> <p className="text-slate-900 text-sm mt-2">
Current Stage: {String(request.currentStage || '').replace(/_/g, ' ')}
</p>
</div> </div>
</div> </div>
@ -440,52 +636,98 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-slate-900">Overall Progress</span> <span className="text-slate-900">Overall Progress</span>
<span className="text-slate-600">{request.progressPercentage}%</span> <span className="text-slate-600">{displayProgressPct}%</span>
</div> </div>
<div className="h-3 bg-slate-200 rounded-full overflow-hidden"> <div className="h-3 bg-slate-200 rounded-full overflow-hidden">
<div <div
className="h-full bg-amber-600 transition-all duration-500" className="h-full bg-amber-600 transition-all duration-500"
style={{ width: `${request.progressPercentage}%` }} style={{ width: `${displayProgressPct}%` }}
/> />
</div> </div>
</div> </div>
{/* Workflow Stages */} {workflowProgressMismatch && (
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<span className="font-medium">Progress sync:</span> Timeline and/or audit history show more
workflow progress than the stored current stage ({String(request.currentStage)}). The step list
below follows that history; approve/reject still use the official current stage only.
</div>
)}
<div className="mb-6">
<h3 className="text-lg font-semibold text-slate-900">Progress Timeline</h3>
<CardDescription className="mt-1">
Track the relocation approval process activity recorded at each stage appears below that step.
</CardDescription>
</div>
{/* Workflow stages + per-stage timeline (same pattern as ResignationDetails progress tab) */}
<div className="space-y-4"> <div className="space-y-4">
{workflowStages.map((stage: any, index: number) => { {workflowStages.map((stage: any, index: number) => {
const isCompleted = index < currentStageIndex - 1; const isCompleted = allWorkflowComplete || index < displayOrdinal - 1;
const isCurrent = index === currentStageIndex - 1; const isCurrent = !allWorkflowComplete && index === displayOrdinal - 1;
const stageTimelineEntries = getRelocationTimelineEntriesForStage(
timelineEntries,
stage,
index
);
const timelineEntry =
stageTimelineEntries.length > 0
? stageTimelineEntries[stageTimelineEntries.length - 1]
: null;
return ( return (
<div key={stage.id} className="flex items-start gap-4"> <div key={stage.id} className="flex items-start gap-4">
{/* Status Icon */}
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isCompleted ? 'bg-green-100' : <div
isCurrent ? 'bg-amber-100' : className={`w-10 h-10 rounded-full flex items-center justify-center ${
'bg-slate-100' isCompleted
}`}> ? 'bg-green-100 text-green-600'
: isCurrent
? 'bg-amber-100 text-amber-600'
: 'bg-slate-100 text-slate-400'
}`}
>
{isCompleted ? ( {isCompleted ? (
<CheckCircle2 className="w-5 h-5 text-green-600" /> <CheckCircle2 className="w-5 h-5" />
) : isCurrent ? ( ) : isCurrent ? (
<Clock className="w-5 h-5 text-amber-600" /> <Clock className="w-5 h-5" />
) : ( ) : (
<AlertCircle className="w-5 h-5 text-slate-400" /> <span className="text-xs font-semibold">{stage.id}</span>
)} )}
</div> </div>
{index < workflowStages.length - 1 && ( {index < workflowStages.length - 1 && (
<div className={`w-0.5 h-12 ${isCompleted ? 'bg-green-300' : 'bg-slate-200' <div
}`} /> className={`w-0.5 h-16 ${isCompleted ? 'bg-green-300' : 'bg-slate-200'}`}
/>
)} )}
</div> </div>
{/* Stage Info */} <div
<div className={`flex-1 pb-8 ${isCurrent ? 'bg-amber-50 -ml-4 pl-4 pr-4 py-3 rounded-lg border border-amber-200' : ''}`}> className={`flex-1 pb-8 ${
<div className="flex items-center justify-between"> isCurrent
? 'bg-amber-50 -ml-4 pl-4 pr-4 py-3 rounded-lg border border-amber-200'
: ''
}`}
>
<div className="flex items-center justify-between mb-1 gap-2">
<div> <div>
<h4 className={`${isCurrent ? 'text-amber-900' : 'text-slate-900'}`}> <h4
className={
isCompleted
? 'text-green-700'
: isCurrent
? 'text-amber-900'
: 'text-slate-900'
}
>
{stage.name} {stage.name}
</h4> </h4>
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}> <p
className={`text-sm ${
isCurrent ? 'text-amber-700' : 'text-slate-600'
}`}
>
Responsible: {stage.role} Responsible: {stage.role}
</p> </p>
{getAssignedReviewer(stage.name) && ( {getAssignedReviewer(stage.name) && (
@ -494,14 +736,55 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</p> </p>
)} )}
</div> </div>
<Badge className={ <div className="flex flex-col items-end gap-1 shrink-0">
isCompleted ? 'bg-green-100 text-green-700 border-green-300' : <Badge
isCurrent ? 'bg-amber-100 text-amber-700 border-amber-300' : className={
'bg-slate-100 text-slate-500 border-slate-300' isCompleted
}> ? 'bg-green-100 text-green-700 border-green-300'
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'} : isCurrent
</Badge> ? 'bg-amber-100 text-amber-700 border-amber-300'
: 'bg-slate-100 text-slate-500 border-slate-300'
}
>
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'}
</Badge>
{timelineEntry && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Calendar className="w-3.5 h-3.5" />
<span>{formatDateTime(timelineEntry.timestamp || timelineEntry.createdAt)}</span>
</div>
)}
</div>
</div> </div>
{timelineEntry && (
<div className="space-y-2 mt-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
{timelineEntry.user || 'System'}
</Badge>
<span className="text-[10px] text-slate-500 italic">
{timelineEntry.action || 'Update'}
</span>
{timelineEntry.targetStage &&
timelineEntry.targetStage !== timelineEntry.stage && (
<span className="text-[10px] text-slate-500">
{String(timelineEntry.targetStage).replace(/_/g, ' ')}
</span>
)}
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
{timelineEntry.remarks ||
timelineEntry.comments ||
'No remarks provided.'}
</div>
{stageTimelineEntries.length > 1 && (
<p className="text-[10px] text-slate-500">
{stageTimelineEntries.length} events at this stage; showing the latest.
</p>
)}
</div>
)}
</div> </div>
</div> </div>
); );
@ -678,15 +961,27 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
{doc.status === 'Pending Verification' && currentUser?.role !== 'Dealer' && ( {doc.status === 'Pending Verification' && currentUser?.role !== 'Dealer' && (
<Button <>
size="sm" <Button
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1" size="sm"
onClick={() => handleVerifyDocument(doc.id)} className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1"
title="Verify Document" onClick={() => handleVerifyDocument(doc.id)}
> title="Verify Document"
<CheckCircle2 className="w-4 h-4" /> >
Verify <CheckCircle2 className="w-4 h-4" />
</Button> Verify
</Button>
<Button
size="sm"
variant="destructive"
className="h-8 gap-1"
onClick={() => handleRejectDocument(doc.id)}
title="Reject Document"
>
<AlertCircle className="w-4 h-4" />
Reject
</Button>
</>
)} )}
</div> </div>
</TableCell> </TableCell>
@ -712,6 +1007,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<div> <div>
<h4 className="text-slate-900">EOR Readiness Checklist</h4> <h4 className="text-slate-900">EOR Readiness Checklist</h4>
<p className="text-slate-600 text-sm">Verify new location infrastructure and statutory compliances</p> <p className="text-slate-600 text-sm">Verify new location infrastructure and statutory compliances</p>
<p className="text-slate-500 text-xs mt-1 max-w-2xl">
When document types match a checklist line, proofs from the Documents tab are linked here automatically (same files; no separate EOR upload required).
</p>
</div> </div>
{eorChecklist && ( {eorChecklist && (
<Badge className={getStatusColor(eorChecklist.status)}> <Badge className={getStatusColor(eorChecklist.status)}>
@ -734,7 +1032,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<p className="text-slate-500 text-sm mb-4"> <p className="text-slate-500 text-sm mb-4">
The EOR checklist will be automatically initiated once the request reaches the final clearance stage. The EOR checklist will be automatically initiated once the request reaches the final clearance stage.
</p> </p>
<Button variant="outline" onClick={fetchEorChecklist}> <Button variant="outline" onClick={() => fetchEorChecklist(request?.id)}>
Try Refreshing Try Refreshing
</Button> </Button>
</> </>
@ -753,7 +1051,14 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{eorChecklist.items?.map((item: any) => ( {(!eorChecklist.items || eorChecklist.items.length === 0) ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-slate-500 py-8 text-sm">
No checklist rows returned. Use &quot;Try Refreshing&quot; above or reload the page; rows are created when the request enters NBH Clearance with EOR.
</TableCell>
</TableRow>
) : (
eorChecklist.items.map((item: any) => (
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell> <TableCell>
<input <input
@ -773,14 +1078,36 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{item.description} {item.description}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex flex-col items-start gap-1">
{item.proofDocumentId ? ( {item.proofDocumentId && item.proofDocument ? (
<Button size="sm" variant="ghost" className="h-7 text-blue-600"> <>
<Eye className="w-3.5 h-3.5 mr-1" /> <span className="text-xs text-slate-600 truncate max-w-[220px]" title={item.proofDocument.fileName}>
View {item.proofDocument.fileName}
</Button> </span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-blue-600 px-0"
onClick={() =>
handlePreviewDocument({
name: item.proofDocument.fileName,
url: item.proofDocument.filePath,
type: item.proofDocument.documentType,
uploadedOn: item.proofDocument.updatedAt || item.proofDocument.createdAt,
mimeType: item.proofDocument.mimeType
})
}
>
<Eye className="w-3.5 h-3.5 mr-1" />
View
</Button>
</>
) : item.proofDocumentId ? (
<span className="text-xs text-amber-700">Proof linked (refresh if file details are missing)</span>
) : ( ) : (
<Button <Button
type="button"
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 text-slate-400" className="h-7 text-slate-400"
@ -793,7 +1120,8 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@ -804,7 +1132,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<Button <Button
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
onClick={handleSubmitEorAudit} onClick={handleSubmitEorAudit}
disabled={isSubmittingEor || !eorChecklist.items?.every((i: any) => i.isCompliant)} disabled={
isSubmittingEor ||
!eorChecklist.items?.length ||
!eorChecklist.items.every((i: any) => i.isCompliant)
}
> >
{isSubmittingEor ? ( {isSubmittingEor ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@ -846,7 +1178,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<div className="flex-1"> <div className="flex-1">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h4 className="text-slate-900">{entry.stage || entry.action}</h4> <h4 className="text-slate-900">
{entry.stage || entry.details?.stage || entry.details?.targetStage || entry.action}
</h4>
<p className="text-slate-600 text-sm">{entry.userName || 'System'}</p> <p className="text-slate-600 text-sm">{entry.userName || 'System'}</p>
</div> </div>
<Badge className={getStatusColor(entry.action)}> <Badge className={getStatusColor(entry.action)}>
@ -889,10 +1223,10 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden"> <div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div <div
className="h-full bg-amber-600 transition-all duration-300" className="h-full bg-amber-600 transition-all duration-300"
style={{ width: `${request.progressPercentage}%` }} style={{ width: `${displayProgressPct}%` }}
/> />
</div> </div>
<span className="text-slate-900">{request.progressPercentage}%</span> <span className="text-slate-900">{displayProgressPct}%</span>
</div> </div>
</div> </div>
<div> <div>
@ -913,7 +1247,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<Button <Button
className="w-full bg-green-600 hover:bg-green-700" className="w-full bg-green-600 hover:bg-green-700"
onClick={() => handleAction('approve')} onClick={() => handleAction('approve')}
disabled={isSubmitting} disabled={isSubmitting || !canApprove}
> >
{isSubmitting && actionType === 'approve' ? ( {isSubmitting && actionType === 'approve' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
@ -922,6 +1256,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
)} )}
Approve Request Approve Request
</Button> </Button>
{!canApprove && (
<p className="text-xs text-amber-700">
Approval is blocked until mandatory documents are uploaded and verified for this stage.
</p>
)}
<Button <Button
variant="destructive" variant="destructive"
@ -937,6 +1276,41 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
Reject Request Reject Request
</Button> </Button>
{canSendBack && (
<Button
variant="outline"
className="w-full border-amber-400 text-amber-900 hover:bg-amber-50"
onClick={() => handleAction('send_back')}
disabled={isSubmitting}
>
{isSubmitting && actionType === 'send_back' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Reply className="w-4 h-4 mr-2" />
)}
Send Back
</Button>
)}
<Button
variant="outline"
className="w-full border-red-300 text-red-800 hover:bg-red-50"
onClick={() => handleAction('revoke')}
disabled={isSubmitting || !canRevoke}
>
{isSubmitting && actionType === 'revoke' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Ban className="w-4 h-4 mr-2" />
)}
Revoke Request
</Button>
{!canRevoke && (
<p className="text-xs text-slate-500">
Revoke is restricted to ZBH, DD Lead, DD Head, NBH, Legal Admin, and Super Admin.
</p>
)}
<div className="border-t border-slate-200 pt-3 mt-3" /> <div className="border-t border-slate-200 pt-3 mt-3" />
</> </>
)} )}
@ -994,18 +1368,28 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{actionType === 'approve' ? 'Approve Request' : {actionType === 'approve'
actionType === 'reject' ? 'Reject Request' : ? 'Approve Request'
'Put Request on Hold'} : actionType === 'reject'
? 'Reject Request'
: actionType === 'send_back'
? 'Send Back Request'
: actionType === 'revoke'
? 'Revoke Request'
: 'Put Request on Hold'}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>
Please provide comments for this action. This will be recorded in the audit trail. {actionType === 'send_back' || actionType === 'revoke'
? 'Remarks are required and will be recorded in Work Notes and the audit trail.'
: 'Please provide comments for this action. This will be recorded in the audit trail.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmitAction} className="space-y-4"> <form onSubmit={handleSubmitAction} className="space-y-4">
<div> <div>
<Label htmlFor="comments" >Comments *</Label> <Label htmlFor="comments">
{actionType === 'send_back' || actionType === 'revoke' ? 'Remarks *' : 'Comments *'}
</Label>
<div className="space-y-2" /> <div className="space-y-2" />
</div> </div>
@ -1013,7 +1397,13 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
id="comments" id="comments"
value={comments} value={comments}
onChange={(e) => setComments(e.target.value)} onChange={(e) => setComments(e.target.value)}
placeholder="Enter your comments..." placeholder={
actionType === 'send_back'
? 'Explain what needs to be corrected at the previous stage…'
: actionType === 'revoke'
? 'Document why this relocation request is being revoked…'
: 'Enter your comments...'
}
rows={4} rows={4}
required required
/> />
@ -1030,18 +1420,30 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<Button <Button
type="submit" type="submit"
className={ className={
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' : actionType === 'approve'
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' : ? 'bg-green-600 hover:bg-green-700'
'bg-amber-600 hover:bg-amber-700' : actionType === 'reject'
? 'bg-red-600 hover:bg-red-700'
: actionType === 'send_back'
? 'bg-amber-600 hover:bg-amber-700'
: actionType === 'revoke'
? 'bg-red-700 hover:bg-red-800'
: 'bg-amber-600 hover:bg-amber-700'
} }
disabled={isSubmitting} disabled={isSubmitting}
> >
{isSubmitting ? ( {isSubmitting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : null} ) : null}
{actionType === 'approve' ? 'Approve' : {actionType === 'approve'
actionType === 'reject' ? 'Reject' : ? 'Approve'
'Put on Hold'} : actionType === 'reject'
? 'Reject'
: actionType === 'send_back'
? 'Send Back'
: actionType === 'revoke'
? 'Revoke'
: 'Put on Hold'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</form> </form>

View File

@ -18,7 +18,7 @@ interface RelocationRequestPageProps {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
if (status === 'Completed' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300'; if (status === 'Completed' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300'; if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300'; if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300'; if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
return 'bg-slate-100 text-slate-700 border-slate-300'; return 'bg-slate-100 text-slate-700 border-slate-300';
}; };
@ -58,7 +58,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
}, },
{ {
title: 'In Progress', title: 'In Progress',
value: requests.filter((r: any) => r.status !== 'Completed' && r.status !== 'Closed' && !r.status.includes('Rejected')).length, value: requests.filter((r: any) => r.status !== 'Completed' && r.status !== 'Closed' && !r.status.includes('Rejected') && !r.status.includes('Revoked')).length,
icon: Calendar, icon: Calendar,
color: 'bg-yellow-500', color: 'bg-yellow-500',
}, },

View File

@ -21,7 +21,8 @@ import {
Search, Search,
ChevronRight, ChevronRight,
Info, Info,
Clock as ClockIcon Clock as ClockIcon,
Activity,
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '../ui/badge'; import { Badge } from '../ui/badge';
import { import {
@ -55,6 +56,54 @@ interface WorkNote {
attachments?: Attachment[]; attachments?: Attachment[];
} }
const sortNotesChronological = (arr: WorkNote[]) =>
[...arr].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
const normalizeWorknoteAuthor = (n: any): WorkNote['author'] => ({
name: n?.author?.name || n?.author?.fullName || 'System',
email: n?.author?.email || '',
role: String(n?.author?.role || n?.author?.roleCode || '')
});
/** Workflow-generated rows (approvals, send-back, revoke, etc.) — not composer messages. */
const ACTIVITY_NOTE_TYPES = new Set(['internal', 'workflow', 'system', 'audit', 'status']);
const isActivityLogNote = (note: WorkNote) =>
ACTIVITY_NOTE_TYPES.has(String(note.noteType || '').toLowerCase());
const activityLogBadgeLabel = (note: WorkNote) => {
const t = String(note.noteType || '').toLowerCase();
if (t === 'workflow') return 'Workflow activity';
if (t === 'internal') return 'Review / approval';
if (t === 'system') return 'System';
if (t === 'audit') return 'Audit';
if (t === 'status') return 'Status update';
return 'Activity';
};
/** Short label for compact activity row */
/** Uppercase pill label (e.g. APPROVAL) — matches relocation-style worknote activity rows. */
const activityLogPillCategoryUpper = (note: WorkNote) => {
const t = String(note.noteType || '').toLowerCase();
if (t === 'internal') return 'APPROVAL';
if (t === 'workflow') return 'WORKFLOW';
if (t === 'system') return 'SYSTEM';
if (t === 'audit') return 'AUDIT';
if (t === 'status') return 'STATUS';
return 'ACTIVITY';
};
const formatNoteTimestamp = (createdAt: string) =>
createdAt
? new Date(createdAt).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: '';
// Participant interface for mentions // Participant interface for mentions
interface WorkNotesPageProps { interface WorkNotesPageProps {
@ -106,6 +155,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true); const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [participantSearch, setParticipantSearch] = useState(''); const [participantSearch, setParticipantSearch] = useState('');
const [messageSearch, setMessageSearch] = useState('');
const { socket } = useSocket(); const { socket } = useSocket();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -189,22 +239,21 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
} }
}); });
console.log('Participants list for mentions:', participantsList.map(p => ({ id: p.id, name: p.name })));
const fetchNotes = async () => { const fetchNotes = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const res: any = await worknoteService.getWorknotes(requestId, requestType); const res: any = await worknoteService.getWorknotes(requestId, requestType);
if (res.success) { if (res.success) {
setNotes(res.data.map((n: any) => ({ const mapped: WorkNote[] = res.data.map((n: any) => ({
id: n.id, id: n.id,
noteText: n.noteText, noteText: n.noteText,
noteType: n.noteType, noteType: n.noteType,
createdAt: n.createdAt, createdAt: n.createdAt,
userId: n.userId, userId: n.userId,
author: n.author || { name: 'System', email: '', role: 'system' }, author: n.author ? normalizeWorknoteAuthor(n) : { name: 'System', email: '', role: 'system' },
attachments: n.attachments || [] attachments: n.attachments || []
}))); }));
setNotes(sortNotesChronological(mapped));
} }
} catch (error) { } catch (error) {
console.error('Fetch notes error:', error); console.error('Fetch notes error:', error);
@ -221,13 +270,20 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
if (socket) { if (socket) {
socket.emit('join_room', requestId); socket.emit('join_room', requestId);
socket.on('new_worknote', (newNote: any) => { socket.on('new_worknote', (raw: any) => {
const newNote: WorkNote = {
id: raw.id,
noteText: raw.noteText,
noteType: raw.noteType,
createdAt: raw.createdAt,
userId: raw.userId,
author: raw.author ? normalizeWorknoteAuthor(raw) : { name: 'System', email: '', role: 'system' },
attachments: raw.attachments || []
};
setNotes(prev => { setNotes(prev => {
// 1. Check for exact ID match (real vs real or real vs replaced temp)
const isDuplicate = prev.some(n => n.id === newNote.id); const isDuplicate = prev.some(n => n.id === newNote.id);
if (isDuplicate) return prev; if (isDuplicate) return prev;
// 2. Check for optimistic match (matching text and author from a very recent temp note)
const optimisticMatchIndex = prev.findIndex(n => const optimisticMatchIndex = prev.findIndex(n =>
n.id.startsWith('temp-') && n.id.startsWith('temp-') &&
n.noteText === newNote.noteText && n.noteText === newNote.noteText &&
@ -235,14 +291,12 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
); );
if (optimisticMatchIndex !== -1) { if (optimisticMatchIndex !== -1) {
// Replace the temp note with the real one from the socket
const newNotes = [...prev]; const newNotes = [...prev];
newNotes[optimisticMatchIndex] = newNote; newNotes[optimisticMatchIndex] = newNote;
return newNotes; return sortNotesChronological(newNotes);
} }
// 3. New message from someone else return sortNotesChronological([...prev, newNote]);
return [newNote, ...prev];
}); });
}); });
@ -403,26 +457,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
participantsList.forEach(p => { participantsList.forEach(p => {
if (p.id && p.name) { if (p.id && p.name) {
// Simplified regex: Look for @ followed by the name, case-insensitive
// We use a more standard boundary check
const escapedName = p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escapedName = p.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi'); const mentionRegex = new RegExp(`@${escapedName}\\b`, 'gi');
if (processedMessage.match(mentionRegex)) { if (processedMessage.match(mentionRegex)) {
console.log(`Mention found for ${p.name} (${p.id})`);
mentionedUserIds.push(p.id); mentionedUserIds.push(p.id);
processedMessage = processedMessage.replace(mentionRegex, `@[${p.name}](user:${p.id})`); processedMessage = processedMessage.replace(mentionRegex, `@[${p.name}](user:${p.id})`);
} else {
console.log(`No match for participant: ${p.name} in message: "${processedMessage}"`);
} }
} }
}); });
console.log('Final processed message:', processedMessage);
console.log('Mentioned user IDs:', mentionedUserIds);
console.log('Final processed message for API:', processedMessage);
try { try {
// Optimistic update // Optimistic update
const tempId = `temp-${Date.now()}`; const tempId = `temp-${Date.now()}`;
@ -441,7 +485,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
attachments: optimisticAttachments attachments: optimisticAttachments
}; };
setNotes(prev => [tempNote, ...prev]); setNotes(prev => sortNotesChronological([...prev, tempNote]));
const res: any = await worknoteService.addWorknote({ const res: any = await worknoteService.addWorknote({
requestId: requestId, requestId: requestId,
@ -453,8 +497,17 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
}); });
if (res.success && res.data) { if (res.success && res.data) {
// Replace optimistic update with actual data from server const saved = res.data;
setNotes(prev => prev.map(n => n.id === tempId ? res.data : n)); const normalized: WorkNote = {
id: saved.id,
noteText: saved.noteText,
noteType: saved.noteType,
createdAt: saved.createdAt,
userId: saved.userId,
author: saved.author ? normalizeWorknoteAuthor(saved) : { name: 'System', email: '', role: 'system' },
attachments: saved.attachments || []
};
setNotes(prev => sortNotesChronological(prev.map(n => (n.id === tempId ? normalized : n))));
} }
} catch (error) { } catch (error) {
console.error('Send message error:', error); console.error('Send message error:', error);
@ -492,6 +545,16 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
}); });
}; };
const messageQuery = messageSearch.trim().toLowerCase();
const displayNotes = messageQuery
? notes.filter(
(n) =>
(n.noteText || '').toLowerCase().includes(messageQuery) ||
(n.author?.name || '').toLowerCase().includes(messageQuery) ||
(n.noteType || '').toLowerCase().includes(messageQuery)
)
: notes;
const filteredParticipants = participantsList.filter(p => { const filteredParticipants = participantsList.filter(p => {
// Filter by name query // Filter by name query
const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase()); const matchesQuery = p.name.toLowerCase().includes(mentionQuery.toLowerCase());
@ -580,87 +643,156 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
{/* Main Chat Engine */} {/* Main Chat Engine */}
<div className="flex-1 flex flex-col min-w-0 bg-white min-h-0 relative"> <div className="flex-1 flex flex-col min-w-0 bg-white min-h-0 relative">
<div className="flex-1 overflow-y-auto px-6 py-4 custom-scrollbar bg-slate-50 relative z-0"> <div className="flex-1 overflow-y-auto px-6 py-4 custom-scrollbar bg-slate-50 relative z-0">
<div className={`max-w-4xl mx-auto space-y-6 flex flex-col py-4 ${mode === 'modal' ? '' : 'px-4'}`}> <div className={`max-w-4xl mx-auto flex flex-col py-4 gap-4 ${mode === 'modal' ? '' : 'px-4'}`}>
{[...notes].reverse().map((note) => { <div className="sticky top-0 z-[1] -mx-1 px-1 pb-1 bg-slate-50/95 backdrop-blur-sm">
const isMe = (note?.author?.email && currentUser?.email && note.author.email.toLowerCase() === currentUser.email.toLowerCase()) || <div className="relative">
(note?.userId && currentUser?.id && note.userId === currentUser.id) || <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
note.id.startsWith('temp-'); <Input
type="search"
return ( value={messageSearch}
<div key={note.id} className={`flex w-full ${isMe ? 'justify-end' : 'justify-start'}`}> onChange={(e) => setMessageSearch(e.target.value)}
<div className={`flex gap-3 max-w-[85%] ${isMe ? 'flex-row-reverse' : ''}`}> placeholder="Search messages..."
{/* Avatar */} className="pl-9 h-10 bg-white border-slate-200 rounded-xl text-sm shadow-sm"
<Avatar className="w-10 h-10 flex-shrink-0 mt-1"> aria-label="Search messages"
<AvatarFallback className={`${getAvatarColor(note?.author?.name || 'System')} text-white`}> />
{getInitials(note?.author?.name || 'S')}
</AvatarFallback>
</Avatar>
{/* Message Content */}
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
<div className={`flex items-center gap-2 mb-1 px-1 ${isMe ? 'flex-row-reverse text-right' : 'text-left'}`}>
<span className="text-slate-900 font-medium text-sm">{isMe ? 'You' : (note?.author?.name || 'Unknown')}</span>
<span className="text-slate-400 text-[10px] uppercase">
{(note?.author?.role && note.author.role !== '0' && note.author.role !== '') ? `(${note.author.role})` : ''}
</span>
<span className="text-slate-400 text-[10px]">
{note.createdAt ? new Date(note.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''}
</span>
</div>
<div className={`rounded-2xl border px-4 py-2.5 shadow-sm relative ${isMe
? 'bg-blue-50 border-blue-100 text-slate-800 rounded-tr-none'
: 'bg-white border-slate-200 text-slate-700 rounded-tl-none'
}`}>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{renderMessageWithMentions(note.noteText)}
</p>
{note.attachments && note.attachments.length > 0 && (
<div className="mt-2 space-y-2 border-t border-slate-100 pt-2">
{note.attachments.map(file => {
const isImage = file.mimeType.startsWith('image/');
return (
<div key={file.id} className="flex items-center gap-2">
{isImage ? (
<div className="rounded-lg overflow-hidden border border-slate-100 max-w-[200px]">
<img
src={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
alt={file.fileName}
className="w-full h-auto cursor-pointer"
onClick={() => setPreviewFile(file)}
/>
</div>
) : file.mimeType === 'application/pdf' ? (
<button
onClick={() => setPreviewFile(file)}
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
>
<Paperclip className="w-3 h-3" />
{file.fileName} (Preview)
</button>
) : (
<a
href={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
>
<Paperclip className="w-3 h-3" />
{file.fileName}
</a>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
);
})} {displayNotes.map((note) => {
if (isActivityLogNote(note)) {
const who = note.author?.name || 'System';
const role =
note.author?.role && note.author.role !== '0' && note.author.role !== ''
? note.author.role
: '';
return (
<div key={note.id} className="flex w-full justify-center px-1 py-1 sm:px-2">
<div
role="status"
title={activityLogBadgeLabel(note)}
className="flex w-full max-w-[min(100%,42rem)] items-start gap-2.5 rounded-xl border border-slate-200/90 bg-slate-100/80 px-3 py-2.5 shadow-sm"
>
<Activity
className="mt-0.5 h-4 w-4 shrink-0 text-purple-600"
strokeWidth={2.5}
aria-hidden
/>
<span className="shrink-0 pt-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
{activityLogPillCategoryUpper(note)}
</span>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm leading-snug text-slate-800">
<span className="font-semibold text-slate-900">{who}</span>
{role ? (
<span className="text-xs font-normal text-slate-500"> · {role}</span>
) : null}
<span className="font-normal text-slate-600"> </span>
<span className="font-normal text-slate-700">{renderMessageWithMentions(note.noteText)}</span>
</p>
</div>
<time
className="shrink-0 whitespace-nowrap pt-0.5 text-right text-[10px] leading-tight text-slate-400 tabular-nums sm:text-[11px]"
dateTime={note.createdAt}
>
{formatNoteTimestamp(note.createdAt)}
</time>
</div>
</div>
);
}
const isMe =
(note?.author?.email &&
currentUser?.email &&
note.author.email.toLowerCase() === currentUser.email.toLowerCase()) ||
(note?.userId && currentUser?.id && String(note.userId) === String(currentUser.id)) ||
note.id.startsWith('temp-');
return (
<div key={note.id} className={`flex w-full ${isMe ? 'justify-end' : 'justify-start'}`}>
<div className={`flex gap-3 max-w-[min(85%,36rem)] ${isMe ? 'flex-row-reverse' : ''}`}>
<Avatar className="w-10 h-10 flex-shrink-0 mt-1">
<AvatarFallback className={`${getAvatarColor(note?.author?.name || 'System')} text-white`}>
{getInitials(note?.author?.name || 'S')}
</AvatarFallback>
</Avatar>
<div className={`flex flex-col min-w-0 ${isMe ? 'items-end' : 'items-start'}`}>
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 mb-1 px-1 ${
isMe ? 'flex-row-reverse text-right' : 'text-left'
}`}
>
<span className="text-slate-900 font-medium text-sm">
{isMe ? 'You' : note?.author?.name || 'Unknown'}
</span>
<span className="text-slate-400 text-[10px] uppercase tracking-wide">
{note?.author?.role && note.author.role !== '0' && note.author.role !== ''
? `(${note.author.role})`
: ''}
</span>
<span className="text-slate-400 text-[10px] tabular-nums">
{formatNoteTimestamp(note.createdAt)}
</span>
</div>
<div
className={`rounded-2xl border px-4 py-2.5 shadow-sm relative text-left ${
isMe
? 'bg-blue-50 border-blue-100 text-slate-800 rounded-tr-none'
: 'bg-white border-slate-200 text-slate-700 rounded-tl-none'
}`}
>
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words">
{renderMessageWithMentions(note.noteText)}
</p>
{note.attachments && note.attachments.length > 0 && (
<div className="mt-2 space-y-2 border-t border-slate-100 pt-2">
{note.attachments.map((file) => {
const isImage = file.mimeType.startsWith('image/');
return (
<div key={file.id} className="flex items-center gap-2">
{isImage ? (
<div className="rounded-lg overflow-hidden border border-slate-100 max-w-[200px]">
<img
src={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
alt={file.fileName}
className="w-full h-auto cursor-pointer"
onClick={() => setPreviewFile(file)}
/>
</div>
) : file.mimeType === 'application/pdf' ? (
<button
type="button"
onClick={() => setPreviewFile(file)}
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
>
<Paperclip className="w-3 h-3" />
{file.fileName} (Preview)
</button>
) : (
<a
href={`${BACKEND_URL}/${file.filePath.replace(/\\/g, '/')}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs text-blue-600 hover:underline"
>
<Paperclip className="w-3 h-3" />
{file.fileName}
</a>
)}
</div>
);
})}
</div>
)}
</div>
</div>
</div>
</div>
);
})}
{notes.length === 0 && !isLoading && ( {notes.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-16 text-center"> <div className="flex flex-col items-center justify-center py-16 text-center">
@ -670,6 +802,12 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
</div> </div>
)} )}
{notes.length > 0 && displayNotes.length === 0 && !isLoading && (
<div className="flex flex-col items-center justify-center py-12 text-center text-slate-500 text-sm">
No messages match &quot;{messageSearch.trim()}&quot;. Clear the search box to see the full thread.
</div>
)}
{isLoading && ( {isLoading && (
<div className="flex justify-center items-center py-8"> <div className="flex justify-center items-center py-8">
<span className="text-slate-500">Loading notes...</span> <span className="text-slate-500">Loading notes...</span>
@ -830,7 +968,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Right Sidebar - Participants */} {/* Right Sidebar - Participants */}
{isSidebarOpen && ( {isSidebarOpen && (

View File

@ -152,7 +152,8 @@ export function LoginPage({ onLogin }: LoginPageProps) {
placeholder="Enter your password" placeholder="Enter your password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full pr-10" className="no-native-password-reveal w-full pr-10"
autoComplete="current-password"
disabled={isLoading} disabled={isLoading}
/> />
<button <button

View File

@ -13,9 +13,11 @@ import { User as UserType } from '../../lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { dealerService } from '../../services/dealer.service'; import { dealerService } from '../../services/dealer.service';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '../ui/utils';
import { API } from '../../api/API';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
interface DealerConstitutionalChangePageProps { interface DealerConstitutionalChangePageProps {
currentUser: UserType | null; currentUser?: UserType | null;
onViewDetails?: (id: string) => void; onViewDetails?: (id: string) => void;
} }
@ -26,9 +28,7 @@ const getStatusColor = (status: string) => {
return 'bg-slate-100 text-slate-700 border-slate-300'; return 'bg-slate-100 text-slate-700 border-slate-300';
}; };
const constitutionTypes = ['Proprietorship', 'Partnership', 'LLP', 'Pvt Ltd']; export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitutionalChangePageProps) {
export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: DealerConstitutionalChangePageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [currentConstitution, setCurrentConstitution] = useState(''); const [currentConstitution, setCurrentConstitution] = useState('');
const [proposedConstitution, setProposedConstitution] = useState(''); const [proposedConstitution, setProposedConstitution] = useState('');
@ -40,6 +40,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [profile, setProfile] = useState<any>(null); const [profile, setProfile] = useState<any>(null);
const [structureTargets, setStructureTargets] = useState<{ value: string; label: string }[]>([]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
@ -48,12 +49,19 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
const fetchData = async () => { const fetchData = async () => {
try { try {
setLoading(true); setLoading(true);
const dashboard = await dealerService.getDashboardData(); const [dashboard, constitutionalRes, metaRes] = await Promise.all([
const constitutionalRes = await dealerService.getConstitutionalChanges(); dealerService.getDashboardData(),
dealerService.getConstitutionalChanges(),
API.getConstitutionalChangeMeta() as any
]);
setProfile(dashboard.profile); setProfile(dashboard.profile);
setCurrentConstitution(dashboard.profile?.constitutionType || 'Proprietorship'); const normalizedCurrent = normalizeDealerProfileConstitution(dashboard.profile?.constitutionType);
setCurrentConstitution(normalizedCurrent);
setRequests(constitutionalRes.requests || []); setRequests(constitutionalRes.requests || []);
if (metaRes.data?.success && Array.isArray(metaRes.data.structureTargets)) {
setStructureTargets(metaRes.data.structureTargets);
}
} catch (error) { } catch (error) {
console.error('Fetch constitutional data error:', error); console.error('Fetch constitutional data error:', error);
toast.error('Failed to load requests'); toast.error('Failed to load requests');
@ -85,7 +93,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
const payload = { const payload = {
currentConstitution, currentConstitution,
changeType: proposedConstitution, changeType: proposedConstitution,
reason, reason: reason.trim(),
newPartnersDetails: newPartners, newPartnersDetails: newPartners,
shareholdingPattern shareholdingPattern
}; };
@ -100,9 +108,10 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
setReason(''); setReason('');
setNewPartners(''); setNewPartners('');
setShareholdingPattern(''); setShareholdingPattern('');
} catch (error) { } catch (error: any) {
console.error('Submit constitutional change error:', error); console.error('Submit constitutional change error:', error);
toast.error('Failed to submit constitutional change request'); const msg = error?.response?.data?.message || 'Failed to submit constitutional change request';
toast.error(msg);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -202,10 +211,12 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
<SelectValue placeholder="Select new constitution" /> <SelectValue placeholder="Select new constitution" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{constitutionTypes {structureTargets
.filter(type => type !== currentConstitution) .filter((o) => o.value !== currentConstitution)
.map(type => ( .map((o) => (
<SelectItem key={type} value={type}>{type}</SelectItem> <SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@ -240,7 +251,7 @@ export function DealerConstitutionalChangePage({ currentUser, onViewDetails }: D
)} )}
{/* Shareholding Pattern */} {/* Shareholding Pattern */}
{(proposedConstitution === 'Pvt Ltd' || proposedConstitution === 'LLP') && ( {(proposedConstitution === 'Private Limited' || proposedConstitution === 'LLP') && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="shareholdingPattern">Proposed Shareholding Pattern</Label> <Label htmlFor="shareholdingPattern">Proposed Shareholding Pattern</Label>
<Textarea <Textarea

View File

@ -23,10 +23,13 @@ interface DealerRelocationPageProps {
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300'; if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300'; if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300'; if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
return 'bg-slate-100 text-slate-700 border-slate-300'; return 'bg-slate-100 text-slate-700 border-slate-300';
}; };
const getApiErrorMessage = (error: any, fallback: string) =>
error?.response?.data?.message || error?.data?.message || error?.message || fallback;
export function DealerRelocationPage({ currentUser, onViewDetails }: DealerRelocationPageProps) { export function DealerRelocationPage({ currentUser, onViewDetails }: DealerRelocationPageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedOutlet, setSelectedOutlet] = useState<any | null>(null); const [selectedOutlet, setSelectedOutlet] = useState<any | null>(null);
@ -69,7 +72,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
setRequests(relocationRes.requests || []); setRequests(relocationRes.requests || []);
} catch (error) { } catch (error) {
console.error('Fetch relocation data error:', error); console.error('Fetch relocation data error:', error);
toast.error('Failed to load outlets and requests'); toast.error(getApiErrorMessage(error, 'Failed to load outlets and requests'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -90,6 +93,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
setDistricts(districtsData); setDistricts(districtsData);
} catch (error) { } catch (error) {
console.error('Fetch master data error:', error); console.error('Fetch master data error:', error);
toast.error(getApiErrorMessage(error, 'Failed to load master data'));
} finally { } finally {
setMasterDataLoading(false); setMasterDataLoading(false);
} }
@ -165,7 +169,7 @@ export function DealerRelocationPage({ currentUser, onViewDetails }: DealerReloc
setReason(''); setReason('');
} catch (error) { } catch (error) {
console.error('Submit relocation error:', error); console.error('Submit relocation error:', error);
toast.error('Failed to submit relocation request'); toast.error(getApiErrorMessage(error, 'Failed to submit relocation request'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }

View 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;
}

View File

@ -209,4 +209,59 @@ html {
.custom-scrollbar { .custom-scrollbar {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #e2e8f0 transparent; scrollbar-color: #e2e8f0 transparent;
}
/* Thin, light horizontal scrollbar (e.g. tab strips with overflow-x) */
.custom-scrollbar-x::-webkit-scrollbar {
height: 4px;
}
.custom-scrollbar-x::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar-x::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 9999px;
}
.custom-scrollbar-x::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.custom-scrollbar-x {
scrollbar-width: thin;
scrollbar-color: #e2e8f0 transparent;
}
/* Extra-thin, light vertical scrollbar (e.g. modals) */
.custom-scrollbar-slim::-webkit-scrollbar {
width: 2px;
}
.custom-scrollbar-slim::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar-slim::-webkit-scrollbar-thumb {
background: #f1f5f9;
border-radius: 9999px;
}
.custom-scrollbar-slim::-webkit-scrollbar-thumb:hover {
background: #e2e8f0;
}
.custom-scrollbar-slim {
scrollbar-width: thin;
scrollbar-color: #f1f5f9 transparent;
}
/* Password fields with a custom show/hide toggle: hide native reveal (Edge/IE + Chromium). */
.no-native-password-reveal::-ms-reveal {
display: none;
}
.no-native-password-reveal::-ms-clear {
display: none;
} }