Dealer_Onboard_Frontend/src/features/relocation/pages/RelocationRequestDetails.tsx

1675 lines
77 KiB
TypeScript

import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, Navigation, MapPin, MessageSquare, Loader2, Calendar, Reply, Ban } from 'lucide-react';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '@/components/ui/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner';
import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import {
getCurrentStageBadgeClass,
getOffboardingRequestStatusBadgeClass,
getStatusLabelBadgeClass,
getStatusProgressBarClass,
isOffboardingTerminalNegative,
WORKFLOW_IN_PROGRESS_ACCENT,
} from '@/lib/offboardingDisplay';
interface RelocationRequestDetailsProps {
requestId: string;
onBack: () => void;
currentUser: UserType | null;
}
// Workflow stages configuration
const workflowStages = [
{ id: 1, name: 'ASM Review', key: 'ASM_REVIEW', role: 'ASM' },
{ id: 2, name: 'RBM Review', key: 'RBM_REVIEW', role: 'RBM' },
{ id: 3, name: 'DD ZM Review', key: 'DD_ZM_REVIEW', role: 'DD-ZM' },
{ id: 4, name: 'ZBH Review', key: 'ZBH_REVIEW', role: 'ZBH' },
{ id: 5, name: 'DD Lead Review', key: 'DD_LEAD_REVIEW', role: 'DD Lead' },
{ id: 6, name: 'NBH Approval', key: 'NBH_APPROVAL', role: 'NBH' },
{ id: 7, name: 'Legal Clearance', key: 'LEGAL_CLEARANCE', role: 'Legal Admin' },
{ id: 8, 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;
}
function relocationIsSendBackAction(action: string): boolean {
const a = String(action || '').toLowerCase();
if (a.includes('sent back')) return true;
if (a.includes('send') && a.includes('back')) return true;
return false;
}
function relocationIsDocumentOnlyTimelineEntry(e: any): boolean {
const action = String(e?.action || '');
if (/document\s*verified/i.test(action)) return true;
if (/document/i.test(action) && /upload/i.test(action)) return true;
return false;
}
/**
* Applies one timeline/audit workflow row to a running 1-based stage ordinal.
* Send-back moves the workflow backward to `targetStage`; forward actions advance using stage/target labels.
*/
function relocationApplyWorkflowEntry(
currentOrdinal: number,
e: { action?: string; stage?: string; targetStage?: string }
): number {
if (relocationIsDocumentOnlyTimelineEntry(e)) return currentOrdinal;
const action = String(e?.action || '');
if (relocationIsSendBackAction(action)) {
const t = relocationStageLabelToOrdinal(String(e?.targetStage || ''));
return t > 0 ? t : currentOrdinal;
}
let next = currentOrdinal;
for (const lab of [e?.targetStage, e?.stage].filter(Boolean)) {
const o = relocationStageLabelToOrdinal(String(lab));
if (o > next) next = o;
}
return next;
}
/** Effective workflow step from timeline order (send-back lowers the active stage; max-only logic would not). */
function relocationTimelineResolvedOrdinal(entries: any[]): number {
const sorted = [...(entries || [])]
.filter(Boolean)
.sort(
(a, b) =>
new Date(a?.timestamp || a?.createdAt || 0).getTime() -
new Date(b?.timestamp || b?.createdAt || 0).getTime()
);
let resolved = 0;
for (const e of sorted) {
resolved = relocationApplyWorkflowEntry(resolved, e);
}
return resolved;
}
/** Same resolution rules as {@link relocationTimelineResolvedOrdinal}, using audit API rows. */
function relocationAuditResolvedOrdinal(logs: any[]): number {
const sorted = [...(logs || [])]
.filter(Boolean)
.sort(
(a, b) =>
new Date(a?.createdAt || a?.timestamp || 0).getTime() -
new Date(b?.createdAt || b?.timestamp || 0).getTime()
);
let resolved = 0;
for (const log of sorted) {
const action = String(log?.action || '');
if (action === 'DOCUMENT_UPLOADED' || action === 'DOCUMENT_VERIFIED') continue;
const d = log?.details || log?.newData || {};
const syntheticAction = [action, d?.action, String(d?.type || '')].filter(Boolean).join(' ');
resolved = relocationApplyWorkflowEntry(resolved, {
action: syntheticAction,
stage: d?.stage !== undefined ? String(d.stage) : log?.stage !== undefined ? String(log.stage) : undefined,
targetStage: d?.targetStage !== undefined ? String(d.targetStage) : undefined
});
}
return resolved;
}
/** Timeline rows for a workflow stage (matches resignation: filter by `stage` at action time). */
function getRelocationTimelineEntriesForStage(
entries: any[],
stage: { name: string; key: string },
stageIndex: number
): any[] {
const list = Array.isArray(entries) ? [...entries] : [];
const filtered = list.filter((t: any) => {
const src = String(t?.stage || '').trim();
if (src === stage.name || src === stage.key) return true;
if (stageIndex === 0 && (src === 'Submitted' || src === 'Request submitted')) return true;
return false;
});
filtered.sort((a, b) => {
const ta = new Date(a?.timestamp || a?.createdAt || 0).getTime();
const tb = new Date(b?.timestamp || b?.createdAt || 0).getTime();
return ta - tb;
});
return filtered;
}
// Required documents configuration
const requiredDocuments = [
'Property documents for new location',
'Lease/Rental agreement for new location',
'NOC from current landlord',
'Municipal approvals',
'Fire safety certificate',
'Pollution clearance',
'Layout/Floor plan of new location',
'Photos of new location',
'Locality map',
'Building plan approval',
'Electricity connection documents',
'Water supply documents'
];
const getStatusColor = (status: string) => getStatusLabelBadgeClass(status);
const getDocChecklistUploadButtonClass = (isRejected: boolean) =>
isRejected
? 'h-7 px-2 text-red-700 hover:bg-red-50 hover:text-red-800 flex-shrink-0'
: 'h-7 px-2 text-slate-700 hover:bg-slate-50 flex-shrink-0';
const getApiErrorMessage = (error: any, fallback: string) => {
const responseData = error?.response?.data || error?.data;
if (responseData?.readiness) {
const missing = responseData.readiness?.missingUploads || [];
const pending = responseData.readiness?.pendingVerification || [];
const details = [
missing.length ? `Missing: ${missing.join(', ')}` : '',
pending.length ? `Pending verification: ${pending.join(', ')}` : ''
]
.filter(Boolean)
.join(' | ');
return details ? `${responseData.message || fallback} (${details})` : (responseData.message || fallback);
}
return responseData?.message || error?.message || fallback;
};
export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) {
const { get: getSla } = useSlaBatchStatus(
requestId ? [{ entityType: 'relocation', entityId: requestId }] : [],
Boolean(requestId)
);
const navigate = useNavigate();
const [request, setRequest] = useState<any>(null);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isActionDialogOpen, setIsActionDialogOpen] = useState(false);
const [actionType, setActionType] = useState<'approve' | 'reject' | 'hold' | 'sendBack' | 'revoke'>('approve');
const [comments, setComments] = useState('');
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [eorChecklist, setEorChecklist] = useState<any>(null);
const [isEorLoading, setIsEorLoading] = useState(false);
const [isSubmittingEor, setIsSubmittingEor] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedDocType, setSelectedDocType] = useState<string>(requiredDocuments[0]);
// True when the dialog was opened from a checklist row -> doc type is implicit,
// so we hide the dropdown and show the doc name as a read-only badge instead.
const [docTypeLocked, setDocTypeLocked] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [activeTab, setActiveTab] = useState('workflow');
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<any>(null);
const [rejectDocDialogOpen, setRejectDocDialogOpen] = useState(false);
const [rejectDocId, setRejectDocId] = useState<string | null>(null);
const [rejectDocReason, setRejectDocReason] = useState('');
const [isRejectingDoc, setIsRejectingDoc] = useState(false);
useEffect(() => {
fetchRequestDetails();
fetchAuditLogs();
}, [requestId]);
const fetchAuditLogs = async () => {
try {
const response: any = await API.getAuditLogs('relocation', requestId);
if (response.data && response.data.success) {
setAuditLogs(response.data.data || []);
}
} catch (error) {
console.error('Error fetching audit logs:', error);
}
};
const fetchEorChecklist = async (relocationUuid?: string) => {
try {
setIsEorLoading(true);
const id = relocationUuid || request?.id || requestId;
const response = await API.getEorChecklistForRelocation(id) as any;
if (response.data.success) {
setEorChecklist(response.data.data);
}
} catch (error) {
console.error('Fetch EOR checklist error:', error);
// Don't toast error here as it might not be created yet
} finally {
setIsEorLoading(false);
}
};
const handleUpdateEorItem = async (description: string, isCompliant: boolean, itemType: string) => {
if (!eorChecklist) return;
try {
const response = await API.updateEorChecklistItem(eorChecklist.id, {
description,
isCompliant,
itemType,
remarks: ''
}) as any;
if (response.data.success) {
fetchEorChecklist();
}
} catch (error) {
console.error('Update EOR item error:', error);
toast.error(getApiErrorMessage(error, 'Failed to update item'));
}
};
const handleSubmitEorAudit = async () => {
if (!eorChecklist) return;
try {
setIsSubmittingEor(true);
const response = await API.submitEorAudit(eorChecklist.id, {
status: 'Completed',
overallComments: 'Relocation EOR Audit Completed'
}) as any;
if (response.data.success) {
toast.success('EOR Audit submitted successfully');
fetchRequestDetails();
fetchEorChecklist();
}
} catch (error) {
console.error('Submit EOR audit error:', error);
toast.error(getApiErrorMessage(error, 'Failed to submit EOR audit'));
} finally {
setIsSubmittingEor(false);
}
};
const fetchRequestDetails = async (isSilent = false) => {
try {
if (!isSilent) setIsLoading(true);
const response = await API.getRelocationRequestById(requestId) as any;
if (response.data.success) {
const req = response.data.request;
setRequest(req);
}
} catch (error) {
console.error('Fetch relocation request details error:', error);
toast.error(getApiErrorMessage(error, 'Failed to fetch request details'));
} finally {
setIsLoading(false);
}
};
/**
* 1-based ordinal from persisted record (currentStage, else status) — drives timeline highlight and progress bar.
*/
const getDbStageOrdinal = () => {
if (!request) return 1;
if (request.status === 'Completed' || request.currentStage === 'Completed') {
return workflowStages.length + 1;
}
if (request.status === 'Rejected' || request.status === 'Revoked' || request.status === 'Withdrawn') {
const tl = [...(request.timeline || [])].filter(Boolean).reverse();
for (const e of tl) {
const stageLabel = String(e.stage || '').trim();
const stageKey = String(e.targetStage || '').trim();
const idx = workflowStages.findIndex((s) =>
s.name === stageLabel || s.key === stageKey || s.key === stageLabel || s.name === stageKey
);
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, ' ')
);
if (idx >= 0) return idx + 1;
const fromStatus = relocationStageLabelToOrdinal(String(request.status || ''));
if (fromStatus > 0) return fromStatus;
return 1;
};
const timelineEntries = Array.isArray(request?.timeline) ? request.timeline : [];
const timelineResolvedOrdinal = relocationTimelineResolvedOrdinal(timelineEntries);
const auditResolvedOrdinal = relocationAuditResolvedOrdinal(auditLogs);
const dbOrdinal = request ? getDbStageOrdinal() : 1;
/** Audit/timeline can reference later steps while the request still sits in a prior stage — do not use that to drive the active step. */
const workflowProgressMismatch =
Boolean(request) &&
Math.max(timelineResolvedOrdinal, auditResolvedOrdinal) > dbOrdinal &&
(timelineEntries.length > 0 || auditLogs.length > 0);
const currentStageConfig = request
? workflowStages[Math.min(Math.max(dbOrdinal, 1), workflowStages.length) - 1]
: undefined;
const allWorkflowComplete =
request?.status === 'Completed' ||
request?.currentStage === 'Completed' ||
dbOrdinal >= workflowStages.length + 1;
/** Match backend: N/(pipeline+1) while in flight; 100% only when completed. */
const timelineProgressPct = allWorkflowComplete
? 100
: Math.min(100, Math.round((dbOrdinal / workflowStages.length) * 100));
const displayProgressPct = allWorkflowComplete ? 100 : timelineProgressPct;
const workflowTerminalNegative = request
? isOffboardingTerminalNegative(request.status, request.currentStage)
: false;
const statusProgressBarClass = request
? getStatusProgressBarClass(request.status, request.currentStage)
: 'bg-status-progress';
const requestStatusBadgeClass = request
? getOffboardingRequestStatusBadgeClass(request.status, request.currentStage)
: 'bg-re-red hover:bg-re-red-hover text-white border-transparent';
const missingRequiredDocs = request
? requiredDocuments.filter((doc) => !request.documents?.some((d: any) =>
d.type === doc || (d.name && d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0]))
))
: [];
const pendingVerificationDocs = request
? requiredDocuments.filter((doc) => {
const matched = request.documents?.filter((d: any) =>
d.type === doc || (d.name && d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0]))
) || [];
return matched.length > 0 && !matched.some((d: any) => d.status === 'Verified');
})
: [];
// Helper to find assigned reviewer for a stage
const getAssignedReviewer = (stageName: string) => {
if (!request || !request.participants || request.participants.length === 0) return null;
// The backend stores the stage string directly in metadata (e.g. "ASM Review")
const participant = request.participants.find((p: any) =>
p.metadata?.stage === stageName ||
p.metadata?.stage === stageName.toUpperCase().replace(/ /g, '_')
);
if (!participant) return null;
return participant.user?.fullName || participant.user?.name || participant.user?.role || null;
};
// Visibility logic for Approve/Reject buttons
const canUserAction = () => {
if (!request || !currentUser) return false;
// Check for Super Admin bypass
const isAdmin = (currentUser?.role as any) === 'Super Admin' || currentUser.role === 'Super Admin';
if (isAdmin) return true;
// Check if user's role matches the role required for the current stage
return Boolean(currentStageConfig?.role && currentUser.role === currentStageConfig.role);
};
const showActions =
canUserAction() &&
request.status !== 'Completed' &&
request.status !== 'Rejected' &&
request.status !== 'Revoked';
const canSendBack =
showActions &&
request.currentStage &&
request.currentStage !== 'ASM Review' &&
request.currentStage !== 'Rejected';
const canRevoke = showActions && ['ZBH', 'DD Lead', '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' | 'sendBack' | 'revoke') => {
setActionType(type);
setIsActionDialogOpen(true);
};
const handleSubmitAction = async (e: React.FormEvent) => {
e.preventDefault();
const remarksTrimmed = String(comments || '').trim();
const isSendBack = actionType === 'sendBack';
const isRevoke = actionType === 'revoke';
if ((isSendBack || isRevoke) && remarksTrimmed.length < 5) {
toast.error(`Remarks are required for ${actionType === 'sendBack' ? 'Send Back' : 'Revoke'} (minimum 5 characters).`);
return;
}
try {
setIsSubmitting(true);
const actionMap: Record<string, string> = {
approve: 'approve',
reject: 'reject',
hold: 'hold',
sendBack: 'sendBack',
revoke: 'revoke'
};
const action = actionMap[actionType] || 'approve';
const response = await API.updateRelocationRequest(requestId, action, { remarks: remarksTrimmed }) as any;
if (response.data.success) {
const verb =
actionType === 'sendBack'
? 'sent back'
: actionType === 'revoke'
? 'revoked'
: `${actionType}ed`;
toast.success(`Request ${verb} successfully`);
setIsActionDialogOpen(false);
setComments('');
fetchRequestDetails();
fetchAuditLogs();
// If moving to NBH Clearance EOR, fetch the checklist
if (actionType === 'approve' && currentStageConfig?.key === 'LEGAL_CLEARANCE' && request?.id) {
fetchEorChecklist(request.id);
}
}
} catch (error) {
console.error('Submit action error:', error);
toast.error(getApiErrorMessage(error, 'Failed to submit action'));
} finally {
setIsSubmitting(false);
}
};
const handleUploadDocument = async () => {
if (!selectedFile || !selectedDocType) {
toast.error('Please select both a document type and a file');
return;
}
try {
setIsUploading(true);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('documentType', selectedDocType);
formData.append('stage', request.currentStage);
const response = await API.uploadRelocationDocument(requestId, formData) as any;
if (response.data.success) {
toast.success('Document uploaded successfully');
setIsUploadDialogOpen(false);
setSelectedFile(null);
fetchRequestDetails(true);
fetchAuditLogs();
}
} catch (error) {
console.error('Upload document error:', error);
toast.error(getApiErrorMessage(error, 'Failed to upload document'));
} finally {
setIsUploading(false);
}
};
const handleVerifyDocument = async (documentId: string) => {
try {
const response = await API.verifyRelocationDocument(requestId, documentId) as any;
if (response.data.success) {
toast.success('Document verified successfully');
fetchRequestDetails(true); // Silent refresh
fetchAuditLogs();
}
} catch (error) {
console.error('Verify document error:', error);
toast.error(getApiErrorMessage(error, 'Failed to verify document'));
}
};
const submitRejectDocument = async () => {
if (!rejectDocId || !String(rejectDocReason).trim()) {
toast.error('Please enter a rejection reason.');
return;
}
try {
setIsRejectingDoc(true);
const response = await API.rejectRelocationDocument(requestId, rejectDocId, {
remarks: rejectDocReason.trim()
}) as any;
if (response.data?.success) {
toast.success('Document rejected successfully');
setRejectDocDialogOpen(false);
setRejectDocId(null);
setRejectDocReason('');
fetchRequestDetails(true);
fetchAuditLogs();
} else {
toast.error(response.data?.message || 'Failed to reject document');
}
} catch (error) {
console.error('Reject document error:', error);
toast.error(getApiErrorMessage(error, 'Failed to reject document'));
} finally {
setIsRejectingDoc(false);
}
};
const handlePreviewDocument = (doc: any) => {
setSelectedDoc({
fileName: doc.name,
filePath: doc.url,
documentType: doc.type,
createdAt: doc.uploadedOn,
mimeType: doc.mimeType
});
setIsPreviewOpen(true);
};
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="w-10 h-10 text-re-red animate-spin" />
<p className="text-slate-500 font-medium">Loading request details...</p>
</div>
);
}
if (!request) {
return (
<div className="bg-white rounded-lg border border-slate-200 p-8 text-center">
<h2 className="text-slate-900 mb-2">Request Not Found</h2>
<p className="text-slate-600 mb-4">The relocation request you're looking for doesn't exist.</p>
<Button onClick={onBack}>Go Back</Button>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={onBack}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<div>
<h1 className="text-slate-900">{request.requestId} - Relocation Request Details</h1>
<p className="text-slate-600">
{request.outlet?.name} ({request.outlet?.code})
</p>
<div className="mt-1">
<SlaBadge status={getSla('relocation', requestId)} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="relative hover:bg-red-50 hover:border-red-300 hover:text-re-red-hover transition-all shadow-sm"
onClick={() => navigate(`/worknotes/relocation/${requestId}`, {
state: {
applicationName: request?.outlet?.name || 'Relocation',
registrationNumber: request?.requestId || '',
participants: request?.participants || []
}
})}
>
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
{request?.worknotes?.length > 0 && (
<Badge className="ml-2 bg-re-red hover:bg-re-red-hover text-white h-5 px-2">
{request.worknotes.length}
</Badge>
)}
</Button>
<Badge className={requestStatusBadgeClass}>
{request.status}
</Badge>
</div>
</div>
{/* Request Overview */}
<Card>
<CardHeader>
<CardTitle>Relocation Overview</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<p className="text-slate-600 text-sm mb-1">Dealer Details</p>
<p className="text-slate-900">{request.outlet?.name}</p>
<p className="text-slate-600 text-sm">{request.outlet?.code}</p>
<p className="text-slate-600 text-xs mt-1">{request.dealer?.fullName}</p>
</div>
<div>
<p className="text-slate-600 text-sm mb-2">Relocation Route</p>
<div className="space-y-2">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-slate-400" />
<div>
<p className="text-slate-600 text-xs">From (Current)</p>
<p className="text-slate-900 text-sm">{request.currentLocation || (request.outlet ? `${request.outlet.address}, ${request.outlet.city}` : 'N/A')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-re-red" />
<div>
<p className="text-slate-600 text-xs">To (Proposed)</p>
<p className="text-slate-900 text-sm">{request.proposedLocation || `${request.newAddress}, ${request.newCity}`}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="border-slate-300 text-slate-700">
Type: {request.relocationType}
</Badge>
{request.distance && (
<Badge variant="outline" className="border-red-200 bg-red-50 text-re-red-hover">
Distance: {request.distance}
</Badge>
)}
{request.propertyType && (
<Badge variant="outline" className="border-blue-200 bg-blue-50 text-blue-700">
Property: {request.propertyType}
</Badge>
)}
{request.expectedRelocationDate && (
<Badge variant="outline" className="border-purple-200 bg-purple-50 text-purple-700">
Expected Date: {request.expectedRelocationDate}
</Badge>
)}
</div>
</div>
</div>
<div>
<p className="text-slate-600 text-sm mb-1">Request Information</p>
<p className="text-slate-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</p>
<p className="text-slate-600 text-sm">By: {request.dealer?.fullName}</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<span className="text-slate-600 text-sm">Current Stage:</span>
<Badge className={getCurrentStageBadgeClass(request.currentStage, request.status)}>
{String(request.currentStage || '').replace(/_/g, ' ')}
</Badge>
</div>
</div>
</div>
<div className="mt-6">
<p className="text-slate-600 text-sm mb-2">Reason for Relocation</p>
<p className="text-slate-900">{request.reason}</p>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<CardHeader className="pb-4">
<div className="overflow-x-auto custom-scrollbar-x-slim -mx-6 px-6">
<TabsList className="w-max min-w-full justify-start">
<TabsTrigger value="workflow">Workflow Progress</TabsTrigger>
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="history">History & Audit Trail</TabsTrigger>
</TabsList>
</div>
</CardHeader>
<CardContent>
{/* Workflow Progress Tab */}
<TabsContent value="workflow" className="mt-0 status-progress-ui">
{/* Progress Bar */}
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<span className="text-slate-900">Overall Progress</span>
<Badge className={`${statusProgressBarClass} text-white border-transparent hover:opacity-90`}>
{displayProgressPct}% Complete
</Badge>
</div>
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${statusProgressBarClass}`}
style={{ width: `${displayProgressPct}%` }}
/>
</div>
</div>
{workflowTerminalNegative && (
<div className="mb-6 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>
)}
{workflowProgressMismatch && !workflowTerminalNegative && (
<div className="mb-6 rounded-lg border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-900">
<span className="font-medium">Activity ahead of current stage:</span> Some timeline or audit
entries reference steps after the official current stage ({String(request.currentStage)}).
Per-step history below may include future steps; the highlighted step and approvals follow the
server 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">
{workflowTerminalNegative ? (
<ul className="list-disc space-y-1 pl-5 text-sm text-slate-600">
{workflowStages.map((stage: any) => (
<li key={stage.id}>
<span className="text-slate-900">{stage.name}</span> {stage.role}
</li>
))}
</ul>
) : (
workflowStages.map((stage: any, index: number) => {
const isCompleted = allWorkflowComplete || index < dbOrdinal - 1;
const isCurrent = !allWorkflowComplete && index === dbOrdinal - 1;
const stageTimelineEntries = getRelocationTimelineEntriesForStage(
timelineEntries,
stage,
index
);
const timelineEntry =
stageTimelineEntries.length > 0
? stageTimelineEntries[stageTimelineEntries.length - 1]
: null;
return (
<div key={stage.id} className="flex items-start gap-4">
<div className="flex flex-col items-center">
<div
className={`w-10 h-10 rounded-full flex items-center justify-center ${
isCompleted
? 'bg-green-100 text-green-600'
: isCurrent
? WORKFLOW_IN_PROGRESS_ACCENT.icon
: 'bg-slate-100 text-slate-400'
}`}
>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5" />
) : isCurrent ? (
<Clock className="w-5 h-5" />
) : (
<span className="text-xs font-semibold">{stage.id}</span>
)}
</div>
{index < workflowStages.length - 1 && (
<div
className={`w-0.5 h-16 ${isCompleted ? 'bg-green-300' : 'bg-slate-200'}`}
/>
)}
</div>
<div
className={`flex-1 pb-8 ${
isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.panel : ''
}`}
>
<div className="flex items-center justify-between mb-1 gap-2">
<div>
<h4
className={
isCompleted
? 'text-green-700'
: isCurrent
? WORKFLOW_IN_PROGRESS_ACCENT.title
: 'text-slate-900'
}
>
{stage.name}
</h4>
<p
className={`text-sm ${
isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.subtitle : 'text-slate-600'
}`}
>
Responsible: {stage.role}
</p>
{getAssignedReviewer(stage.name) && (
<p className="text-xs text-blue-600 font-medium mt-1">
Assigned: {getAssignedReviewer(stage.name)}
</p>
)}
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge
className={
isCompleted
? 'bg-green-100 text-green-700 border-green-300'
: isCurrent
? WORKFLOW_IN_PROGRESS_ACCENT.stageBadge
: 'bg-slate-100 text-slate-500 border-slate-300'
}
>
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'}
</Badge>
{timelineEntry && (
<div className="flex items-center gap-1 text-xs text-slate-600">
<Calendar className="w-3.5 h-3.5" />
<span>{formatDateTime(timelineEntry.timestamp || timelineEntry.createdAt)}</span>
</div>
)}
</div>
</div>
{timelineEntry && (
<div className="space-y-2 mt-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
{timelineEntry.user || 'System'}
</Badge>
<span className="text-[10px] text-slate-500 italic">
{timelineEntry.action || 'Update'}
</span>
{timelineEntry.targetStage &&
timelineEntry.targetStage !== timelineEntry.stage && (
<span className="text-[10px] text-slate-500">
{String(timelineEntry.targetStage).replace(/_/g, ' ')}
</span>
)}
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
{timelineEntry.remarks ||
timelineEntry.comments ||
'No remarks provided.'}
</div>
{stageTimelineEntries.length > 1 && (
<p className="text-[10px] text-slate-500">
{stageTimelineEntries.length} events at this stage; showing the latest.
</p>
)}
</div>
)}
</div>
</div>
);
})
)}
</div>
</TabsContent>
{/* Documents Tab */}
<TabsContent value="documents" className="mt-0">
<Tabs defaultValue="required" className="w-full">
<TabsList className="w-full justify-start mb-4">
<TabsTrigger value="required">Required for Process</TabsTrigger>
<TabsTrigger value="existing">Existing Documents</TabsTrigger>
</TabsList>
{/* Required Documents Sub-tab */}
<TabsContent value="required" className="mt-0">
<div className="space-y-4">
{/* Upload Button */}
<div className="flex items-center justify-between">
<h4 className="text-slate-900">Required Documents</h4>
<Dialog
open={isUploadDialogOpen}
onOpenChange={(open) => {
setIsUploadDialogOpen(open);
if (!open) {
setDocTypeLocked(false);
setSelectedFile(null);
}
}}
>
<DialogTrigger asChild>
<Button
size="sm"
className="bg-re-red hover:bg-re-red-hover"
onClick={() => {
setDocTypeLocked(false);
setSelectedDocType(requiredDocuments[0]);
setSelectedFile(null);
}}
>
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
{docTypeLocked
? 'Pick a file for the selected document.'
: 'Select the document type and upload the file.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{docTypeLocked ? (
<div>
<Label>Document</Label>
<div className="mt-1 flex items-center gap-2 bg-red-50 border border-red-200 rounded-md px-3 h-10">
<Badge className="bg-re-red text-white border-transparent">
{selectedDocType}
</Badge>
</div>
</div>
) : (
<div>
<Label>Document Type</Label>
<select
className="w-full mt-1 px-3 py-2 border border-slate-300 rounded-md"
value={selectedDocType}
onChange={(e) => setSelectedDocType(e.target.value)}
>
{requiredDocuments.map((doc, index) => {
const isAlreadyUploaded = request.documents?.some((d: any) =>
d.type === doc || d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0])
);
return (
<option key={index} value={doc}>
{isAlreadyUploaded ? `${doc}` : doc}
</option>
);
})}
</select>
</div>
)}
<div>
<Label>Upload File</Label>
<Input
type="file"
className="mt-1"
onChange={(e) => setSelectedFile(e.target.files?.[0] || null)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsUploadDialogOpen(false)} disabled={isUploading}>
Cancel
</Button>
<Button
className="bg-re-red hover:bg-re-red-hover"
onClick={handleUploadDocument}
disabled={isUploading}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Uploading...
</>
) : (
'Upload'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Required Documents Checklist */}
<div className="grid grid-cols-2 gap-2">
{requiredDocuments.map((doc: string, index: number) => {
const uploaded = request.documents?.find((d: any) =>
d.type === doc || d.name.toLowerCase().includes(doc.toLowerCase().split(' ')[0])
);
const isRejected = uploaded && String(uploaded.status) === 'Rejected';
const ok = uploaded && !isRejected;
return (
<div
key={index}
className={`flex items-center justify-between gap-2 p-2 rounded border text-sm ${
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-2 min-w-0">
{isRejected ? (
<AlertCircle className="w-4 h-4 text-red-600 flex-shrink-0" />
) : ok ? (
<CheckCircle2 className="w-4 h-4 text-green-600 flex-shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-slate-400 flex-shrink-0" />
)}
<span
className={`truncate ${
isRejected ? 'text-red-900' : ok ? 'text-green-900' : 'text-slate-700'
}`}
>
{doc}
</span>
</div>
{!ok && (
<Button
size="sm"
variant="ghost"
className={getDocChecklistUploadButtonClass(!!isRejected)}
onClick={() => {
setSelectedDocType(doc);
setSelectedFile(null);
setDocTypeLocked(true);
setIsUploadDialogOpen(true);
}}
>
<Upload className="w-3.5 h-3.5 mr-1" />
{isRejected ? 'Re-upload' : 'Upload'}
</Button>
)}
</div>
);
})}
</div>
</div>
</TabsContent>
{/* Existing Documents Sub-tab */}
<TabsContent value="existing" className="mt-0">
{request.documents && request.documents.length > 0 ? (
<div>
<h4 className="text-slate-900 mb-3">All Uploaded Documents</h4>
<div className="border border-slate-200 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead>Document Name</TableHead>
<TableHead>Category</TableHead>
<TableHead>Uploaded On</TableHead>
<TableHead>Uploaded By</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{request.documents.map((doc: any) => (
<TableRow
key={doc.id}
className={String(doc.status) === 'Rejected' ? 'bg-red-50/80' : undefined}
>
<TableCell className="text-slate-900">
{doc.name}
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-300">
{doc.category || 'General'}
</Badge>
</TableCell>
<TableCell className="text-slate-600">
{formatDateTime(doc.uploadedOn)}
</TableCell>
<TableCell className="text-slate-600">
{doc.uploadedBy}
</TableCell>
<TableCell>
<Badge className={getStatusColor(doc.status)}>
{doc.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
size="icon"
variant="outline"
className="h-8 w-8"
onClick={() => doc.url && handlePreviewDocument(doc)}
disabled={!doc.url}
title={doc.url ? "View Document" : "File path not available"}
>
<Eye className="w-4 h-4" />
</Button>
<Button
size="icon"
variant="outline"
className="h-8 w-8"
onClick={() => doc.url && window.open(`http://localhost:5000/${doc.url}`, '_blank')}
disabled={!doc.url}
title={doc.url ? "Download Document" : "File path not available"}
>
<Download className="w-4 h-4" />
</Button>
{doc.status === 'Pending Verification' && (() => {
const role = currentUser?.role || currentUser?.roleCode || '';
// SRS — only authorized review roles can verify relocation documents
return ['DD Lead', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
})() && (
<>
<Button
size="sm"
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1"
onClick={() => handleVerifyDocument(doc.id)}
title="Verify Document"
>
<CheckCircle2 className="w-4 h-4" />
Verify
</Button>
<Button
size="sm"
variant="destructive"
className="h-8 gap-1"
onClick={() => {
setRejectDocId(doc.id);
setRejectDocReason('');
setRejectDocDialogOpen(true);
}}
title="Reject Document"
>
<AlertCircle className="w-4 h-4" />
Reject
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
) : (
<div className="text-center py-8 text-slate-500">
No documents uploaded yet
</div>
)}
</TabsContent>
</Tabs>
</TabsContent>
{/* EOR Checklist Tab */}
<TabsContent value="eor" className="mt-0">
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h4 className="text-slate-900">EOR Readiness Checklist</h4>
<p className="text-slate-600 text-sm">Verify new location infrastructure and statutory compliances</p>
<p className="text-slate-500 text-xs mt-1 max-w-2xl">
When document types match a checklist line, proofs from the Documents tab are linked here automatically (same files; no separate EOR upload required).
</p>
</div>
{eorChecklist && (
<Badge className={getStatusColor(eorChecklist.status)}>
{eorChecklist.status}
</Badge>
)}
</div>
{!eorChecklist ? (
<div className="bg-slate-50 border border-dashed border-slate-300 rounded-lg p-12 text-center">
{isEorLoading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
<p className="text-slate-500">Fetching checklist...</p>
</div>
) : (
<>
<AlertCircle className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<h5 className="text-slate-900 mb-1">No Checklist Found</h5>
<p className="text-slate-500 text-sm mb-4">
The EOR checklist will be automatically initiated once the request reaches the final clearance stage.
</p>
<Button variant="outline" onClick={() => fetchEorChecklist(request?.id)}>
Try Refreshing
</Button>
</>
)}
</div>
) : (
<div className="space-y-4">
<div className="border border-slate-200 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50">
<TableHead className="w-[50px]"></TableHead>
<TableHead>Category</TableHead>
<TableHead>Checklist Item</TableHead>
<TableHead>Proof & Documents</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(!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.
</TableCell>
</TableRow>
) : (
eorChecklist.items.map((item: any) => (
<TableRow key={item.id}>
<TableCell>
<input
type="checkbox"
checked={item.isCompliant}
onChange={(e) => handleUpdateEorItem(item.description, e.target.checked, item.itemType)}
disabled={eorChecklist.status === 'Completed' || (currentUser?.role !== 'NBH' && currentUser?.role !== 'Super Admin')}
className="w-4 h-4 rounded border-slate-300 text-re-red"
/>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-300 capitalize text-xs">
{item.itemType}
</Badge>
</TableCell>
<TableCell className="text-slate-900 font-medium text-sm">
{item.description}
</TableCell>
<TableCell>
<div className="flex flex-col items-start gap-1">
{item.proofDocumentId && item.proofDocument ? (
<>
<span className="text-xs text-slate-600 truncate max-w-[220px]" title={item.proofDocument.fileName}>
{item.proofDocument.fileName}
</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-blue-600 px-0"
onClick={() =>
handlePreviewDocument({
name: item.proofDocument.fileName,
url: item.proofDocument.filePath,
type: item.proofDocument.documentType,
uploadedOn: item.proofDocument.updatedAt || item.proofDocument.createdAt,
mimeType: item.proofDocument.mimeType
})
}
>
<Eye className="w-3.5 h-3.5 mr-1" />
View
</Button>
</>
) : item.proofDocumentId ? (
<span className="text-xs text-re-red-hover">Proof linked (refresh if file details are missing)</span>
) : (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 text-slate-400"
disabled={eorChecklist.status === 'Completed'}
>
<Upload className="w-3.5 h-3.5 mr-1" />
Upload
</Button>
)}
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Audit Submission Button */}
{(currentUser?.role === 'NBH' || currentUser?.role === 'Super Admin') && eorChecklist.status !== 'Completed' && (
<div className="flex justify-end pt-4 border-t border-slate-200">
<Button
className="bg-green-600 hover:bg-green-700"
onClick={handleSubmitEorAudit}
disabled={
isSubmittingEor ||
!eorChecklist.items?.length ||
!eorChecklist.items.every((i: any) => i.isCompliant)
}
>
{isSubmittingEor ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-2" />
)}
Finalize EOR & Complete Relocation
</Button>
</div>
)}
{!eorChecklist.items?.every((i: any) => i.isCompliant) && (
<p className="text-right text-xs text-re-red italic">
All items must be marked as compliant before final submission.
</p>
)}
</div>
)}
</div>
</TabsContent>
{/* History Tab */}
<TabsContent value="history" className="mt-0">
<div className="space-y-4">
{auditLogs.length > 0 ? (
auditLogs.map((entry: any, index: number) => (
<div key={index} className="flex gap-4 pb-6 border-b border-slate-100 last:border-0 relative">
<div className="w-2 h-2 rounded-full bg-slate-300 mt-2 z-10" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Badge className={`
${(entry.description || entry.action || entry.details?.action || '').toLowerCase().includes('reject') || (entry.description || entry.action || entry.details?.action || '').toLowerCase().includes('revok')
? 'bg-red-100 text-red-700 border-red-200'
: (entry.description || entry.action || entry.details?.action || '').toLowerCase().includes('sent back') || (entry.description || entry.action || entry.details?.action || '').toLowerCase().includes('send back')
? 'bg-red-50 text-re-red-hover border-red-200'
: (entry.description || entry.action || entry.details?.action || '').toLowerCase().includes('approv') || (entry.description || entry.action || entry.details?.action || '').toLowerCase().includes('initi') || (entry.action || '').toLowerCase().includes('complete')
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: 'bg-slate-100 text-slate-700 border-slate-200'}
`}>
{entry.action}
</Badge>
<span className="text-xs text-slate-500 font-medium italic">by {entry.actor?.name || entry.userName || 'System'}</span>
{entry.details?.stage && (
<span className="text-[10px] text-slate-400 font-normal">at {entry.details.stage}</span>
)}
</div>
<span className="text-xs text-slate-500">
{formatDateTime(entry.timestamp || entry.createdAt)}
</span>
</div>
{(entry.remarks || entry.description || entry.newData?.remarks || entry.details?.remarks) && (
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-sm text-slate-700 shadow-sm ml-1">
{entry.remarks || entry.description || entry.newData?.remarks || entry.details?.remarks}
</div>
)}
</div>
</div>
))
) : (
<div className="text-center py-8 text-slate-500">
No history found
</div>
)}
</div>
</TabsContent>
</CardContent>
</Tabs>
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="space-y-6">
{/* Current Status Card */}
<Card>
<CardHeader>
<CardTitle>Current Status</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<p className="text-slate-600 text-sm">Current Stage</p>
<Badge className={getCurrentStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
</div>
<div>
<p className="text-slate-600 text-sm">Progress</p>
<div className="flex items-center gap-2 mt-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-300 ${statusProgressBarClass}`}
style={{ width: `${displayProgressPct}%` }}
/>
</div>
<span className="text-slate-900">{displayProgressPct}%</span>
</div>
</div>
<div>
<p className="text-slate-600 text-sm">Distance</p>
<p className="text-slate-900">{request.distance}</p>
</div>
</CardContent>
</Card>
{/* Actions Card */}
<Card>
<CardHeader>
<CardTitle>Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{showActions && (
<>
<Button
className="w-full bg-green-600 hover:bg-green-700"
onClick={() => handleAction('approve')}
disabled={isSubmitting || !canApprove}
>
{isSubmitting && actionType === 'approve' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<CheckCircle2 className="w-4 h-4 mr-2" />
)}
Approve Request
</Button>
{!canApprove && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-800">
Approval is blocked until mandatory documents are uploaded and verified for this stage.
</div>
)}
<Button
variant="destructive"
className="w-full"
onClick={() => handleAction('reject')}
disabled={isSubmitting}
>
{isSubmitting && actionType === 'reject' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<AlertCircle className="w-4 h-4 mr-2" />
)}
Reject Request
</Button>
{canSendBack && (
<Button
variant="outline"
className="w-full border-red-300 text-red-900 hover:bg-red-50"
onClick={() => handleAction('sendBack')}
disabled={isSubmitting}
>
{isSubmitting && actionType === 'sendBack' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Reply className="w-4 h-4 mr-2" />
)}
Send Back
</Button>
)}
{canRevoke && (
<Button
variant="outline"
className="w-full border-red-300 text-red-800 hover:bg-red-50"
onClick={() => handleAction('revoke')}
disabled={isSubmitting}
>
{isSubmitting && actionType === 'revoke' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Ban className="w-4 h-4 mr-2" />
)}
Revoke Request
</Button>
)}
<div className="border-t border-slate-200 pt-3 mt-3" />
</>
)}
<Button
variant="outline"
className="w-full border-blue-300 text-blue-700 hover:bg-blue-50"
onClick={() => navigate(`/worknotes/relocation/${requestId}`, {
state: {
applicationName: request?.outlet?.name || 'Relocation',
registrationNumber: request?.requestId || '',
participants: request?.participants || []
}
})}
>
<MessageSquare className="w-4 h-4 mr-2" />
Worknotes ({request?.worknotes?.length || 0})
</Button>
</CardContent>
</Card>
</div>
</div>
{/* Action Dialog */}
<Dialog open={isActionDialogOpen} onOpenChange={setIsActionDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{actionType === 'approve'
? 'Approve Request'
: actionType === 'reject'
? 'Reject Request'
: actionType === 'sendBack'
? 'Send Back Request'
: actionType === 'revoke'
? 'Revoke Request'
: 'Put Request on Hold'}
</DialogTitle>
<DialogDescription>
{actionType === 'sendBack' || actionType === 'revoke'
? 'Remarks are required and will be recorded in Work Notes and the audit trail.'
: 'Please provide comments for this action. This will be recorded in the audit trail.'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmitAction} className="space-y-4">
<div>
<Label htmlFor="comments">
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks *' : 'Comments *'}
</Label>
<div className="space-y-2" />
</div>
<Textarea
id="comments"
value={comments}
onChange={(e) => setComments(e.target.value)}
placeholder={
actionType === 'sendBack'
? 'Explain what needs to be corrected at the previous stage…'
: actionType === 'revoke'
? 'Document why this relocation request is being revoked…'
: 'Enter your comments...'
}
rows={4}
required
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsActionDialogOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
className={
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: actionType === 'reject'
? 'bg-re-red hover:bg-re-red-hover'
: actionType === 'sendBack'
? 'bg-re-red hover:bg-re-red-hover'
: actionType === 'revoke'
? 'bg-re-red hover:bg-re-red-hover'
: 'bg-re-red hover:bg-re-red-hover'
}
disabled={isSubmitting}
>
{isSubmitting ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : null}
{actionType === 'approve'
? 'Approve'
: actionType === 'reject'
? 'Reject'
: actionType === 'sendBack'
? 'Send Back'
: actionType === 'revoke'
? 'Revoke'
: 'Put on Hold'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Worknotes Dialog */}
{/* Worknotes Dialog - handled in Header */}
<Dialog open={rejectDocDialogOpen} onOpenChange={setRejectDocDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reject document</DialogTitle>
<DialogDescription>
Mark this upload as rejected and provide a reason. The action is recorded in the audit trail.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Label htmlFor="rejectDocReason">Rejection reason *</Label>
<Textarea
id="rejectDocReason"
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>
<DocumentPreviewModal
isOpen={isPreviewOpen}
onClose={() => setIsPreviewOpen(false)}
document={selectedDoc}
/>
</div>
);
}