1675 lines
77 KiB
TypeScript
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 "Try Refreshing" 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>
|
|
);
|
|
}
|