Compare commits

...

16 Commits

Author SHA1 Message Date
05211fe90a typo chnge errors and Loi Document request mail templatd added 2026-05-26 12:34:32 +05:30
dc49fa9065 dispatch feature approve restriction and some few bugs covered from tracker 2026-05-25 22:53:58 +05:30
06116af31a theme have color enhanced and smtp setup done 2026-05-20 20:16:50 +05:30
faa29a7511 started removing document type dependecy and f&F screeen bug changes fixed 2026-05-19 21:26:47 +05:30
61deac775c few de demo bugs and sla tracker implemeted alog with sla monitor screen 2026-05-18 21:10:22 +05:30
81d4dd493f bugs coverd 2026-05-15 20:20:15 +05:30
1340f44485 few more bugs fixed and dealer ide ui alignmen and visibibity chabges done F& F made manuall trigger 2026-05-14 14:39:31 +05:30
ec70f1d3f1 system log table added and feew bugs coverd from the tracker 2026-05-13 20:45:33 +05:30
201bfa6a41 new mail templates added for edge scenerios 2026-05-12 19:59:47 +05:30
c23593bb11 contitutional and relocation changes done based on document alignment 2026-05-06 10:45:55 +05:30
b357dbdcbb stage names modified and calendar addd in opportunity requests 2026-05-04 13:26:51 +05:30
2f82699572 notif
ication service enhanced even more detailed way added more templates documentented i splitted based on modulewise joint approval added for resignation flow, upload ppt document with new docment type add for DD Lead user
2026-04-30 18:52:17 +05:30
95032cf2a7 added joint approval in resignation for RBM DD-ZM approval stage 2026-04-29 16:37:06 +05:30
352c656a9e theme color changed to red i multiple places 2026-04-28 20:42:15 +05:30
2a289e433c stage transition bug resolved 2026-04-28 13:48:05 +05:30
f3927b4686 progress track isue for interview level 1 fixed 2026-04-28 08:17:23 +05:30
112 changed files with 6804 additions and 2754 deletions

1
package-lock.json generated
View File

@ -12222,7 +12222,6 @@
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,

View File

@ -47,6 +47,7 @@ import { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocati
import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
import QuestionnaireList from '@/components/admin/QuestionnaireList';
import InterviewConfigManagement from '@/features/master/components/InterviewConfigManagement';
import { SystemLogsPage } from '@/features/admin/pages/SystemLogsPage';
import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
import { NotificationsPage } from '@/pages/NotificationsPage';
import { Toaster } from '@/components/ui/sonner';
@ -56,12 +57,20 @@ import { API } from '@/api/API';
import { SocketProvider } from '@/context/SocketContext';
// Layout Component
const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => {
const AppLayout = ({
onLogout,
title,
subtitle,
}: {
onLogout: () => void;
title: string;
subtitle: string;
}) => {
return (
<div className="flex h-screen bg-slate-50 overflow-hidden">
<Sidebar onLogout={onLogout} />
<div className="flex-1 flex flex-col overflow-hidden">
<Header title={title} onRefresh={() => window.location.reload()} />
<Header title={title} subtitle={subtitle} onRefresh={() => window.location.reload()} />
<main className="flex-1 overflow-y-auto p-6">
<Outlet />
</main>
@ -77,11 +86,15 @@ export default function App() {
const navigate = useNavigate();
const location = useLocation();
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();
const userRoleCode = String(currentUser?.roleCode || '').toLowerCase();
return normalizedTargetRoles.includes(userRole) || normalizedTargetRoles.includes(userRoleCode);
};
const resignationRoles = ['DD Admin', 'DD_ADMIN', 'ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'LEGAL_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const terminationRoles = ['ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'ZBH', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Legal Admin', 'LEGAL_ADMIN', 'Legal', 'DD Admin', 'DD_ADMIN', 'CCO', 'CEO', 'Super Admin', 'SUPER_ADMIN'];
const fnfRoles = ['DD Admin', 'DD_ADMIN', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Finance', 'Finance Admin', 'FINANCE_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const financeRoles = ['Finance', 'Finance Admin'];
useEffect(() => {
@ -134,8 +147,18 @@ export default function App() {
// Helper to determine page title based on path
const getPageTitle = (pathname: string) => {
if (pathname.startsWith('/applications/') && pathname.length > 14) return 'Application Details';
if (pathname.includes('/resignation/') && pathname.length > 13) return 'Resignation Details';
// ... Add more dynamic title logic as needed
if (pathname.startsWith('/resignation/') && !pathname.startsWith('/dealer-resignation')) return 'Resignation Details';
if (pathname.startsWith('/dealer-resignation/')) return 'Resignation Request Details';
if (pathname.startsWith('/termination/')) return 'Termination Details';
if (pathname.startsWith('/fnf/')) return 'F&F Request Details';
if (pathname.startsWith('/constitutional-change/')) return 'Constitutional Change Details';
if (pathname.startsWith('/relocation-requests/')) return 'Relocation Request Details';
if (pathname.startsWith('/finance-onboarding/')) return 'Payment Details';
if (pathname.startsWith('/finance-audit/')) return 'Finance Audit';
if (pathname.startsWith('/finance-fnf/')) return 'F&F Settlement Details';
if (pathname.startsWith('/fdd-details/')) return 'FDD Audit Workspace';
if (pathname.startsWith('/questionnaire-builder/')) return 'Questionnaire Builder';
if (pathname.startsWith('/worknotes/')) return 'Work Notes';
const titles: Record<string, string> = {
'/dashboard': 'Dashboard',
'/applications': 'Dealership Requests',
@ -161,11 +184,155 @@ export default function App() {
'/approval-policies': 'Approval Policies',
'/fdd-dashboard': 'FDD Dashboard',
'/fdd-details': 'Audit Workspace',
'/questions': 'Questionnaires',
'/questionnaires': 'Questionnaire Templates',
'/interview-configs': 'Interview Configuration',
'/system-logs': 'System Logs',
'/sla-configurations': 'SLA Matrix',
'/notifications': 'Notifications',
};
return titles[pathname] || 'Dashboard';
};
/** Short context line under the main header title — varies by route (and sometimes role). */
const getPageSubtitle = (pathname: string, role: string) => {
const rl = (role || '').toLowerCase();
const isDealerRole = rl === 'dealer' || rl.includes('dealer');
const isFinanceRole = rl.includes('finance');
if (pathname.startsWith('/worknotes/')) {
return 'Collaborative notes and clarifications linked to this onboarding or offboarding record.';
}
if (pathname.startsWith('/applications/') && pathname !== '/applications') {
return 'Review stages, documents, interviews, and decisions for this single dealership onboarding application.';
}
if (pathname === '/applications') {
return 'Search, filter, and open dealership applications your role can work on.';
}
if (pathname === '/all-applications') {
return 'Cross-team view of every dealership application in the pipeline.';
}
if (pathname === '/opportunity-requests') {
return 'Applications tied to an opportunity location for DD prioritisation.';
}
if (pathname === '/non-opportunities') {
return 'Applications without a mapped opportunity; track for future reference or follow-up.';
}
if (pathname === '/dashboard') {
if (isDealerRole) {
return 'Your home for outlet actions: constitutional change (how your business is legally registered), relocation, and resignation requests.';
}
if (isFinanceRole) {
return 'Payment verification, finance audits, and F&F settlement work for dealership accounts.';
}
return 'Operational snapshot for dealership onboarding: workloads, alerts, and shortcuts for your role.';
}
if (pathname.startsWith('/dealer-resignation/')) {
return 'Read-only summary of the resignation you submitted for this outlet.';
}
if (pathname === '/dealer-resignation') {
return 'Start a new outlet resignation or open a request you already submitted.';
}
if (pathname === '/dealer-constitutional') {
return 'Constitutional change updates your outlets registered legal structure (for example sole proprietorship to private limited). Submit one request per outlet; Royal Enfield teams review documents and approve before records change.';
}
if (pathname === '/dealer-relocation') {
return 'Request a move of your dealership to a new address or territory, and track requests in progress.';
}
if (pathname.startsWith('/constitutional-change/')) {
return 'Review evidence, comments, and workflow for this constitutional change case.';
}
if (pathname === '/constitutional-change') {
return 'Process dealer requests to change registered legal constitution, supporting documents, and approvals.';
}
if (pathname.startsWith('/relocation-requests/')) {
return 'Assess feasibility, documents, and approvals for this relocation request.';
}
if (pathname === '/relocation-requests') {
return 'Manage dealer relocation proposals: new sites, handovers, and compliance checks.';
}
if (pathname.startsWith('/resignation/') && !pathname.startsWith('/dealer-resignation')) {
return 'HR workflow: clearances, handovers, and settlement steps for this resignation.';
}
if (pathname === '/resignation') {
return 'Queue of dealership resignation cases across your authorised outlets.';
}
if (pathname.startsWith('/termination/')) {
return 'Contractual exit details, evidence, and approvals for this termination case.';
}
if (pathname === '/termination') {
return 'Monitor dealership terminations, disputes, and mandated approvals.';
}
if (pathname.startsWith('/fnf/')) {
return 'Line items, deductions, and payout status for this full & final settlement.';
}
if (pathname === '/fnf') {
return 'Track F&F batches from clearance through finance payout.';
}
if (pathname.startsWith('/finance-onboarding/')) {
return 'Payment schedule, proofs, and audit notes for this onboarding application.';
}
if (pathname === '/finance-onboarding') {
return 'Validate Security Deposit, first fills, and related onboarding payments.';
}
if (pathname.startsWith('/finance-audit/')) {
return 'Finance audit trail and checklist for this application.';
}
if (pathname.startsWith('/finance-fnf/')) {
return 'Settlement calculations and release steps for this F&F record.';
}
if (pathname === '/finance-fnf') {
return 'Finance queue for F&F approvals and disbursements.';
}
if (pathname.startsWith('/fdd-details/')) {
return 'Field Development Director audit workspace for this application.';
}
if (pathname === '/fdd-dashboard') {
return 'FDD workload: audits due, flags raised, and follow-up actions.';
}
if (pathname.startsWith('/questionnaire-builder/') || pathname === '/questionnaire-builder') {
return 'Author and publish questionnaire versions used in dealership assessments.';
}
if (pathname === '/questionnaires' || pathname === '/questions') {
return 'List of published questionnaire templates and versions.';
}
if (pathname === '/master') {
return 'Hierarchy, geography, templates, and reference data shared across onboarding.';
}
if (pathname === '/users') {
return 'Create and maintain internal users, roles, and access for this portal.';
}
if (pathname === '/approval-policies') {
return 'Configure who must approve each onboarding stage or document type.';
}
if (pathname === '/sla-configurations') {
return 'Define turnaround targets and escalations for onboarding milestones.';
}
if (pathname === '/interview-configs') {
return 'Set up interview templates, panels, and scoring used during selection.';
}
if (pathname === '/system-logs') {
return 'Immutable record of configuration and administrative actions for compliance.';
}
if (pathname === '/notifications') {
return 'System and workflow alerts for your account.';
}
if (pathname === '/tasks') {
return 'Tasks assigned to you (placeholder module).';
}
if (pathname === '/reports') {
return 'Analytics and exports for onboarding performance (placeholder module).';
}
if (pathname === '/settings') {
return 'Profile, notifications, and security preferences for your account.';
}
const title = getPageTitle(pathname);
return title === 'Dashboard'
? 'Operational snapshot for dealership onboarding: workloads, alerts, and shortcuts for your role.'
: `You are viewing: ${title}.`;
};
if (loading) {
return (
<div className="flex items-center justify-center h-screen bg-slate-50">
@ -210,7 +377,11 @@ export default function App() {
{/* Internal & Dealer Routes - EXCLUDES Prospective Dealers */}
<Route element={
<RoleGuard excludeRoles={['Prospective Dealer']} redirectTo="/prospective-dashboard">
<AppLayout onLogout={handleLogout} title={getPageTitle(location.pathname)} />
<AppLayout
onLogout={handleLogout}
title={getPageTitle(location.pathname)}
subtitle={getPageSubtitle(location.pathname, currentRole)}
/>
</RoleGuard>
}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
@ -271,6 +442,11 @@ export default function App() {
? <InterviewConfigManagement />
: <Navigate to="/dashboard" />
} />
<Route path="/system-logs" element={
hasRole(['Super Admin'])
? <SystemLogsPage />
: <Navigate to="/dashboard" />
} />
{/* HR/Finance Modules (Simplified for brevity, following pattern) */}
<Route path="/resignation" element={
@ -330,10 +506,10 @@ export default function App() {
} />
<Route path="/constitutional-change" element={<ConstitutionalChangePage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/constitutional-change/${id}`)} />} />
<Route path="/constitutional-change/:id" element={<ConstitutionalChangeDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/constitutional-change')} currentUser={currentUser} />} />
<Route path="/constitutional-change/:id" element={<ConstitutionalChangeDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate(hasRole(['Dealer']) ? '/dealer-constitutional' : '/constitutional-change')} currentUser={currentUser} />} />
<Route path="/relocation-requests" element={<RelocationRequestPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/relocation-requests/${id}`)} />} />
<Route path="/relocation-requests/:id" element={<RelocationRequestDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/relocation-requests')} currentUser={currentUser} />} />
<Route path="/relocation-requests/:id" element={<RelocationRequestDetails requestId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate(hasRole(['Dealer']) ? '/dealer-relocation' : '/relocation-requests')} currentUser={currentUser} />} />
{/* Dealer Routes */}
<Route path="/dealer-resignation" element={<DealerResignationPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/dealer-resignation/${id}`)} />} />

View File

@ -43,6 +43,7 @@ export const API = {
exportApplicationResponses: (params: { applicationIds: string }) => client.get('/onboarding/applications/export-responses', params),
getApplications: (params?: any) => client.get('/onboarding/applications', params),
shortlistApplications: (data: any) => client.post('/onboarding/applications/shortlist', data),
sendBulkReminders: (data: { applicationIds: string[] }) => client.post('/onboarding/applications/reminders', data),
getApplicationById: (id: string) => client.get(`/onboarding/applications/${id}`),
updateApplication: (id: string, data: any) => client.put(`/onboarding/applications/${id}`, data),
getLatestQuestionnaire: () => client.get('/questionnaire/latest'),
@ -53,6 +54,10 @@ export const API = {
assignArchitectureTeam: (applicationId: string, assignedTo: string) => client.post(`/onboarding/applications/${applicationId}/assign-architecture`, { assignedTo }),
updateArchitectureStatus: (applicationId: string, status: string, remarks?: string) => client.post(`/onboarding/applications/${applicationId}/architecture-status`, { status, remarks }),
generateDealerCodes: (applicationId: string) => client.post(`/onboarding/applications/${applicationId}/generate-codes`),
requestProspectDocuments: (
applicationId: string,
data: { documentTypes: string[]; dueDays?: number; customMessage?: string }
) => client.post(`/onboarding/applications/${applicationId}/request-documents`, data),
updateApplicationStatus: (id: string, data: any) => client.put(`/onboarding/applications/${id}/status`, data),
convertToOpportunity: (id: string, data?: any) => client.post(`/onboarding/applications/${id}/convert-to-opportunity`, data),
bulkConvertToOpportunity: (data: any) => client.post('/onboarding/applications/bulk-convert-to-opportunity', data),
@ -157,11 +162,17 @@ export const API = {
getResignations: (params?: any) => client.get('/resignation', params),
createResignation: (data: any) => client.post('/resignation', data),
approveResignation: (id: string, data?: any) => client.post(`/resignation/${id}/approve`, data),
dispatchResignation: (id: string, data?: any) => client.post(`/resignation/${id}/dispatch`, data),
rejectResignation: (id: string, data: any) => client.post(`/resignation/${id}/reject`, data),
withdrawResignation: (id: string, reason: string) => client.post(`/resignation/${id}/withdraw`, { reason }),
getTerminations: (params?: any) => client.get('/termination', params),
createTermination: (data: any) => client.post('/termination', data),
createTermination: (data: any) => {
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData;
return client.post('/termination', data, isFormData ? {
headers: { 'Content-Type': 'multipart/form-data' }
} : undefined);
},
updateTermination: (id: string, data: any) => client.post(`/termination/${id}/status`, data),
getOnboardingPayments: () => client.get('/settlement/onboarding'),
@ -206,11 +217,26 @@ export const API = {
updateConstitutionalChange: (id: string, action: ConstitutionalChangeAction, data?: { comments?: string; remarks?: string }) =>
client.post(`/constitutional-change/${id}/action`, { action, ...data }),
uploadConstitutionalDocuments: (id: string, documents: any[]) => client.post(`/constitutional-change/${id}/documents`, { documents }),
verifyConstitutionalDocument: (id: string, documentId: string) =>
client.post(`/constitutional-change/${id}/documents/${documentId}/verify`),
rejectConstitutionalDocument: (id: string, documentId: string, data?: { remarks?: string }) =>
client.post(`/constitutional-change/${id}/documents/${documentId}/reject`, data || {}),
// SLA
getSlaConfigs: () => client.get('/master/sla-configs'),
saveSlaConfig: (data: any) => client.post('/master/sla-configs', data),
initializeDefaultSlas: () => client.post('/master/sla-configs/initialize'),
getSlaOperationsDashboard: (params?: { module?: string; breachedOnly?: boolean; mineOnly?: boolean }) =>
client.get('/sla/operations/dashboard', params),
postSlaBatchStatus: (data: { items: Array<{ entityType: string; entityId: string }> }) =>
client.post('/sla/status/batch', data),
getQuestionnaireReminderSettings: () => client.get('/sla/settings/questionnaire-reminder'),
updateQuestionnaireReminderSettings: (data: {
enabled?: boolean;
firstAfterDays?: number;
intervalDays?: number;
maxCount?: number;
}) => client.put('/sla/settings/questionnaire-reminder', data),
// Interview Configs
getInterviewConfigs: (params?: any) => client.get('/master/interview-configs', params),
@ -238,6 +264,21 @@ export const API = {
getFddAssignment: (applicationId: string) => client.get(`/fdd/${applicationId}`),
assignFddAgency: (data: any) => client.post('/fdd/assign', data),
flagNonResponsive: (data: any) => client.post('/flag', data),
// System Audit Logs (segregated `system_audit_logs` table — Super Admin only)
getSystemAuditLogs: (params?: {
module?: string;
entityType?: string;
entityId?: string;
action?: string;
userId?: string;
search?: string;
dateFrom?: string;
dateTo?: string;
page?: number;
limit?: number;
}) => client.get('/audit/system-logs', params),
getSystemAuditSummary: () => client.get('/audit/system-summary'),
};
export default API;

View File

@ -175,7 +175,7 @@ export function ApprovalPoliciesPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Settings2 className="w-6 h-6 text-amber-600" />
<Settings2 className="w-6 h-6 text-re-red" />
Approval Policies
</h1>
<p className="text-slate-500">Configure stage-level approvers, mode, and minimum approvals.</p>
@ -185,7 +185,7 @@ export function ApprovalPoliciesPage() {
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button className="bg-amber-600 hover:bg-amber-700" onClick={openCreateModal}>
<Button className="bg-re-red hover:bg-re-red-hover" onClick={openCreateModal}>
<Plus className="w-4 h-4 mr-2" />
Add New Policy
</Button>
@ -197,7 +197,7 @@ export function ApprovalPoliciesPage() {
<CardTitle className="text-lg font-semibold text-slate-800">Configured Stages</CardTitle>
</CardHeader>
<CardContent className="p-0">
<div className="overflow-x-auto">
<div className="overflow-x-auto custom-scrollbar-x-slim">
<Table>
<TableHeader className="bg-slate-50/50">
<TableRow>
@ -244,7 +244,7 @@ export function ApprovalPoliciesPage() {
<Button
size="sm"
variant="ghost"
className="text-amber-600 hover:text-amber-700 hover:bg-amber-50 h-8 px-2"
className="text-re-red hover:text-re-red-hover hover:bg-red-50 h-8 px-2"
onClick={() => openEditModal(policy)}
>
<Edit2 className="w-4 h-4 mr-1.5" />
@ -264,7 +264,7 @@ export function ApprovalPoliciesPage() {
<DialogContent className="sm:max-w-[480px] overflow-visible">
<DialogHeader className="gap-1 pb-2 border-b">
<DialogTitle className="text-base flex items-center gap-2">
{isEditMode ? <Edit2 className="w-4 h-4 text-amber-600" /> : <Plus className="w-4 h-4 text-amber-600" />}
{isEditMode ? <Edit2 className="w-4 h-4 text-re-red" /> : <Plus className="w-4 h-4 text-re-red" />}
{isEditMode ? 'Edit Policy' : 'Create New Policy'}
</DialogTitle>
<DialogDescription className="text-[11px]">
@ -307,7 +307,7 @@ export function ApprovalPoliciesPage() {
))}
<SelectGroup>
<DropdownMenuSeparator />
<SelectItem value="CUSTOM" className="text-xs font-semibold text-amber-600 italic">
<SelectItem value="CUSTOM" className="text-xs font-semibold text-re-red italic">
+ Enter Custom Stage Code
</SelectItem>
</SelectGroup>
@ -376,10 +376,10 @@ export function ApprovalPoliciesPage() {
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="w-full justify-between h-8 text-[11px] text-slate-500 font-normal border-slate-200">
<div className="flex items-center gap-1.5">
<Plus className="w-3 h-3 text-amber-600" />
<Plus className="w-3 h-3 text-re-red" />
<span>Add Roles...</span>
</div>
<Badge variant="secondary" className="bg-amber-50 text-amber-700 border-transparent text-[9px] px-1 h-4">
<Badge variant="secondary" className="bg-red-50 text-re-red-hover border-transparent text-[9px] px-1 h-4">
{draft.requiredRoles.length}
</Badge>
</Button>
@ -445,7 +445,7 @@ export function ApprovalPoliciesPage() {
<Button variant="outline" size="sm" className="text-xs h-8" onClick={() => setIsModalOpen(false)}>
Cancel
</Button>
<Button className="bg-amber-600 hover:bg-amber-700 h-8 text-xs font-semibold" onClick={savePolicy}>
<Button className="bg-re-red hover:bg-re-red-hover h-8 text-xs font-semibold" onClick={savePolicy}>
<Save className="w-3 h-3 mr-1.5" />
{isEditMode ? 'Save Changes' : 'Create Policy'}
</Button>

View File

@ -204,7 +204,7 @@ const QuestionnaireBuilder: React.FC = () => {
if (fetching) {
return (
<div className="flex justify-center p-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-re-red"></div>
</div>
);
}
@ -230,7 +230,7 @@ const QuestionnaireBuilder: React.FC = () => {
</div>
<div className="flex flex-wrap items-center gap-4 w-full md:w-auto">
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${totalWeight === 100 ? 'bg-green-50 border-green-200 text-green-700' : 'bg-amber-50 border-amber-200 text-amber-700'}`}>
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg border ${totalWeight === 100 ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-re-red-hover'}`}>
{totalWeight !== 100 && <AlertCircle size={16} />}
<span className="text-sm font-semibold">Total Score: {totalWeight}/100</span>
</div>
@ -240,13 +240,13 @@ const QuestionnaireBuilder: React.FC = () => {
type="text"
value={version}
onChange={(e) => setVersion(e.target.value)}
className="border border-slate-300 p-2 rounded-lg w-full md:w-48 text-sm focus:ring-2 focus:ring-amber-500 outline-none"
className="border border-slate-300 p-2 rounded-lg w-full md:w-48 text-sm focus:ring-2 focus:ring-red-500 outline-none"
placeholder="Version Name (e.g. v2.0)"
/>
<button
onClick={handleSave}
disabled={loading}
className="bg-amber-600 text-white px-6 py-2 rounded-lg flex items-center gap-2 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed transition shadow-sm font-medium whitespace-nowrap"
className="bg-re-red text-white px-6 py-2 rounded-lg flex items-center gap-2 hover:bg-re-red-hover disabled:bg-slate-300 disabled:cursor-not-allowed transition shadow-sm font-medium whitespace-nowrap"
>
<Save size={18} /> {loading ? 'Saving...' : 'Publish'}
</button>
@ -271,7 +271,7 @@ const QuestionnaireBuilder: React.FC = () => {
type="text"
value={q.questionText}
onChange={(e) => updateQuestion(index, 'questionText', e.target.value)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none transition-shadow"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none transition-shadow"
placeholder="Enter your question here..."
/>
</div>
@ -281,7 +281,7 @@ const QuestionnaireBuilder: React.FC = () => {
<select
value={q.sectionName}
onChange={(e) => updateQuestion(index, 'sectionName', e.target.value)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none bg-white"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none bg-white"
>
{SECTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
@ -292,16 +292,16 @@ const QuestionnaireBuilder: React.FC = () => {
<select
value={q.inputType}
onChange={(e) => updateQuestion(index, 'inputType', e.target.value as any)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none bg-white"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none bg-white"
>
<option value="text">Text Input</option>
<option value="text">One Liner</option>
<option value="email">Email Address</option>
<option value="textarea">Long Text (Textarea)</option>
<option value="textarea">Paragraph</option>
<option value="number">Numeric</option>
<option value="file">File Upload</option>
<option value="yesno">Yes / No</option>
<option value="select">Multiple Choice (Dropdown)</option>
<option value="radio">Multiple Choice (Radio)</option>
<option value="select">Options (Dropdown)</option>
<option value="radio">Options (Radio)</option>
</select>
</div>
@ -313,17 +313,17 @@ const QuestionnaireBuilder: React.FC = () => {
type="number"
value={isNaN(q.weight) ? 0 : q.weight}
onChange={(e) => updateQuestion(index, 'weight', parseFloat(e.target.value) || 0)}
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none pl-3 pr-8"
className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-red-500 outline-none pl-3 pr-8"
title="Weightage"
/>
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 text-xs font-bold">%</span>
</div>
</div>
{/* <div className="flex items-center gap-2 px-3 py-2.5 bg-white border border-slate-200 rounded-lg cursor-pointer hover:border-amber-300 transition-colors"
{/* <div className="flex items-center gap-2 px-3 py-2.5 bg-white border border-slate-200 rounded-lg cursor-pointer hover:border-red-300 transition-colors"
onClick={() => updateQuestion(index, 'isMandatory', !q.isMandatory)}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${q.isMandatory ? 'bg-amber-600 border-amber-600' : 'border-slate-300'}`}>
<div className={`w-4 h-4 rounded border flex items-center justify-center transition-colors ${q.isMandatory ? 'bg-re-red border-re-red' : 'border-slate-300'}`}>
{q.isMandatory && <div className="w-2 h-2 bg-white rounded-sm" />}
</div>
<span className="text-xs font-medium text-slate-600 select-none">Req.</span>
@ -345,7 +345,7 @@ const QuestionnaireBuilder: React.FC = () => {
type="text"
value={opt.text}
onChange={(e) => updateOption(index, optIndex, 'text', e.target.value)}
className="flex-1 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none"
className="flex-1 border border-slate-300 p-2 rounded-md text-sm focus:ring-1 focus:ring-red-500 outline-none"
placeholder={`Option ${optIndex + 1}`}
/>
<div className="flex items-center gap-2">
@ -356,7 +356,7 @@ const QuestionnaireBuilder: React.FC = () => {
max={isNaN(q.weight) ? 0 : q.weight}
min={0}
onChange={(e) => updateOption(index, optIndex, 'score', e.target.value)}
className={`w-20 border ${(opt.score > q.weight) || isNaN(opt.score) ? 'border-red-500 text-red-600' : 'border-slate-300'} p-2 rounded-md text-sm focus:ring-1 focus:ring-amber-500 outline-none`}
className={`w-20 border ${(opt.score > q.weight) || isNaN(opt.score) ? 'border-red-500 text-red-600' : 'border-slate-300'} p-2 rounded-md text-sm focus:ring-1 focus:ring-red-500 outline-none`}
/>
</div>
<button
@ -371,7 +371,7 @@ const QuestionnaireBuilder: React.FC = () => {
</div>
<button
onClick={() => addOption(index)}
className="mt-3 text-sm flex items-center gap-1 text-amber-600 hover:text-amber-700 font-medium"
className="mt-3 text-sm flex items-center gap-1 text-re-red hover:text-re-red-hover font-medium"
>
<Plus size={16} /> Add Option
</button>
@ -392,7 +392,7 @@ const QuestionnaireBuilder: React.FC = () => {
<button
onClick={addQuestion}
className="mt-8 w-full border-2 border-dashed border-slate-300 p-4 rounded-xl text-slate-500 hover:border-amber-500 hover:text-amber-600 hover:bg-amber-50/30 flex justify-center items-center gap-2 transition-all font-medium"
className="mt-8 w-full border-2 border-dashed border-slate-300 p-4 rounded-xl text-slate-500 hover:border-red-500 hover:text-re-red hover:bg-red-50/30 flex justify-center items-center gap-2 transition-all font-medium"
>
<Plus size={20} /> Add Another Question
</button>

View File

@ -47,7 +47,7 @@ const QuestionnaireList: React.FC = () => {
</div>
<button
onClick={() => navigate('/questionnaire-builder')}
className="bg-amber-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-amber-700 transition"
className="bg-re-red text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-re-red-hover transition"
>
<Plus size={20} /> Create New Version
</button>
@ -55,14 +55,14 @@ const QuestionnaireList: React.FC = () => {
{loading ? (
<div className="flex justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-re-red"></div>
</div>
) : versions.length === 0 ? (
<div className="text-center p-12 bg-white rounded-lg shadow-sm border border-slate-200">
<p className="text-slate-500 mb-4">No questionnaire versions found.</p>
<button
onClick={() => navigate('/questionnaire-builder')}
className="text-amber-600 font-medium hover:underline"
className="text-re-red font-medium hover:underline"
>
Create your first version
</button>
@ -102,7 +102,7 @@ const QuestionnaireList: React.FC = () => {
<td className="p-4 text-right">
<button
onClick={() => navigate(`/questionnaire-builder/${v.id}`)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium text-slate-600 hover:text-amber-600 hover:bg-amber-50 transition border border-slate-200 hover:border-amber-200"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded text-sm font-medium text-slate-600 hover:text-re-red hover:bg-red-50 transition border border-slate-200 hover:border-red-200"
>
<Edit2 size={14} /> Edit / Clone
</button>

View File

@ -219,7 +219,7 @@ export function UserManagementPage() {
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Users className="w-6 h-6 text-amber-600" />
<Users className="w-6 h-6 text-re-red" />
User Management
</h1>
<p className="text-slate-500">Manage system users, roles, and access permissions.</p>
@ -232,7 +232,7 @@ export function UserManagementPage() {
zoneId: '', regionId: '', stateId: '', districtId: ''
}); setShowUserModal(true);
}}
className="bg-amber-600 hover:bg-amber-700 text-white shrink-0"
className="bg-re-red hover:bg-re-red-hover text-white shrink-0"
>
<UserPlus className="w-4 h-4 mr-2" />
Add New User
@ -318,7 +318,7 @@ export function UserManagementPage() {
<TableRow key={user.id} className="hover:bg-slate-50/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center font-bold">
<div className="w-10 h-10 rounded-full bg-red-100 text-re-red-hover flex items-center justify-center font-bold">
{user.fullName?.charAt(0) || user.email?.charAt(0)}
</div>
<div>
@ -384,7 +384,7 @@ export function UserManagementPage() {
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button variant="ghost" size="icon" onClick={() => handleEditUser(user)}>
<Edit2 className="w-4 h-4 text-slate-400 hover:text-amber-600" />
<Edit2 className="w-4 h-4 text-slate-400 hover:text-re-red" />
</Button>
<Button variant="ghost" size="icon">
<Trash2 className="w-4 h-4 text-slate-400 hover:text-red-600" />
@ -569,7 +569,7 @@ export function UserManagementPage() {
<DialogFooter className="bg-slate-50 -mx-6 -mb-6 p-4 rounded-b-lg">
<Button variant="outline" onClick={() => setShowUserModal(false)}>Cancel</Button>
<Button className="bg-amber-600 hover:bg-amber-700 text-white" onClick={handleSubmit}>
<Button className="bg-re-red hover:bg-re-red-hover text-white" onClick={handleSubmit}>
{editingUser ? 'Save Changes' : 'Create User'}
</Button>
</DialogFooter>

View File

@ -198,8 +198,8 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-amber-50 file:text-amber-700
hover:file:bg-amber-100"
file:bg-red-50 file:text-re-red
hover:file:bg-red-100"
onChange={(e) => handleFileChange(q.id, e)}
disabled={readOnly}
/>
@ -240,7 +240,7 @@ const QuestionnaireForm: React.FC<QuestionnaireFormProps> = ({
checked={responses[q.id] === val}
onChange={() => handleInputChange(q.id, val)}
disabled={readOnly}
className="text-amber-600 focus:ring-amber-500 w-4 h-4"
className="text-re-red focus:ring-re-red w-4 h-4"
/>
<span className="text-gray-700">{val}</span>
</label>

View File

@ -18,10 +18,12 @@ import { formatDistanceToNow } from 'date-fns';
interface HeaderProps {
title: string;
/** Context line under the title; changes per route in App layout. */
subtitle: string;
onRefresh?: () => void;
}
export function Header({ title, onRefresh }: HeaderProps) {
export function Header({ title, subtitle, onRefresh }: HeaderProps) {
const { user: currentUser } = useSelector((state: RootState) => state.auth);
const { socket } = useSocket();
const [notifications, setNotifications] = useState<Notification[]>([]);
@ -101,7 +103,7 @@ export function Header({ title, onRefresh }: HeaderProps) {
<div className="flex items-center justify-between">
<div>
<h1 className="text-slate-900">{title}</h1>
<p className="text-slate-600">Manage and track dealership applications</p>
<p className="text-slate-600 text-sm leading-snug max-w-3xl">{subtitle}</p>
</div>
<div className="flex items-center gap-3">

View File

@ -14,7 +14,8 @@ import {
RefreshCcw,
MapPin,
ClipboardList,
ListChecks
ListChecks,
Activity
} from 'lucide-react';
import { useState, useRef, useCallback, useEffect } from 'react';
import ReactDOM from 'react-dom';
@ -46,13 +47,16 @@ export function Sidebar({ onLogout }: SidebarProps) {
const [flyout, setFlyout] = useState<FlyoutState | null>(null);
const hoverTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const currentRole = currentUser?.role || currentUser?.roleCode || '';
const normalizedRole = String(currentRole).trim().toLowerCase();
const hasRole = (roles: string[]) => roles.map((r) => r.toLowerCase()).includes(normalizedRole);
const hasRole = (roles: string[]) => {
const normalizedTargetRoles = roles.map((r) => r.toLowerCase());
const userRole = String(currentUser?.role || '').toLowerCase();
const userRoleCode = String(currentUser?.roleCode || '').toLowerCase();
return normalizedTargetRoles.includes(userRole) || normalizedTargetRoles.includes(userRoleCode);
};
const resignationRoles = ['DD Admin', 'ASM', 'RBM', 'DD Lead', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'Super Admin'];
const terminationRoles = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Legal', 'DD Admin', 'CCO', 'CEO', 'Super Admin'];
const fnfRoles = ['DD Admin', 'DD Lead', 'NBH', 'Finance', 'Finance Admin', 'Super Admin'];
const resignationRoles = ['DD Admin', 'DD_ADMIN', 'ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'ZBH', 'NBH', 'Legal', 'Legal Admin', 'LEGAL_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const terminationRoles = ['ASM', 'RBM', 'DD-ZM', 'DD_ZM', 'ZBH', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Legal Admin', 'LEGAL_ADMIN', 'Legal', 'DD Admin', 'DD_ADMIN', 'CCO', 'CEO', 'Super Admin', 'SUPER_ADMIN'];
const fnfRoles = ['DD Admin', 'DD_ADMIN', 'DD-ZM', 'DD_ZM', 'DD Lead', 'DD_LEAD', 'DD Head', 'DD_HEAD', 'NBH', 'Finance', 'Finance Admin', 'FINANCE_ADMIN', 'Super Admin', 'SUPER_ADMIN'];
const canSeeResignation = hasRole(resignationRoles);
const canSeeTermination = hasRole(terminationRoles);
const canSeeFnF = hasRole(fnfRoles);
@ -111,6 +115,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
menuItems.push({ id: 'users', label: 'User Management', icon: Users });
menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList });
menuItems.push({ id: 'interview-configs', label: 'Interview Configs', icon: ListChecks });
menuItems.push({ id: 'system-logs', label: 'System Logs', icon: Activity });
}
const handleSearch = (e: React.FormEvent) => {
@ -175,7 +180,7 @@ export function Sidebar({ onLogout }: SidebarProps) {
<div className="flex flex-col min-w-0">
<img src="/assets/images/Re_Logo.png" alt="Royal Enfield" className="h-6 w-auto" />
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-slate-400 mt-1 whitespace-nowrap">
Dealer Onboarding
Dealer Network
</span>
</div>
<button

View File

@ -65,6 +65,20 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
}
};
const isEmailValid = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
const isMobileValid = (mobile: string) => /^[0-9]{10}$/.test(mobile);
const isPincodeValid = (pincode: string) => /^[0-9]{6}$/.test(pincode);
const isFormValid = Boolean(
formData.country && formData.stateId && formData.districtId && formData.name &&
formData.interestedCity && formData.email && formData.pincode && formData.mobile &&
formData.ownRoyalEnfield && formData.age && formData.education &&
formData.companyName && formData.source && formData.existingDealer &&
formData.description && formData.address && formData.acceptTerms &&
otpVerified && isEmailValid(formData.email) && isMobileValid(formData.mobile) && isPincodeValid(formData.pincode) &&
(formData.ownRoyalEnfield === 'no' || (formData.ownRoyalEnfield === 'yes' && formData.royalEnfieldModel))
);
const handleVerifyMobile = () => {
if (!formData.mobile || formData.mobile.length < 10) {
toast.error('Please enter a valid mobile number');
@ -93,6 +107,11 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
return;
}
if (formData.ownRoyalEnfield === 'yes' && !formData.royalEnfieldModel) {
toast.error('Please select your motorcycle model');
return;
}
if (!formData.acceptTerms) {
toast.error('Please accept the terms and conditions');
return;
@ -145,10 +164,10 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
};
const reModels = [
"Classic 650", "Scram 440", "Goan Classic 350", "Bear 650", "Guerrilla 450",
"Shotgun 650", "Himalayan 450", "Bullet 350", "Super Meteor 650", "Hunter 350",
"Scram 411", "Meteor 350", "Interceptor INT 650", "Continental GT 650",
"Classic 350", "Other Royal Enfield motorcycle"
"Continental GT", "Interceptor 650", "Himalayan", "Classic 350",
"Classic 500", "Thunderbird 350", "Thunderbird 500", "Thunderbird X 350",
"Thunderbird X 500", "Bullet 350", "Bullet 500", "Bullet ES",
"Bullet Trials 350", "Bullet Trials 500", "Other Royal Enfield motorcycle"
];
const sourceOptions = [
@ -257,24 +276,35 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
onChange={(e) => setFormData({...formData, interestedCity: e.target.value})}
/>
<Input
type="email"
placeholder="Email Id*"
className="h-[44px] border-[#cccccc] rounded-none px-4 text-[14px] focus-visible:ring-1 focus-visible:ring-black placeholder:text-[#999999]"
value={formData.email}
onChange={(e) => setFormData({...formData, email: e.target.value})}
/>
<Input
type="text"
maxLength={6}
placeholder="Pincode*"
className="h-[44px] border-[#cccccc] rounded-none px-4 text-[14px] focus-visible:ring-1 focus-visible:ring-black placeholder:text-[#999999]"
value={formData.pincode}
onChange={(e) => setFormData({...formData, pincode: e.target.value})}
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
setFormData({...formData, pincode: val});
}}
/>
<div className="relative">
<Input
type="text"
maxLength={10}
placeholder="Mobile No.*"
className="h-[44px] border-[#cccccc] rounded-none px-4 text-[14px] focus-visible:ring-1 focus-visible:ring-black placeholder:text-[#999999]"
value={formData.mobile}
onChange={(e) => setFormData({...formData, mobile: e.target.value})}
onChange={(e) => {
const val = e.target.value.replace(/\D/g, '');
setFormData({...formData, mobile: val});
}}
/>
{!otpVerified ? (
<button
@ -301,7 +331,13 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
type="radio"
className="hidden"
checked={formData.ownRoyalEnfield === val}
onChange={() => setFormData({...formData, ownRoyalEnfield: val})}
onChange={() => {
setFormData({
...formData,
ownRoyalEnfield: val,
royalEnfieldModel: val === 'no' ? '' : formData.royalEnfieldModel
});
}}
/>
<span className="text-[14px] capitalize">{val}</span>
</label>
@ -316,18 +352,19 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
onChange={(e) => setFormData({...formData, age: e.target.value})}
/>
{formData.ownRoyalEnfield === 'yes' && (
<div className="relative">
<select
className="w-full h-[44px] px-4 border border-[#cccccc] appearance-none bg-white text-[14px] outline-none disabled:bg-slate-50"
className="w-full h-[44px] px-4 border border-[#cccccc] appearance-none bg-white text-[14px] outline-none"
value={formData.royalEnfieldModel}
disabled={formData.ownRoyalEnfield !== 'yes'}
onChange={(e) => setFormData({...formData, royalEnfieldModel: e.target.value})}
>
<option value="">Motorcycle Owned</option>
<option value="">Select Motorcycle*</option>
{reModels.map(m => <option key={m} value={m}>{m}</option>)}
</select>
<ChevronDown className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
</div>
)}
<Input
placeholder="Education Qualification*"
@ -406,14 +443,15 @@ export function ApplicationFormPage({ onAdminLogin }: ApplicationFormPageProps)
onCheckedChange={(checked) => setFormData({...formData, acceptTerms: checked as boolean})}
/>
<label htmlFor="terms" className="text-[14px] font-medium cursor-pointer">
I accept the <b>terms and conditions</b> as well as <b>privacy policy</b>.
I accept the <b>terms and conditions</b> as well as <b>privacy policy</b>.<span className="text-red-500">*</span>
</label>
</div>
</div>
<button
type="submit"
className="h-12 px-10 bg-black text-white flex items-center gap-3 hover:bg-slate-900 transition-colors"
disabled={!isFormValid}
className="h-12 px-10 bg-black text-white flex items-center gap-3 hover:bg-slate-900 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<span className="font-bold uppercase tracking-wider text-[14px]">Submit</span>
<ChevronRight className="w-4 h-4" />

View File

@ -0,0 +1,33 @@
import { Badge } from '@/components/ui/badge';
import { SlaBucket, SlaStatusSnapshot } from '@/services/sla.service';
const BUCKET_CLASS: Record<SlaBucket, string> = {
healthy: 'bg-emerald-100 text-emerald-800 border-emerald-200',
warning: 'bg-red-50 text-red-800 border-red-200',
critical: 'bg-orange-100 text-orange-800 border-orange-200',
breached: 'bg-red-100 text-red-800 border-red-200'
};
const BUCKET_LABEL: Record<SlaBucket, string> = {
healthy: 'On track',
warning: 'Due soon',
critical: 'At risk',
breached: 'Breached'
};
export function SlaBadge({ status, compact }: { status: SlaStatusSnapshot | null | undefined; compact?: boolean }) {
if (!status) return null;
const bucket = status.isPaused ? 'warning' : status.bucket;
const label = status.isPaused ? 'Paused' : BUCKET_LABEL[status.bucket];
return (
<Badge
variant="outline"
className={`text-[10px] font-semibold ${BUCKET_CLASS[bucket]} ${compact ? 'px-1.5' : ''}`}
title={`${status.stageName} · ${status.remainingLabel} (${status.percentUsed}% of TAT)`}
>
{compact ? label : `${label} · ${status.remainingLabel}`}
</Badge>
);
}

View File

@ -38,8 +38,8 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<>
<div className="flex items-center justify-between p-4 border-b bg-slate-50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-100 flex items-center justify-center border border-amber-200">
<Eye className="w-5 h-5 text-amber-600" />
<div className="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center border border-red-200">
<Eye className="w-5 h-5 text-re-red" />
</div>
<div>
<DialogTitle className="text-sm font-bold text-slate-900 leading-none mb-1">

View File

@ -7,9 +7,12 @@ import { cn } from "./utils";
function Progress({
className,
indicatorClassName,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
}: React.ComponentProps<typeof ProgressPrimitive.Root> & {
indicatorClassName?: string;
}) {
return (
<ProgressPrimitive.Root
data-slot="progress"
@ -21,7 +24,7 @@ function Progress({
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
className={cn("bg-primary h-full w-full flex-1 transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@ -42,14 +42,18 @@ function ScrollBar({
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
"h-1 flex-col border-t border-t-transparent",
className,
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
className={cn(
"relative flex-1 rounded-full",
orientation === "vertical" && "bg-border",
orientation === "horizontal" && "bg-slate-200/70"
)}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);

View File

@ -8,7 +8,7 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
className="relative w-full overflow-x-auto custom-scrollbar-x-slim"
>
<table
data-slot="table"

View File

@ -0,0 +1,612 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Activity,
RefreshCw,
Search,
ChevronLeft,
ChevronRight,
Eye,
User as UserIcon,
Filter
} from 'lucide-react';
import { toast } from 'sonner';
import { API } from '@/api/API';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog';
type SystemAuditLog = {
id: string;
module: string;
moduleLabel: string;
action: string;
actionLabel: string;
entityType: string;
entityId: string | null;
entityLabel: string | null;
description: string;
actor: {
id: string | null;
name: string;
role: string | null;
email: string | null;
};
oldData: any;
newData: any;
metadata: any;
ipAddress: string | null;
userAgent: string | null;
timestamp: string;
};
type Summary = {
totalEntries: number;
byModule: { module: string; moduleLabel: string; total: number }[];
lastActivity: SystemAuditLog | null;
};
const MODULE_OPTIONS = [
{ value: '__all__', label: 'All Modules' },
{ value: 'QUESTIONNAIRE', label: 'Questionnaire' },
{ value: 'INTERVIEW_CONFIG', label: 'Interview Configuration' },
{ value: 'SYSTEM_CONFIG', label: 'System Configuration' },
{ value: 'SLA_CONFIG', label: 'SLA Configuration' },
{ value: 'EMAIL_TEMPLATE', label: 'Email Template' },
{ value: 'MASTER_HIERARCHY', label: 'Master Hierarchy' },
{ value: 'ROLE_ASSIGNMENT', label: 'Role Assignment' },
{ value: 'USER_ADMIN', label: 'User Administration' },
{ value: 'DEALER_MAPPING', label: 'Dealer Mapping' }
];
const ACTION_OPTIONS = [
{ value: '__all__', label: 'All Actions' },
{ value: 'CREATED', label: 'Created' },
{ value: 'UPDATED', label: 'Updated' },
{ value: 'DELETED', label: 'Deleted' },
{ value: 'ACTIVATED', label: 'Activated' },
{ value: 'DEACTIVATED', label: 'Deactivated' },
{ value: 'INITIALIZED', label: 'Initialized' },
{ value: 'SUBMITTED', label: 'Submitted' },
{ value: 'ASSIGNED', label: 'Assigned' },
{ value: 'UNASSIGNED', label: 'Unassigned' },
{ value: 'REORDERED', label: 'Reordered' }
];
const ACTION_BADGE_CLASS: Record<string, string> = {
CREATED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
UPDATED: 'bg-blue-100 text-blue-700 border-blue-200',
DELETED: 'bg-rose-100 text-rose-700 border-rose-200',
ACTIVATED: 'bg-emerald-100 text-emerald-700 border-emerald-200',
DEACTIVATED: 'bg-slate-200 text-slate-700 border-slate-300',
INITIALIZED: 'bg-red-50 text-re-red-hover border-red-200',
SUBMITTED: 'bg-indigo-100 text-indigo-700 border-indigo-200',
ASSIGNED: 'bg-violet-100 text-violet-700 border-violet-200',
UNASSIGNED: 'bg-slate-200 text-slate-700 border-slate-300',
REORDERED: 'bg-sky-100 text-sky-700 border-sky-200'
};
const formatTimestamp = (ts: string) => {
try {
const d = new Date(ts);
return d.toLocaleString('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
} catch {
return ts;
}
};
const JsonBlock = ({ value }: { value: any }) => {
if (value === null || value === undefined) {
return <p className="text-xs italic text-slate-400"></p>;
}
return (
<pre className="text-xs bg-slate-900 text-slate-100 rounded-lg p-3 overflow-auto max-h-72 whitespace-pre-wrap break-words">
{JSON.stringify(value, null, 2)}
</pre>
);
};
export const SystemLogsPage: React.FC = () => {
const [logs, setLogs] = useState<SystemAuditLog[]>([]);
const [summary, setSummary] = useState<Summary | null>(null);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [limit] = useState(25);
const [totalPages, setTotalPages] = useState(1);
const [totalEntries, setTotalEntries] = useState(0);
const [moduleFilter, setModuleFilter] = useState<string>('__all__');
const [actionFilter, setActionFilter] = useState<string>('__all__');
const [search, setSearch] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [appliedSearch, setAppliedSearch] = useState('');
const [selected, setSelected] = useState<SystemAuditLog | null>(null);
const queryParams = useMemo(
() => ({
module: moduleFilter !== '__all__' ? moduleFilter : undefined,
action: actionFilter !== '__all__' ? actionFilter : undefined,
search: appliedSearch || undefined,
dateFrom: dateFrom || undefined,
dateTo: dateTo || undefined,
page,
limit
}),
[moduleFilter, actionFilter, appliedSearch, dateFrom, dateTo, page, limit]
);
const fetchLogs = useCallback(async () => {
setLoading(true);
try {
const res = (await API.getSystemAuditLogs(queryParams)) as any;
const body = res?.data;
if (res?.ok && body?.success) {
setLogs(body.data || []);
setTotalPages(body.pagination?.totalPages || 1);
setTotalEntries(body.pagination?.total || 0);
} else {
toast.error(body?.message || 'Unable to load system logs');
setLogs([]);
}
} catch (err) {
console.error('[SystemLogsPage] fetchLogs error:', err);
toast.error('Failed to load system logs');
setLogs([]);
} finally {
setLoading(false);
}
}, [queryParams]);
const fetchSummary = useCallback(async () => {
try {
const res = (await API.getSystemAuditSummary()) as any;
const body = res?.data;
if (res?.ok && body?.success) {
setSummary(body.data || null);
}
} catch (err) {
console.error('[SystemLogsPage] fetchSummary error:', err);
}
}, []);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
fetchSummary();
}, [fetchSummary]);
const applySearch = () => {
setPage(1);
setAppliedSearch(search.trim());
};
const clearFilters = () => {
setModuleFilter('__all__');
setActionFilter('__all__');
setSearch('');
setAppliedSearch('');
setDateFrom('');
setDateTo('');
setPage(1);
};
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Activity className="w-6 h-6 text-re-red" />
System Activity Logs
</h1>
<p className="text-slate-500">
Configuration-level changes across questionnaires, interview configs, master hierarchy, system / SLA configs, and role assignments.
</p>
</div>
<Button
variant="outline"
onClick={() => {
fetchLogs();
fetchSummary();
}}
disabled={loading}
>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Summary */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-bold text-slate-500 uppercase tracking-wider">
Total Events
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-slate-900">{summary?.totalEntries ?? '—'}</p>
<p className="text-[11px] text-slate-400 mt-1">Lifetime</p>
</CardContent>
</Card>
{(summary?.byModule || []).slice(0, 4).map((row) => (
<Card key={row.module} className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-bold text-slate-500 uppercase tracking-wider">
{row.moduleLabel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold text-slate-900">{row.total}</p>
<button
onClick={() => {
setModuleFilter(row.module);
setPage(1);
}}
className="text-[11px] text-re-red hover:underline mt-1"
>
View module
</button>
</CardContent>
</Card>
))}
</div>
{/* Filters */}
<Card className="border-slate-200">
<CardContent className="pt-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 items-end">
<div className="lg:col-span-2">
<label className="text-xs font-semibold text-slate-600 mb-1 block flex items-center gap-1">
<Search className="w-3 h-3" /> Search
</label>
<div className="flex gap-2">
<Input
placeholder="Entity name, description, or actor…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') applySearch();
}}
/>
<Button variant="outline" onClick={applySearch}>
Go
</Button>
</div>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">Module</label>
<Select
value={moduleFilter}
onValueChange={(v) => {
setModuleFilter(v);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="All Modules" />
</SelectTrigger>
<SelectContent>
{MODULE_OPTIONS.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">Action</label>
<Select
value={actionFilter}
onValueChange={(v) => {
setActionFilter(v);
setPage(1);
}}
>
<SelectTrigger>
<SelectValue placeholder="All Actions" />
</SelectTrigger>
<SelectContent>
{ACTION_OPTIONS.map((a) => (
<SelectItem key={a.value} value={a.value}>
{a.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">From</label>
<Input
type="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setPage(1);
}}
/>
</div>
<div>
<label className="text-xs font-semibold text-slate-600 mb-1 block">To</label>
<Input
type="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPage(1);
}}
/>
</div>
</div>
<div className="flex items-center justify-between mt-4">
<div className="text-xs text-slate-500 flex items-center gap-2">
<Filter className="w-3 h-3" />
Showing {logs.length} of {totalEntries} matching event(s)
</div>
<Button variant="ghost" size="sm" onClick={clearFilters}>
Reset filters
</Button>
</div>
</CardContent>
</Card>
{/* Logs table */}
<Card className="border-slate-200 overflow-hidden">
<div className="overflow-x-auto custom-scrollbar-x-slim">
<table className="w-full text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr className="text-left">
<th className="px-4 py-3 font-semibold">When</th>
<th className="px-4 py-3 font-semibold">Module</th>
<th className="px-4 py-3 font-semibold">Action</th>
<th className="px-4 py-3 font-semibold">Entity</th>
<th className="px-4 py-3 font-semibold">Actor</th>
<th className="px-4 py-3 font-semibold">Description</th>
<th className="px-4 py-3 font-semibold w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-400">
<RefreshCw className="w-5 h-5 inline-block mr-2 animate-spin" />
Loading system logs
</td>
</tr>
)}
{!loading && logs.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-400">
No events match the current filters.
</td>
</tr>
)}
{!loading &&
logs.map((log) => (
<tr
key={log.id}
className="hover:bg-slate-50/70 cursor-pointer"
onClick={() => setSelected(log)}
>
<td className="px-4 py-3 whitespace-nowrap text-slate-700">
{formatTimestamp(log.timestamp)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="outline" className="border-slate-300 text-slate-700">
{log.moduleLabel}
</Badge>
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge
variant="outline"
className={
ACTION_BADGE_CLASS[log.action] ||
'bg-slate-100 text-slate-700 border-slate-200'
}
>
{log.actionLabel}
</Badge>
</td>
<td className="px-4 py-3 text-slate-800">
<div className="font-medium truncate max-w-[260px]" title={log.entityLabel || ''}>
{log.entityLabel || `${log.entityType}`}
</div>
{log.entityId && (
<div className="text-[10px] font-mono text-slate-400 truncate max-w-[260px]">
{log.entityId}
</div>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-full bg-slate-100 text-slate-600 flex items-center justify-center text-xs font-semibold">
{(log.actor?.name || 'S').charAt(0).toUpperCase()}
</div>
<div>
<div className="text-slate-800 leading-tight">{log.actor?.name || 'System'}</div>
{log.actor?.role && (
<div className="text-[10px] text-slate-500 uppercase tracking-wider">
{log.actor.role}
</div>
)}
</div>
</div>
</td>
<td className="px-4 py-3 text-slate-600">
<div className="truncate max-w-[360px]" title={log.description}>
{log.description}
</div>
</td>
<td className="px-4 py-3">
<Button
size="icon"
variant="ghost"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
setSelected(log);
}}
title="View details"
>
<Eye className="w-4 h-4 text-slate-500" />
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-100 bg-slate-50/50">
<div className="text-xs text-slate-500">
Page {page} of {totalPages}
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
disabled={loading || page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
<ChevronLeft className="w-4 h-4 mr-1" />
Prev
</Button>
<Button
size="sm"
variant="outline"
disabled={loading || page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
</Card>
{/* Detail dialog */}
<Dialog open={!!selected} onOpenChange={(open) => !open && setSelected(null)}>
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
{selected && (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Activity className="w-5 h-5 text-re-red" />
{selected.moduleLabel} · {selected.actionLabel}
</DialogTitle>
<DialogDescription className="text-slate-600">
{selected.description}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-4 text-sm mt-4">
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Entity
</p>
<p className="text-slate-800">{selected.entityLabel || selected.entityType}</p>
{selected.entityId && (
<p className="text-[11px] font-mono text-slate-400 mt-0.5">
{selected.entityId}
</p>
)}
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Actor
</p>
<p className="text-slate-800 flex items-center gap-2">
<UserIcon className="w-4 h-4 text-slate-400" />
{selected.actor?.name || 'System'}
</p>
{selected.actor?.role && (
<p className="text-[11px] text-slate-500 uppercase tracking-wider mt-0.5">
{selected.actor.role}
</p>
)}
{selected.actor?.email && (
<p className="text-[11px] text-slate-500 mt-0.5">
{selected.actor.email}
</p>
)}
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
When
</p>
<p className="text-slate-800">{formatTimestamp(selected.timestamp)}</p>
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-1">
Source
</p>
<p className="text-slate-800">{selected.ipAddress || '—'}</p>
<p className="text-[11px] text-slate-500 truncate" title={selected.userAgent || ''}>
{selected.userAgent || ''}
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Previous values
</p>
<JsonBlock value={selected.oldData} />
</div>
<div>
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
New values
</p>
<JsonBlock value={selected.newData} />
</div>
</div>
{selected.metadata && (
<div className="mt-4">
<p className="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">
Metadata
</p>
<JsonBlock value={selected.metadata} />
</div>
)}
</>
)}
</DialogContent>
</Dialog>
</div>
);
};
export default SystemLogsPage;

View File

@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useLocation, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@ -11,6 +11,7 @@ import { setCredentials } from '@/store/slices/authSlice';
export function ProspectiveLoginPage() {
const navigate = useNavigate();
const routerLocation = useLocation();
const dispatch = useDispatch();
const [step, setStep] = useState<'PHONE' | 'OTP'>('PHONE');
const [phone, setPhone] = useState('');
@ -18,6 +19,20 @@ export function ProspectiveLoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
// Honour an optional `?next=...` deep-link sent by applicant emails. We allow only
// internal paths under `/prospective-dashboard/...` to prevent open-redirect abuse.
const resolveRedirectTarget = (): string => {
const raw = new URLSearchParams(routerLocation.search).get('next');
if (!raw) return '/prospective-dashboard';
try {
const decoded = decodeURIComponent(raw);
if (decoded.startsWith('/prospective-dashboard')) return decoded;
} catch {
// fall through to default
}
return '/prospective-dashboard';
};
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
if (!phone || phone.length < 10) {
@ -76,7 +91,7 @@ export function ProspectiveLoginPage() {
localStorage.setItem('token', token);
toast.success('Logged in successfully!');
navigate('/prospective-dashboard');
navigate(resolveRedirectTarget());
} else {
const errorMessage = response.data?.message || 'Invalid OTP';
setError(errorMessage);
@ -94,13 +109,13 @@ export function ProspectiveLoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4 overflow-y-auto">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-amber-600/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-amber-600/10 rounded-full blur-3xl"></div>
<div className="absolute -top-40 -right-40 w-80 h-80 bg-re-red/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-re-red/10 rounded-full blur-3xl"></div>
</div>
<div className="relative w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-600 rounded-full mb-4">
<div className="inline-flex items-center justify-center w-20 h-20 bg-re-red rounded-full mb-4">
<svg viewBox="0 0 24 24" className="w-12 h-12 text-white" fill="currentColor">
<path d="M12 2L4 6v6c0 5.5 3.8 10.7 8 12 4.2-1.3 8-6.5 8-12V6l-8-4zm0 2.2l6 3v4.8c0 4.5-3.1 8.7-6 10-2.9-1.3-6-5.5-6-10V7.2l6-3z"></path>
<circle cx="12" cy="12" r="3"></circle>
@ -122,8 +137,8 @@ export function ProspectiveLoginPage() {
</Button>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 rounded-lg">
<Smartphone className="w-6 h-6 text-amber-600" />
<div className="p-2 bg-red-50 rounded-lg">
<Smartphone className="w-6 h-6 text-re-red" />
</div>
<div>
<h2 className="text-slate-900 text-lg font-semibold">Dealer Login</h2>
@ -158,7 +173,7 @@ export function ProspectiveLoginPage() {
<Button
type="submit"
className="w-full bg-amber-600 hover:bg-amber-700 h-9"
className="w-full bg-re-red hover:bg-re-red-hover h-9"
disabled={isLoading || phone.length < 10}
>
{isLoading ? 'Sending...' : 'Send OTP'}
@ -198,7 +213,7 @@ export function ProspectiveLoginPage() {
<Button
type="submit"
className="w-full bg-amber-600 hover:bg-amber-700 h-9"
className="w-full bg-re-red hover:bg-re-red-hover h-9"
disabled={isLoading || otp.length < 6}
>
{isLoading ? 'Verifying...' : 'Verify OTP'}
@ -207,7 +222,7 @@ export function ProspectiveLoginPage() {
<div className="text-center text-sm">
<button
type="button"
className="text-amber-600 hover:text-amber-700 font-medium"
className="text-re-red hover:text-re-red-hover font-medium"
onClick={() => setStep('PHONE')}
>
Change Phone Number
@ -215,7 +230,7 @@ export function ProspectiveLoginPage() {
<span className="mx-2 text-slate-400">|</span>
<button
type="button"
className="text-amber-600 hover:text-amber-700 font-medium"
className="text-re-red hover:text-re-red-hover font-medium"
onClick={handleSendOtp}
disabled={isLoading}
>

View File

@ -1,6 +1,7 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ConstitutionalChangePage } from "../pages/ConstitutionalChangePage"
import { toast } from "sonner"
jest.mock("sonner", () => ({
toast: {
@ -138,4 +139,54 @@ describe("ConstitutionalChangePage", () => {
expect(screen.getByText(/^Dealer \*$/i)).toBeInTheDocument()
})
it("shows backend duplicate-open message on create conflict", async () => {
const user = userEvent.setup()
const { API } = await import("@/api/API")
;(API.getDealers as jest.Mock).mockResolvedValueOnce({
data: {
success: true,
data: [
{
user: { id: "dealer-user-1" },
constitutionType: "Proprietorship",
businessName: "Dealer A",
legalName: "Dealer A Pvt",
dealerCode: { dealerCode: "DLR-1" },
},
],
},
})
;(API.createConstitutionalChange as jest.Mock).mockRejectedValueOnce({
response: {
data: {
message:
"Open constitutional request CCR-1 already exists at ASM Review. Complete it before creating a new one.",
},
},
})
setup()
await user.click(screen.getByRole("button", { name: /new request/i }))
await screen.findByRole("heading", {
name: /create constitutional change request/i,
})
await user.click(screen.getByRole("combobox", { name: /dealer/i }))
await user.click(await screen.findByText(/DLR-1 — Dealer A/i))
await user.click(screen.getByRole("combobox", { name: /proposed constitution/i }))
await user.click(await screen.findByText(/^Partnership$/i))
const reasonField = screen.getByLabelText(/reason for constitutional change/i)
await user.type(reasonField, "Need to onboard new partner")
await user.click(screen.getByRole("button", { name: /submit request/i }))
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringContaining("Open constitutional request CCR-1 already exists")
)
})
})
})

View File

@ -12,9 +12,19 @@ import { useState, useEffect, useMemo } from 'react';
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 { OFFBOARDING_ACTIONS } from '@/lib/offboarding-actions';
import { useNavigate } from 'react-router-dom';
import { formatDateTime } from '@/components/ui/utils';
import {
getCurrentStageBadgeClass,
getOffboardingRequestStatusBadgeClass,
getStatusLabelBadgeClass,
getStatusProgressBarClass,
isOffboardingTerminalNegative,
WORKFLOW_IN_PROGRESS_ACCENT,
} from '@/lib/offboardingDisplay';
interface ConstitutionalChangeDetailsProps {
requestId: string;
@ -44,7 +54,7 @@ const formatStageRole = (role: string) =>
// Document requirements mapping (same as in ConstitutionalChangePage) — SRS §12.2.4 by target constitution
const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16],
'LLP': [1, 2, 3, 7, 8, 10, 11, 16],
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Pvt Ltd': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Proprietorship': [1, 2, 3, 10, 16]
@ -86,19 +96,12 @@ const getTypeColor = (type: string) => {
}
};
const getStatusColor = (status: string) => {
const s = String(status || '');
if (s === 'Completed' || s === 'Verified' || s === 'APPROVED' || s === 'COMPLETED' || s === 'CREATED' || /^DOCUMENT/i.test(s)) {
return 'bg-green-100 text-green-700 border-green-300';
}
if (s.includes('Revoked') || s === 'REVOKED') return 'bg-orange-100 text-orange-800 border-orange-300';
if (s.includes('Rejected') || s === 'REJECTED') return 'bg-red-100 text-red-700 border-red-300';
if (s === 'SENT BACK' || s.includes('Review') || s.includes('Pending') || s === 'In Progress' || s === 'Submitted') {
return 'bg-yellow-100 text-yellow-700 border-yellow-300';
}
if (s === 'UPDATED') return 'bg-slate-100 text-slate-700 border-slate-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const getStatusColor = (status: string) => getStatusLabelBadgeClass(status);
const getDocChecklistUploadButtonClass = (isRejected: boolean) =>
isRejected
? 'h-8 px-2 text-red-700 hover:bg-red-50 hover:text-red-800'
: 'h-8 px-2 text-slate-700 hover:bg-slate-50';
/** Audit rows were stored as UPDATED for approvals; avoid treating "UPDATED" as pending via substring "update". */
const getConstitutionalHistoryPresentation = (entry: any) => {
@ -145,12 +148,25 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const [isUploadDialogOpen, setIsUploadDialogOpen] = useState(false);
const [selectedDocType, setSelectedDocType] = useState<number | null>(null);
const [uploadFile, setUploadFile] = useState<File | null>(null);
// 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 [activeMainTab, setActiveMainTab] = useState('workflow');
const [activeDocumentTab, setActiveDocumentTab] = useState('required');
const [request, setRequest] = useState<any>(null);
// The URL slug (`requestId` prop) is the human-readable code such as
// `CC-2026-MAY-00002`, but `sla_tracking.entityId` is a UUID column.
// Wait until the request has loaded and feed its UUID `id` to the SLA hook.
const slaEntityId: string = request?.id || '';
const { get: getSla } = useSlaBatchStatus(
slaEntityId ? [{ entityType: 'constitutional', entityId: slaEntityId }] : [],
Boolean(slaEntityId)
);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isActionLoading, setIsActionLoading] = useState(false);
/** Set when POST /action returns 4xx (apisauce does not throw — must check response.ok). */
const [actionDialogError, setActionDialogError] = useState<string | null>(null);
const [isUploadingDoc, setIsUploadingDoc] = useState(false);
const [rejectDocDialogOpen, setRejectDocDialogOpen] = useState(false);
const [rejectDocIndex, setRejectDocIndex] = useState<number | null>(null);
@ -222,7 +238,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
if (isLoading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
<p className="text-slate-600">Loading request details...</p>
</div>
);
@ -301,9 +317,9 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
(String(request.status || '') === 'Completed' && !['Rejected', 'Revoked'].includes(String(request.currentStage || '')));
/** SRS §12.2 — closed failure states: do not show misleading step progress. */
const workflowTerminalNegative =
['Rejected', 'Revoked'].includes(String(request.status || '')) ||
['Rejected', 'Revoked'].includes(String(request.currentStage || ''));
const workflowTerminalNegative = isOffboardingTerminalNegative(request.status, request.currentStage);
const statusProgressBarClass = getStatusProgressBarClass(request.status, request.currentStage);
const requestStatusBadgeClass = getOffboardingRequestStatusBadgeClass(request.status, request.currentStage);
const getLatestStageTimelineEntry = (stageName: string) => {
const aliases: Record<string, string[]> = {
@ -415,6 +431,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const handleAction = (type: 'approve' | 'reject' | 'sendBack' | 'revoke') => {
setActionType(type);
setActionDialogError(null);
setIsActionDialogOpen(true);
};
@ -436,6 +453,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
try {
setIsActionLoading(true);
setActionDialogError(null);
const actionPayload =
actionType === 'approve'
? OFFBOARDING_ACTIONS.APPROVE
@ -448,7 +466,9 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
comments
}) as any;
if (response.data.success) {
const payload = response?.data as { success?: boolean; message?: string } | undefined;
/** apisauce returns { ok: false } on 4xx without throwing — must branch on this. */
if (response?.ok && payload?.success) {
const actionText =
actionType === 'approve' ? 'approved' :
actionType === 'reject' ? 'rejected' :
@ -457,12 +477,26 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
toast.success(`Request ${actionText} successfully`);
setIsActionDialogOpen(false);
setComments('');
setActionDialogError(null);
await fetchRequestDetails();
return;
}
const message =
payload?.message ||
(response as any)?.data?.error ||
'Failed to submit action';
setActionDialogError(message);
const docGate = /mandatory documents/i.test(message);
toast.error(message, { duration: docGate ? 14000 : 8000 });
} catch (error) {
console.error('Submit action error:', error);
const message = (error as any)?.response?.data?.message || 'Failed to submit action';
toast.error(message);
const message =
(error as any)?.response?.data?.message ||
(error as any)?.message ||
'Failed to submit action';
setActionDialogError(message);
toast.error(message, { duration: /mandatory documents/i.test(message) ? 14000 : 8000 });
} finally {
setIsActionLoading(false);
}
@ -483,6 +517,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
? existingDocs.findIndex((d: any) => Number(d?.docNumber) === selectedDocType)
: -1;
const payloadDoc = {
id: globalThis.crypto?.randomUUID?.() ?? `doc-${Date.now()}-${selectedDocType}`,
docNumber: selectedDocType,
name: documentNames[selectedDocType] || 'Other',
fileName: uploadFile.name,
@ -512,22 +547,19 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
}
};
const resolveDocumentId = (doc: any, index: number) =>
doc?.id != null ? String(doc.id) : String(index);
const handleVerifyDocument = async (targetDoc: any, targetIndex: number) => {
try {
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
const updatedDocs = existingDocs.map((doc: any, index: number) => {
const isTargetByIndex = index === targetIndex;
const isTargetByDocNumber = targetDoc.docNumber && doc.docNumber === targetDoc.docNumber;
if (!(isTargetByIndex || isTargetByDocNumber)) return doc;
return { ...doc, status: 'Verified', verifiedOn: new Date().toISOString(), verifiedBy: currentUser?.name || 'System' };
});
const response = await API.uploadConstitutionalDocuments(requestId, updatedDocs) as any;
const documentId = resolveDocumentId(targetDoc, targetIndex);
const response = await API.verifyConstitutionalDocument(requestId, documentId) as any;
if (response.data?.success) {
toast.success('Document verified successfully');
fetchRequestDetails();
await fetchRequestDetails({ silent: true });
if (request?.id) await fetchAuditLogs(request.id);
} else {
toast.error('Failed to verify document');
toast.error(response.data?.message || 'Failed to verify document');
}
} catch (error) {
console.error('Verify document error:', error);
@ -537,31 +569,29 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const submitRejectDocument = async () => {
if (rejectDocIndex == null || !String(rejectDocReason).trim()) {
toast.error('Please enter a rejection reason (SRS document verification).');
toast.error('Please enter a rejection reason.');
return;
}
const targetDoc = (request?.documents || [])[rejectDocIndex];
if (!targetDoc) {
toast.error('Document not found');
return;
}
try {
setIsRejectingDoc(true);
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
const updatedDocs = existingDocs.map((doc: any, index: number) => {
if (index !== rejectDocIndex) return doc;
return {
...doc,
status: 'Rejected',
rejectedOn: new Date().toISOString(),
rejectedBy: (currentUser as any)?.fullName || 'System',
rejectionReason: rejectDocReason.trim()
};
});
const response = await API.uploadConstitutionalDocuments(requestId, updatedDocs) as any;
const documentId = resolveDocumentId(targetDoc, rejectDocIndex);
const response = await API.rejectConstitutionalDocument(requestId, documentId, {
remarks: rejectDocReason.trim()
}) as any;
if (response.data?.success) {
toast.success('Document marked as rejected');
setRejectDocDialogOpen(false);
setRejectDocIndex(null);
setRejectDocReason('');
await fetchRequestDetails({ silent: true });
if (request?.id) await fetchAuditLogs(request.id);
} else {
toast.error('Failed to reject document');
toast.error(response.data?.message || 'Failed to reject document');
}
} catch (error) {
console.error('Reject document error:', error);
@ -591,9 +621,10 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</p>
</div>
</div>
<Badge className={getStatusColor(request.status)}>
<Badge className={requestStatusBadgeClass}>
{request.status}
</Badge>
<SlaBadge status={getSla('constitutional', slaEntityId)} />
</div>
{/* Request Overview */}
@ -625,7 +656,12 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<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 || 'Dealer'}</p>
<p className="text-slate-900 text-sm mt-2">Current Stage: {request.currentStage}</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)}>
{request.currentStage}
</Badge>
</div>
</div>
</div>
@ -681,7 +717,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<Card>
<Tabs value={activeMainTab} onValueChange={setActiveMainTab} className="w-full">
<CardHeader className="pb-4">
<div className="overflow-x-auto -mx-6 px-6">
<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>
@ -692,16 +728,18 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<CardContent>
{/* Workflow Progress Tab */}
<TabsContent value="workflow" className="mt-0">
<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>
<span className="text-slate-600">{request.progressPercentage}%</span>
<Badge className={`${statusProgressBarClass} text-white border-transparent hover:opacity-90`}>
{request.progressPercentage}% Complete
</Badge>
</div>
<div className="h-3 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-amber-600 transition-all duration-500"
className={`h-full transition-all duration-500 ${statusProgressBarClass}`}
style={{ width: `${request.progressPercentage}%` }}
/>
</div>
@ -760,13 +798,13 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{/* Status Icon */}
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${isCompleted ? 'bg-green-100' :
isCurrent ? 'bg-amber-100' :
isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.icon :
'bg-slate-100'
}`}>
{isCompleted ? (
<CheckCircle2 className="w-5 h-5 text-green-600" />
) : isCurrent ? (
<Clock className="w-5 h-5 text-amber-600" />
<Clock className="w-5 h-5 text-re-red" />
) : (
<AlertCircle className="w-5 h-5 text-slate-400" />
)}
@ -778,20 +816,19 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div>
{/* Stage Info */}
<div className={`flex-1 pb-8 ${isCurrent ? 'bg-amber-50 -ml-4 pl-4 pr-4 py-3 rounded-lg border border-amber-200' : ''}`}>
<div className={`flex-1 pb-8 ${isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.panel : ''}`}>
<div className="flex items-center justify-between">
<div>
<h4 className={`${isCurrent ? 'text-amber-900' : 'text-slate-900'}`}>
<h4 className={`${isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.title : 'text-slate-900'}`}>
{formatStageLabel(stage.name)}
</h4>
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}>
<p className={`text-sm ${isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.subtitle : 'text-slate-600'}`}>
{`Responsible: ${formatStageRole(stage.role)}`}
</p>
</div>
<Badge className={
isCompleted ? 'bg-green-100 text-green-700 border-green-300' :
isCurrent ? 'bg-amber-100 text-amber-700 border-amber-300' :
isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.stageBadge :
'bg-slate-100 text-slate-500 border-slate-300'
}>
{isCompleted ? 'Completed' : isCurrent ? 'In Progress' : 'Pending'}
@ -823,7 +860,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
DD-ZM: {ddZmApproval?.approvedByUserId ? 'Approved' : 'Pending'}
</Badge>
{currentRoleApproval?.approvedByUserId && (
<Badge className="bg-blue-100 text-blue-700 border-blue-300">
<Badge className="bg-red-50 text-re-red-hover border-red-200">
Approved by you
</Badge>
)}
@ -863,9 +900,26 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<h4 className="text-slate-900">Document Checklist</h4>
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<Dialog
open={isUploadDialogOpen}
onOpenChange={(open) => {
setIsUploadDialogOpen(open);
if (!open) {
setDocTypeLocked(false);
setUploadFile(null);
}
}}
>
<DialogTrigger asChild>
<Button size="sm" className="bg-amber-600 hover:bg-amber-700">
<Button
size="sm"
className="bg-re-red hover:bg-re-red-hover"
onClick={() => {
setDocTypeLocked(false);
setSelectedDocType(null);
setUploadFile(null);
}}
>
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
@ -874,14 +928,26 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Select the document type and upload the file
{docTypeLocked
? 'Pick a file for the selected document.'
: 'Select the document type and upload the file.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{docTypeLocked && selectedDocType != null ? (
<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">
{documentNames[selectedDocType] || `Document ${selectedDocType}`}
</Badge>
</div>
</div>
) : (
<div>
<Label>Document Type</Label>
<select
className="mt-1 flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:ring-offset-2"
className="mt-1 flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-re-red focus-visible:ring-offset-2"
value={selectedDocType != null ? String(selectedDocType) : ''}
onChange={(e) => {
const v = e.target.value;
@ -897,6 +963,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
))}
</select>
</div>
)}
<div>
<Label>Upload File</Label>
<Input type="file" className="mt-1" onChange={(e) => setUploadFile(e.target.files?.[0] || null)} />
@ -907,7 +974,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
Cancel
</Button>
<Button
className="bg-amber-600 hover:bg-amber-700"
className="bg-re-red hover:bg-re-red-hover"
onClick={handleUploadDocument}
disabled={isUploadingDoc}
>
@ -944,11 +1011,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{uploaded && (
<p className={isRejected ? 'text-red-700 text-sm' : ok ? 'text-green-700 text-sm' : 'text-slate-600 text-sm'}>
{uploaded.fileName || uploaded.name}
{isRejected && uploaded.rejectionReason ? `${uploaded.rejectionReason}` : ''}
{isRejected && (uploaded.rejectionReason || uploaded.rejectionRemarks)
? `${uploaded.rejectionReason || uploaded.rejectionRemarks}`
: ''}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{uploaded ? (
<Badge className={getStatusColor(uploaded.status)}>
{uploaded.status}
@ -958,6 +1028,23 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
Not Uploaded
</Badge>
)}
{!ok && (
<Button
size="sm"
variant="ghost"
className={getDocChecklistUploadButtonClass(!!isRejected)}
onClick={() => {
setSelectedDocType(docNum);
setUploadFile(null);
setDocTypeLocked(true);
setIsUploadDialogOpen(true);
}}
>
<Upload className="w-3.5 h-3.5 mr-1" />
{isRejected ? 'Re-upload' : 'Upload'}
</Button>
)}
</div>
</div>
);
})}
@ -984,7 +1071,10 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</TableHeader>
<TableBody>
{(request.documents || []).map((doc: any, index: number) => (
<TableRow key={index}>
<TableRow
key={index}
className={String(doc.status) === 'Rejected' ? 'bg-red-50/80' : undefined}
>
<TableCell className="text-slate-900">
{doc.docNumber ? documentNames[doc.docNumber] : doc.name}
</TableCell>
@ -1088,7 +1178,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div key={entry.id || index} className="flex items-start gap-4 pb-4 border-b border-slate-200 last:border-0">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${pres.variant === 'success' ? 'bg-green-100' :
pres.variant === 'danger' ? 'bg-red-100' :
pres.variant === 'pending' ? 'bg-amber-100' :
pres.variant === 'pending' ? 'bg-red-50' :
'bg-slate-100'
}`}>
{pres.variant === 'success' ? (
@ -1096,7 +1186,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
) : pres.variant === 'danger' ? (
<AlertCircle className="w-5 h-5 text-red-600" />
) : pres.variant === 'pending' ? (
<Clock className="w-5 h-5 text-amber-600" />
<Clock className="w-5 h-5 text-re-red" />
) : (
<Clock className="w-5 h-5 text-slate-500" />
)}
@ -1145,7 +1235,21 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<CardContent className="space-y-4">
<div>
<p className="text-slate-600 text-sm">Current Stage</p>
<p className="text-slate-900">{request.currentStage}</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: `${request.progressPercentage ?? 0}%` }}
/>
</div>
<span className="text-slate-900">{request.progressPercentage ?? 0}%</span>
</div>
</div>
</CardContent>
</Card>
@ -1201,7 +1305,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{permissions.canSendBack && (
<Button
variant="outline"
className="w-full border-amber-300 text-amber-900 hover:bg-amber-50"
className="w-full border-red-300 text-red-900 hover:bg-red-50"
onClick={() => handleAction('sendBack')}
disabled={isActionLoading}
>
@ -1217,7 +1321,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{permissions.canRevoke && (
<Button
variant="outline"
className="w-full border-orange-300 text-orange-900 hover:bg-orange-50"
className="w-full border-red-300 text-re-red-hover hover:bg-red-50"
onClick={() => handleAction('revoke')}
disabled={isActionLoading}
>
@ -1241,7 +1345,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<div className="border-t border-slate-200 pt-3 mt-3">
<Button
variant="outline"
className="w-full border-blue-700 text-blue-800 hover:bg-blue-50"
className="w-full border-re-red text-re-red hover:bg-red-50"
onClick={() => navigate(`/worknotes/constitutional/${request?.id || requestId}`, {
state: {
requestType: 'constitutional',
@ -1261,7 +1365,13 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</div>
{/* Action Dialog */}
<Dialog open={isActionDialogOpen} onOpenChange={setIsActionDialogOpen}>
<Dialog
open={isActionDialogOpen}
onOpenChange={(open) => {
setIsActionDialogOpen(open);
if (!open) setActionDialogError(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
@ -1278,6 +1388,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
</DialogHeader>
<form onSubmit={handleSubmitAction} className="space-y-4">
{actionDialogError && (
<div
role="alert"
className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-900 flex gap-2"
>
<AlertCircle className="w-5 h-5 shrink-0 text-red-600" aria-hidden />
<div className="min-w-0">
<p className="font-medium">This action was not completed</p>
<p className="mt-1 whitespace-pre-wrap break-words">{actionDialogError}</p>
{/mandatory documents/i.test(actionDialogError) && (
<p className="mt-2 text-red-800">
Use the <strong>Documents</strong> tab to upload every required file for this constitution
type, then approve again.
</p>
)}
</div>
</div>
)}
<div>
<Label htmlFor="comments">
{actionType === 'sendBack' || actionType === 'revoke' ? 'Remarks (required) *' : 'Comments *'}
@ -1304,9 +1432,9 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
type="submit"
className={
actionType === 'approve' ? 'bg-green-600 hover:bg-green-700' :
actionType === 'reject' ? 'bg-red-600 hover:bg-red-700' :
actionType === 'sendBack' ? 'bg-amber-600 hover:bg-amber-700' :
'bg-orange-600 hover:bg-orange-700'
actionType === 'reject' ? 'bg-re-red hover:bg-re-red-hover' :
actionType === 'sendBack' ? 'bg-re-red hover:bg-re-red-hover' :
'bg-re-red hover:bg-re-red-hover'
}
disabled={isActionLoading}
>
@ -1332,7 +1460,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
<DialogHeader>
<DialogTitle>Reject document</DialogTitle>
<DialogDescription>
Per SRS relocation-style verification states, mark this upload as Rejected and provide a reason for the dealer.
Mark this upload as rejected and provide a reason. The action is recorded in the audit trail.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">

View File

@ -12,7 +12,14 @@ import { useState, useEffect } from 'react';
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 { formatDateTime } from '@/components/ui/utils';
import {
getCurrentStageBadgeClass,
getRequestStatusBadgeClass,
getStatusProgressBarClass,
} from '@/lib/statusProgressTheme';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
import {
Pagination,
@ -32,7 +39,7 @@ interface ConstitutionalChangePageProps {
// Document requirements mapping (keys = DB `changeType` ENUM values)
const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 10, 11, 16],
'Private Limited': [1, 2, 3, 5, 6, 7, 8, 10, 16],
'Proprietorship': [1, 2, 3, 10, 16]
};
@ -60,23 +67,16 @@ const documentNames: Record<number, string> = {
[OTHER_DOCUMENT_DOC_NUMBER]: 'Other'
};
const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Collection')) return 'bg-blue-100 text-blue-700 border-blue-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const getStatusColor = (status: string, currentStage?: string) =>
getRequestStatusBadgeClass(status, currentStage);
const getTypeColor = (type: string) => {
switch(type) {
case 'Proprietorship': return 'bg-purple-100 text-purple-700 border-purple-300';
case 'Partnership': return 'bg-blue-100 text-blue-700 border-blue-300';
case 'LLP':
case 'LLP Conversion':
return 'bg-indigo-100 text-indigo-700 border-indigo-300';
case 'Private Limited':
case 'Pvt Ltd':
return 'bg-cyan-100 text-cyan-700 border-cyan-300';
default: return 'bg-slate-100 text-slate-700 border-slate-300';
}
@ -100,6 +100,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [activeTab, setActiveTab] = useState('all');
const itemsPerPage = 10;
const slaItems = requests.map((r: any) => ({
entityType: 'constitutional',
entityId: r.id || r.requestId
}));
const { get: getSla } = useSlaBatchStatus(slaItems, requests.length > 0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [dialogDataLoading, setDialogDataLoading] = useState(false);
@ -289,7 +295,7 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
title: 'Submitted / Review',
value: paginationMeta?.stats?.pending || 0,
icon: Calendar,
color: 'bg-yellow-500',
color: 'bg-re-red',
},
{
title: 'Completed',
@ -310,7 +316,7 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
{/* Loading Overlay */}
{isLoading && (
<div className="fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-50 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
</div>
)}
@ -325,7 +331,7 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-amber-600 hover:bg-amber-700">
<Button className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
New Request
</Button>
@ -486,7 +492,7 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</Button>
<Button
type="submit"
className="bg-amber-600 hover:bg-amber-700"
className="bg-re-red hover:bg-re-red-hover"
disabled={
!dealerData ||
!targetType ||
@ -598,15 +604,18 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Badge className={getCurrentStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-amber-600 transition-all duration-300"
className={`h-full transition-all duration-300 ${getStatusProgressBarClass(request.status, request.currentStage)}`}
style={{ width: `${request.progressPercentage || 0}%` }}
/>
</div>
@ -676,12 +685,15 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Badge className={getCurrentStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
</div>
</TableCell>
<TableCell>
<Badge className={getStatusColor(request.status)}>
<Badge className={getStatusColor(request.status, request.currentStage)}>
{request.status}
</Badge>
</TableCell>
@ -754,7 +766,7 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-amber-600 transition-all duration-300"
className={`h-full transition-all duration-300 ${getStatusProgressBarClass(request.status, request.currentStage)}`}
style={{ width: `${request.progressPercentage || 0}%` }}
/>
</div>
@ -762,9 +774,12 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Badge className={getCurrentStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
<SlaBadge status={getSla('constitutional', request.id || request.requestId)} compact />
</div>
</TableCell>
<TableCell>
<Button
@ -831,7 +846,7 @@ export function ConstitutionalChangePage({ onViewDetails }: ConstitutionalChange
</div>
</TableCell>
<TableCell>
<Badge className={getStatusColor(request.status)}>
<Badge className={getStatusColor(request.status, request.currentStage)}>
{request.status}
</Badge>
</TableCell>

View File

@ -15,18 +15,15 @@ import { dealerService } from '@/services/dealer.service';
import { formatDateTime } from '@/components/ui/utils';
import { API } from '@/api/API';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
import { getRequestStatusBadgeClass, getStatusProgressBarClass } from '@/lib/statusProgressTheme';
interface DealerConstitutionalChangePageProps {
currentUser?: UserType | null;
onViewDetails?: (id: string) => void;
}
const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const getStatusColor = (status: string, currentStage?: string) =>
getRequestStatusBadgeClass(status, currentStage);
export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitutionalChangePageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
@ -122,13 +119,13 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
title: 'Total Requests',
value: requests.length,
icon: RefreshCcw,
color: 'bg-blue-500',
color: 'bg-re-red',
},
{
title: 'Pending',
value: requests.filter(r => r.status !== 'Completed' && r.status !== 'Rejected').length,
icon: Calendar,
color: 'bg-yellow-500',
color: 'bg-re-red',
},
{
title: 'Completed',
@ -143,7 +140,7 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
{/* Loading Overlay */}
{loading && (
<div className="min-h-[400px] flex items-center justify-center">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
</div>
)}
@ -160,7 +157,7 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-blue-600 hover:bg-blue-700">
<Button className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
New Constitutional Change
</Button>
@ -265,17 +262,18 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
)}
{/* Document Requirements */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 className="text-blue-900 mb-2">Documents Required (to be uploaded later)</h4>
<ul className="text-blue-800 text-sm space-y-1">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="text-re-red-hover mb-2">Documents Required (to be uploaded later)</h4>
<ul className="text-re-red-hover text-sm space-y-1">
<li> GST Registration Certificate</li>
<li> Firm PAN Copy</li>
<li> Partnership Deed (if applicable)</li>
<li> LLP Agreement (if applicable)</li>
<li> Certificate of Incorporation (if applicable)</li>
<li> MOA & AOA (if applicable)</li>
<li> Board Resolution</li>
<li> Aadhaar & PAN of all partners/directors</li>
<li> Self-attested KYC documents</li>
<li> Business Purchase Agreement (BPA)</li>
<li> Partnership Agreement / Firm Registration (if target is Partnership)</li>
<li> LLP Agreement / COI (if target is LLP)</li>
<li> MOA, AOA, COI (if target is Private Limited)</li>
<li> Cancelled Cheque</li>
<li> Declaration / Authorization Letter</li>
</ul>
</div>
@ -289,7 +287,7 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
</Button>
<Button
type="submit"
className="bg-blue-600 hover:bg-blue-700"
className="bg-re-red hover:bg-re-red-hover"
disabled={submitting}
>
{submitting ? (
@ -365,7 +363,7 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
<Badge variant="outline">{request.currentConstitution}</Badge>
</TableCell>
<TableCell>
<Badge className="bg-blue-100 text-blue-700 border-blue-300">
<Badge className="bg-red-50 text-re-red-hover border-red-200">
{request.changeType}
</Badge>
</TableCell>
@ -381,7 +379,7 @@ export function DealerConstitutionalChangePage({ onViewDetails }: DealerConstitu
<div className="flex items-center gap-2">
<div className="flex-1 bg-slate-200 rounded-full h-2 min-w-[60px]">
<div
className="bg-blue-600 h-2 rounded-full"
className={`h-2 rounded-full transition-all duration-300 ${getStatusProgressBarClass(request.status, request.currentStage)}`}
style={{ width: `${request.progressPercentage || 0}%` }}
/>
</div>

View File

@ -42,7 +42,7 @@ export function Dashboard({ onNavigate }: DashboardProps) {
title: 'Level 1 Pending',
value: dashboardStats.level1Pending,
icon: Clock,
color: 'bg-amber-500',
color: 'bg-red-500',
trend: { value: 3, isPositive: false },
filter: 'Level 1 Pending'
},
@ -210,7 +210,7 @@ export function Dashboard({ onNavigate }: DashboardProps) {
<div className="w-24 text-slate-700">{item.location}</div>
<div className="flex-1 bg-slate-200 rounded-full h-8 relative overflow-hidden">
<div
className="bg-amber-600 h-full rounded-full transition-all flex items-center justify-end px-3"
className="bg-re-red h-full rounded-full transition-all flex items-center justify-end px-3"
style={{ width: `${(item.count / maxLocationCount) * 100}%` }}
>
<span className="text-white">{item.count}</span>
@ -237,7 +237,7 @@ export function Dashboard({ onNavigate }: DashboardProps) {
className="flex items-start gap-4 p-3 hover:bg-slate-50 rounded-lg cursor-pointer transition-colors"
onClick={() => onNavigate('applications')}
>
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center flex-shrink-0">
<div className="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center flex-shrink-0">
{activity.action === 'Approved' && <CheckCircle className="w-5 h-5 text-green-600" />}
{activity.action === 'Interview Scheduled' && <Clock className="w-5 h-5 text-blue-600" />}
{activity.action === 'Document Uploaded' && <FileText className="w-5 h-5 text-purple-600" />}

View File

@ -32,7 +32,7 @@ export function DealerDashboard({ currentUser, onNavigate }: DealerDashboardProp
if (loading) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<Loader2 className="w-10 h-10 text-amber-600 animate-spin mb-4" />
<Loader2 className="w-10 h-10 text-re-red animate-spin mb-4" />
<p className="text-slate-600">Loading your dashboard...</p>
</div>
);
@ -58,7 +58,7 @@ export function DealerDashboard({ currentUser, onNavigate }: DealerDashboardProp
title: 'Relocation Requests',
value: statsSummary.relocation,
icon: MapPin,
color: 'bg-amber-500',
color: 'bg-re-red',
change: 'Active Requests',
onClick: () => onNavigate('dealer-relocation')
},
@ -96,8 +96,8 @@ export function DealerDashboard({ currentUser, onNavigate }: DealerDashboardProp
title: 'Request Relocation',
description: 'Move dealership to new location',
icon: MapPin,
color: 'bg-amber-50 hover:bg-amber-100 border-amber-200',
textColor: 'text-amber-700',
color: 'bg-red-50 hover:bg-red-100 border-red-200',
textColor: 'text-re-red',
onClick: () => onNavigate('dealer-relocation')
},
];
@ -105,14 +105,14 @@ export function DealerDashboard({ currentUser, onNavigate }: DealerDashboardProp
return (
<div className="space-y-6">
{/* Welcome Section */}
<div className="bg-gradient-to-r from-amber-500 to-amber-600 rounded-lg p-6 text-white">
<div className="rounded-lg bg-gradient-to-r from-re-red to-re-red-hover p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h1 className="text-white mb-2">Welcome back, {profile.name || currentUser?.name}!</h1>
<p className="text-amber-100">
<p className="text-white/90">
Dealer Code: {profile.dealerCode} {profile.businessName}
</p>
<p className="text-amber-100 text-sm mt-1">
<p className="text-white/90 text-sm mt-1">
{primaryOutlet.name} {primaryOutlet.location}
</p>
</div>
@ -225,21 +225,21 @@ export function DealerDashboard({ currentUser, onNavigate }: DealerDashboardProp
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="w-5 h-5 text-amber-600" />
<Clock className="w-5 h-5 text-re-red" />
Important Reminders
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5" />
<AlertCircle className="w-4 h-4 text-re-red mt-0.5" />
<div>
<p className="text-slate-900 text-sm">GST Filing Due</p>
<p className="text-slate-600 text-xs">Due by Jan 15, 2026</p>
</div>
</div>
<div className="flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-600 mt-0.5" />
<AlertCircle className="w-4 h-4 text-re-red mt-0.5" />
<div>
<p className="text-slate-900 text-sm">Inventory Audit Scheduled</p>
<p className="text-slate-600 text-xs">Jan 20, 2026</p>

View File

@ -147,7 +147,7 @@ export function FDDDashboardPage() {
</p>
</div>
) : (
<div className="overflow-x-auto">
<div className="overflow-x-auto custom-scrollbar-x-slim">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
@ -184,7 +184,7 @@ export function FDDDashboardPage() {
<td className="px-6 py-4">
<Badge className={`px-3 py-1 rounded-full text-[10px] uppercase font-bold tracking-wider ${
app.overallStatus === 'Completed' ? 'bg-green-100 text-green-700' :
'bg-amber-100 text-amber-700'
'bg-red-50 text-re-red-hover'
}`}>
{app.overallStatus === 'Active' ? 'FDD Pending' : app.overallStatus}
</Badge>

View File

@ -52,14 +52,14 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
const apps = response.data || [];
// Derive Onboarding Payments from Application + SecurityDeposit (Standardized nomenclature)
// This ensures applications in "Payment Pending" / "Security Details" are visible
// This ensures applications in "Payment Pending" / "Security Deposit" are visible
// even if no payment record has been manually initialized.
const consolidatedPayments: any[] = [];
apps.forEach((app: any) => {
const s = app.overallStatus || app.status;
const isPaymentStage = [
'Payment Pending', 'Security Details', 'LOI In Progress', 'LOI Issued',
'Payment Pending', 'Security Deposit', 'Security Details', 'LOI In Progress', 'LOI Issued',
'LOA Pending', 'Dealer Code Generation', 'LOA_APPROVAL'
].includes(s);
const deposits = app.securityDeposits || [];
@ -80,7 +80,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
});
});
} else if (isPaymentStage) {
if (['Payment Pending', 'Security Details', 'LOI In Progress'].includes(s)) {
if (['Payment Pending', 'Security Deposit', 'Security Details', 'LOI In Progress'].includes(s)) {
// Virtual pending record for Security Deposit (5L)
consolidatedPayments.push({
id: `virtual-${app.id}-sd`,
@ -214,7 +214,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
if (loading) {
return (
<div className="flex items-center justify-center p-20 text-amber-600">
<div className="flex items-center justify-center p-20 text-re-red">
<Clock className="w-8 h-8 animate-spin mr-3" />
<span>Loading Finance Data...</span>
</div>
@ -234,7 +234,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<Card
className="cursor-pointer hover:shadow-lg transition-shadow border-amber-200 bg-amber-50/20"
className="cursor-pointer hover:shadow-lg transition-shadow border-red-200 bg-red-50/20"
onClick={() => {
if (pendingAudits.length > 0 && onViewAuditDetails) {
onViewAuditDetails(pendingAudits[0].applicationId || pendingAudits[0].id);
@ -244,12 +244,12 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
}}
>
<CardHeader className="pb-3">
<CardDescription className="text-amber-600 font-bold">Pending Audits</CardDescription>
<CardTitle className="text-3xl text-amber-600">{pendingAudits.length}</CardTitle>
<CardDescription className="text-re-red font-bold">Pending Audits</CardDescription>
<CardTitle className="text-3xl text-re-red">{pendingAudits.length}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600 text-xs font-medium">FDD Sign-offs</p>
<Button variant="link" className="p-0 h-auto text-amber-600 mt-2 text-xs">
<Button variant="link" className="p-0 h-auto text-re-red mt-2 text-xs">
Review Now
</Button>
</CardContent>
@ -265,7 +265,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</CardHeader>
<CardContent>
<p className="text-slate-600">Onboarding Payments</p>
<Button variant="link" className="p-0 h-auto text-amber-600 mt-2">
<Button variant="link" className="p-0 h-auto text-re-red mt-2">
View All
</Button>
</CardContent>
@ -281,7 +281,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</CardHeader>
<CardContent>
<p className="text-slate-600">Total Validated</p>
<Button variant="link" className="p-0 h-auto text-amber-600 mt-2">
<Button variant="link" className="p-0 h-auto text-re-red mt-2">
View All
</Button>
</CardContent>
@ -297,7 +297,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</CardHeader>
<CardContent>
<p className="text-slate-600">Offboarding Cases</p>
<Button variant="link" className="p-0 h-auto text-amber-600 mt-2">
<Button variant="link" className="p-0 h-auto text-re-red mt-2">
View All
</Button>
</CardContent>
@ -313,7 +313,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</CardHeader>
<CardContent>
<p className="text-slate-600">Settlements Done</p>
<Button variant="link" className="p-0 h-auto text-amber-600 mt-2">
<Button variant="link" className="p-0 h-auto text-re-red mt-2">
View All
</Button>
</CardContent>
@ -381,7 +381,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</div>
<div>
<p className="text-slate-600">Amount</p>
<p className="text-amber-700 font-bold">
<p className="text-re-red-hover font-bold">
{parseFloat(app.amount).toLocaleString('en-IN')}
</p>
</div>
@ -535,7 +535,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</div>
<div>
<p className="text-slate-600 font-medium text-[10px] uppercase tracking-wider mb-1">Dealer Code</p>
<p className="font-mono text-xs font-bold text-blue-600">{fnf.outlet?.code || 'N/A'}</p>
<p className="font-mono text-xs font-bold text-re-red">{fnf.outlet?.code || 'N/A'}</p>
</div>
<div>
<p className="text-slate-600 font-medium text-[10px] uppercase tracking-wider mb-1">Location</p>
@ -564,7 +564,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</Button>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700"
className="bg-re-red hover:bg-re-red-hover"
onClick={() => {
setSelectedFnF(fnf);
setLineItems([]);
@ -709,7 +709,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
<Button
size="sm"
onClick={handleAddLineItem}
className="w-full bg-amber-600 hover:bg-amber-700"
className="w-full bg-re-red hover:bg-re-red-hover"
>
<Plus className="w-4 h-4" />
</Button>
@ -837,7 +837,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</div>
<div>
<p className="text-xs text-slate-500 uppercase tracking-wider mb-1">Current Status</p>
<Badge variant="outline" className="bg-amber-100 text-amber-700 border-amber-200">
<Badge variant="outline" className="bg-red-50 text-re-red-hover border-red-200">
{selectedFnF.status}
</Badge>
</div>
@ -845,10 +845,10 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</Card>
<div className="grid grid-cols-2 gap-4">
<Card className="border-blue-100">
<CardHeader className="bg-blue-50/50 pb-2">
<Card className="border-red-100">
<CardHeader className="bg-red-50/50 pb-2">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-blue-600" />
<TrendingUp className="w-4 h-4 text-re-red" />
Receivables Check
</CardTitle>
</CardHeader>
@ -861,7 +861,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
<span className="text-slate-500">Other Payable Credits</span>
<span className="font-bold text-slate-900">0</span>
</div>
<div className="border-t pt-2 flex justify-between font-bold text-blue-700">
<div className="border-t pt-2 flex justify-between font-bold text-re-red-hover">
<span>Total Payables</span>
<span>{parseFloat(selectedFnF.totalPayables || 0).toLocaleString('en-IN')}</span>
</div>
@ -909,7 +909,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</div>
</div>
</div>
<Button className="bg-blue-600 hover:bg-blue-500 px-6 font-bold shadow-md transition-all active:scale-95">
<Button className="bg-re-red hover:bg-re-red-hover px-6 font-bold shadow-md transition-all active:scale-95">
Generate PDF Summary
</Button>
</div>

View File

@ -8,18 +8,18 @@ import {
RefreshCw,
} from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, Routes, Route, useParams } from 'react-router-dom';
import { useNavigate, Routes, Route, useParams, useLocation } from 'react-router-dom';
import { RootState } from '@/store';
import { logout } from '@/store/slices/authSlice';
import { toast } from 'sonner';
import { API } from '@/api/API';
import { formatDateTime } from '@/components/ui/utils';
import { Badge } from '@/components/ui/badge';
import { ProspectiveApplicationDetails } from '@/features/onboarding/pages/ProspectiveApplicationDetails';
export function ProspectiveDashboardPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const location = useLocation();
const { user } = useSelector((state: RootState) => state.auth);
const [collapsed, setCollapsed] = useState(false);
const [activeTab, setActiveTab] = useState('applicant');
@ -35,22 +35,46 @@ export function ProspectiveDashboardPage() {
{/* Sidebar */}
<div className={`bg-slate-900 text-white h-screen flex flex-col transition-all duration-300 ${collapsed ? 'w-20' : 'w-64'}`}>
<div className="p-4 border-b border-slate-800">
<div className="flex items-center justify-between">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-amber-600 rounded-lg flex items-center justify-center">
<FileText className="w-6 h-6 text-white" />
</div>
<span className="text-amber-600 font-bold">Applicant Portal</span>
</div>
)}
{!collapsed ? (
<div className="space-y-3">
<div className="flex justify-end">
<button
onClick={() => setCollapsed(!collapsed)}
onClick={() => setCollapsed(true)}
className="p-1 hover:bg-slate-800 rounded transition-colors"
title="Collapse sidebar"
>
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
<ChevronLeft className="w-5 h-5" />
</button>
</div>
<div className="w-full">
<img
src="/assets/images/Re_Logo.png"
alt="Royal Enfield"
className="mx-auto block h-auto w-full max-h-14 object-contain"
/>
</div>
<p className="text-center text-[10px] font-bold uppercase tracking-[0.2em] text-slate-400">
Applicant Portal
</p>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="w-full">
<img
src="/assets/images/Re_Logo.png"
alt="Royal Enfield"
className="block h-auto w-full max-h-10 object-contain"
/>
</div>
<button
onClick={() => setCollapsed(false)}
className="p-1 hover:bg-slate-800 rounded transition-colors"
title="Expand sidebar"
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
)}
</div>
<nav className="flex-1 p-4 space-y-2">
@ -60,7 +84,7 @@ export function ProspectiveDashboardPage() {
setActiveTab('applicant');
navigate('/prospective-dashboard');
}}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${activeTab === 'applicant' ? 'bg-amber-600 text-white' : 'text-slate-300 hover:bg-slate-800 hover:text-white'}`}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${activeTab === 'applicant' ? 'bg-re-red text-white hover:bg-re-red-hover' : 'text-slate-300 hover:bg-slate-800 hover:text-white'}`}
>
<FileText className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="flex-1 text-left">My Applications</span>}
@ -72,7 +96,7 @@ export function ProspectiveDashboardPage() {
{!collapsed && (
<div className="px-4 py-2 bg-slate-800 rounded-lg mb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-600 rounded-full flex items-center justify-center text-white">
<div className="w-10 h-10 bg-re-red rounded-full flex items-center justify-center text-white ring-2 ring-white/20">
<span className="font-bold">{user?.name?.charAt(0) || 'A'}</span>
</div>
<div className="flex-1 min-w-0">
@ -97,12 +121,20 @@ export function ProspectiveDashboardPage() {
<header className="bg-white border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-slate-900 text-xl font-semibold">Applicant Management</h1>
<p className="text-slate-600 text-sm">Manage and track dealership applications</p>
<h1 className="text-slate-900 text-xl font-semibold">
{location.pathname.includes('/application/')
? 'Application details'
: 'Applicant management'}
</h1>
<p className="text-slate-600 text-sm max-w-2xl leading-snug">
{location.pathname.includes('/application/')
? 'Review and update statutory information and required documents for this application. Progress is coordinated by Royal Enfield after you submit.'
: 'Start or continue your dealership application, upload documents, and use links we email you (for example the questionnaire).'}
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 px-3 py-2 bg-slate-100 rounded-lg">
<div className="w-8 h-8 bg-amber-600 rounded-full flex items-center justify-center">
<div className="w-8 h-8 bg-re-red rounded-full flex items-center justify-center ring-2 ring-re-red/20">
<User className="w-4 h-4 text-white" />
</div>
<div className="text-left">
@ -175,40 +207,22 @@ function ProspectiveApplicationList() {
{applications.map((app) => (
<div
key={app.id}
className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md hover:border-amber-500 cursor-pointer transition-all group"
className="bg-white rounded-2xl border border-slate-200 p-6 shadow-sm hover:shadow-md hover:border-re-red cursor-pointer transition-all group"
onClick={() => navigate(`/prospective-dashboard/application/${app.id}`)}
>
<div className="flex justify-between items-start mb-4">
<div className="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center group-hover:bg-amber-600 transition-colors">
<FileText className="w-6 h-6 text-amber-600 group-hover:text-white" />
<div className="mb-4">
<div className="w-12 h-12 bg-red-50 rounded-xl flex items-center justify-center group-hover:bg-re-red transition-colors">
<FileText className="w-6 h-6 text-re-red group-hover:text-white" />
</div>
<Badge className={`px-4 py-1.5 rounded-xl text-[10px] uppercase font-bold ${app.overallStatus === 'Completed' ? 'bg-green-100 text-green-700' :
app.overallStatus === 'Rejected' ? 'bg-red-100 text-red-700' :
'bg-amber-100 text-amber-700'}`}>
{app.overallStatus || 'Active'}
</Badge>
</div>
<h3 className="text-xl font-bold text-slate-900 mb-1 truncate">{app.applicationId}</h3>
<p className="text-slate-500 text-sm mb-4 font-medium">{app.city}, {app.state}</p>
<div className="space-y-4 pt-6 border-t border-slate-100">
<div className="flex justify-between items-center">
<span className="text-xs text-slate-500 font-medium">Current Stage</span>
<span className="text-xs font-bold text-slate-900 bg-slate-100 px-3 py-1 rounded-lg">{app.currentStage || 'Initial'}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-xs text-slate-500 font-medium">Applied</span>
<span className="text-xs font-bold text-slate-600">{formatDateTime(app.createdAt)}</span>
</div>
<div className="mt-6">
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Progress</span>
<span className="text-xs font-bold text-amber-600">{app.progressPercentage || 0}%</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-2">
<div className="bg-amber-500 h-2 rounded-full transition-all duration-1000" style={{ width: `${app.progressPercentage || 0}%` }}></div>
</div>
</div>
</div>
</div>
))}

View File

@ -33,8 +33,10 @@ import {
Trash2,
Save,
Paperclip,
FileDown
FileDown,
MessageSquare
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '@/lib/dateUtils';
@ -80,6 +82,7 @@ interface FinancialLineItem {
}
export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPageProps) {
const navigate = useNavigate();
const [fnfCase, setFnfCase] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState('overview');
@ -784,7 +787,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -801,6 +804,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="outline" size="icon" onClick={onBack}>
<ArrowLeft className="w-4 h-4" />
@ -810,13 +814,29 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<p className="text-slate-600">Full & Final Settlement for {fnfCase.dealerName}</p>
</div>
</div>
<Button
variant="outline"
onClick={() =>
navigate(`/worknotes/fnf/${fnfId}`, {
state: {
applicationName: fnfCase.dealerName || 'F&F Settlement',
registrationNumber: fnfCase.caseNumber || fnfCase.settlementId || '',
participants: fnfCase.participants || []
}
})
}
>
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
</Button>
</div>
{/* Status Banner */}
<Card className="border-amber-200 bg-amber-50">
<Card className="border-red-200 bg-red-50">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="size-12 shrink-0 aspect-square rounded-full bg-amber-100 flex items-center justify-center">
<div className="size-12 shrink-0 aspect-square rounded-full bg-red-50 flex items-center justify-center">
<IndianRupee className="w-5 h-5" />
</div>
<div>
@ -825,7 +845,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div>
</div>
<div className="flex gap-2">
<Badge className="bg-amber-600">
<Badge className="bg-re-red">
{fnfCase.status}
</Badge>
<Badge variant={fnfCase.terminationType === 'Resignation' ? 'default' : 'secondary'}>
@ -932,7 +952,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div>
<div>
<Label className="text-slate-500">Status</Label>
<Badge className="bg-amber-600">
<Badge className="bg-re-red">
{fnfCase.status}
</Badge>
</div>
@ -994,9 +1014,9 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<span className="text-slate-900">Total Receivables (from Dealer)</span>
<span className="text-red-700 text-lg">- {settlement.receivables.toLocaleString('en-IN')}</span>
</div>
<div className="flex justify-between items-center p-3 bg-amber-50 rounded-lg">
<div className="flex justify-between items-center p-3 bg-red-50 rounded-lg">
<span className="text-slate-900">Total Deductions</span>
<span className="text-amber-700 text-lg">- {settlement.deductions.toLocaleString('en-IN')}</span>
<span className="text-re-red-hover text-lg">- {settlement.deductions.toLocaleString('en-IN')}</span>
</div>
</div>
@ -1030,8 +1050,8 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-amber-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-re-red mt-0.5" />
<div>
<p className="text-sm text-slate-900 mb-1">Calculation Formula</p>
<p className="text-sm text-slate-600">
@ -1045,7 +1065,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</TabsContent>
<TabsContent value="financial" className="space-y-4">
<Card className="border-blue-200 bg-blue-50">
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-base">Department Claim vs Finance Validation</CardTitle>
<CardDescription>
@ -1401,12 +1421,12 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</Card>
{/* Deductions - Editable */}
<Card className="border-amber-200 bg-amber-50">
<Card className="border-red-200 bg-red-50">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-amber-600" />
<AlertCircle className="w-5 h-5 text-re-red" />
Deductions (Editable)
</CardTitle>
<CardDescription>Add or modify pending claims and deductions</CardDescription>
@ -1512,7 +1532,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</Table>
{/* Add New Deduction */}
<div className="border-t border-amber-300 pt-4 space-y-3">
<div className="border-t border-red-300 pt-4 space-y-3">
<p className="text-sm text-slate-700">Add New Deduction Item:</p>
<div className="grid grid-cols-12 gap-2">
<Select
@ -1541,17 +1561,17 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
onChange={(e) => setNewDeduction({ ...newDeduction, amount: e.target.value })}
className="col-span-3"
/>
<Button onClick={handleAddDeduction} className="col-span-1 bg-amber-600 hover:bg-amber-700">
<Button onClick={handleAddDeduction} className="col-span-1 bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Total */}
<div className="pt-3 border-t-2 border-amber-400">
<div className="pt-3 border-t-2 border-red-300">
<div className="flex justify-between items-center">
<span className="text-slate-900">Total Deductions</span>
<span className="text-amber-700 text-xl">
<span className="text-re-red-hover text-xl">
{settlement.deductions.toLocaleString('en-IN')}
</span>
</div>
@ -1560,10 +1580,10 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</Card>
{/* Final Settlement Summary */}
<Card className="border-2 border-blue-300 bg-blue-50">
<Card className="border-2 border-red-300 bg-red-50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="w-5 h-5 text-amber-600" />
<CheckCircle className="w-5 h-5 text-re-red" />
Final Settlement Summary
</CardTitle>
</CardHeader>
@ -1579,11 +1599,11 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div>
<div className="flex justify-between items-center p-3 bg-white rounded-lg">
<span className="text-slate-900">Total Deductions</span>
<span className="text-amber-700 text-lg">- {settlement.deductions.toLocaleString('en-IN')}</span>
<span className="text-re-red-hover text-lg">- {settlement.deductions.toLocaleString('en-IN')}</span>
</div>
</div>
<div className="h-px bg-blue-300"></div>
<div className="h-px bg-red-300"></div>
<div className={`p-4 rounded-lg border-2 ${
settlement.settlementType === 'Payable to Dealer'
@ -1613,8 +1633,8 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-white border border-amber-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="flex items-start gap-3 p-4 bg-white border border-red-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-re-red mt-0.5" />
<div>
<p className="text-sm text-slate-900 mb-1">Calculation Formula</p>
<p className="text-sm text-slate-600">
@ -1758,7 +1778,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
filePath: dept.supportingDocument,
documentType: 'Departmental Clearance Proof'
})}
className="flex items-center gap-1 text-[10px] text-amber-600 hover:underline"
className="flex items-center gap-1 text-[10px] text-re-red hover:underline"
>
<Paperclip className="w-3 h-3" />
View Proof
@ -1774,10 +1794,10 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</Card>
{/* Important Notes */}
<Card className="bg-blue-50 border-amber-200">
<Card className="bg-red-50 border-red-200">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<AlertCircle className="w-5 h-5 text-re-red mt-0.5" />
<div>
<p className="text-sm text-slate-900 mb-1">Department Response Guidelines</p>
<ul className="text-sm text-slate-700 space-y-1">
@ -1820,7 +1840,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
filePath: doc.url,
documentType: doc.type
})}
className="text-amber-600 hover:text-amber-700 text-[10px] font-semibold flex items-center gap-1"
className="text-re-red hover:text-re-red-hover text-[10px] font-semibold flex items-center gap-1"
>
<Paperclip className="w-3 h-3" /> PREVIEW
</button>
@ -1873,7 +1893,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-amber-400 hover:bg-amber-50 transition-colors">
<div className="border-2 border-dashed border-slate-300 rounded-lg p-8 text-center hover:border-red-300 hover:bg-red-50 transition-colors">
<Upload className="w-8 h-8 text-slate-400 mx-auto mb-2" />
<p className="text-slate-600 mb-2">Click to upload or drag and drop</p>
<p className="text-sm text-slate-500">PDF, DOC, DOCX, PNG, JPG, XLSX (max 10MB)</p>
@ -1910,7 +1930,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div>
<Button
size="sm"
className="bg-amber-600"
className="bg-re-red"
onClick={() => {
setEditingBank(null);
setIsBankModalOpen(true);
@ -1924,9 +1944,9 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{bankDetails.length > 0 ? (
bankDetails.map((bank: any) => (
<Card key={bank.id} className={`relative ${bank.isPrimary ? 'border-amber-500 bg-blue-50/30' : ''}`}>
<Card key={bank.id} className={`relative ${bank.isPrimary ? 'border-re-red bg-red-50/30' : ''}`}>
{bank.isPrimary && (
<div className="absolute top-0 right-0 p-1 bg-amber-600 text-white text-[10px] uppercase font-bold px-2 rounded-bl">
<div className="absolute top-0 right-0 p-1 bg-re-red text-white text-[10px] uppercase font-bold px-2 rounded-bl">
Primary
</div>
)}
@ -1955,7 +1975,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<Button
variant="ghost"
size="sm"
className="h-7 text-[11px] text-amber-600"
className="h-7 text-[11px] text-re-red"
onClick={() => {
setEditingBank(bank);
setIsBankModalOpen(true);
@ -2056,7 +2076,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
{/* Settlement Checklist */}
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 mb-4">
<p className="text-sm font-bold text-slate-900 mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-amber-600" />
<CheckCircle className="w-4 h-4 text-re-red" />
Compliance Checklist
</p>
<div className="space-y-3">
@ -2067,7 +2087,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
id={`check-${item.id}`}
checked={checklist.includes(item.id)}
onChange={() => toggleChecklist(item.id)}
className="w-4 h-4 mt-1 rounded border-slate-300 text-amber-600 focus:ring-amber-500"
className="w-4 h-4 mt-1 rounded border-slate-300 text-re-red focus:ring-re-red"
/>
<label htmlFor={`check-${item.id}`} className="text-sm text-slate-700 leading-tight">
{item.label}
@ -2146,7 +2166,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
}}
/>
{parseFloat(settlementDetails.adjustments) !== 0 && (
<p className="text-sm text-amber-600 mt-1 flex items-center gap-1">
<p className="text-sm text-re-red mt-1 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Adjusted amount: {settlementDetails.settlementAmount}
</p>
@ -2197,7 +2217,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<Button
variant="outline"
className="w-full border-blue-300 text-amber-600 hover:bg-blue-50"
className="w-full border-red-300 text-re-red hover:bg-red-50"
onClick={handleRequestClarification}
disabled={submitting}
>

View File

@ -150,7 +150,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -172,7 +172,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<CardContent>
<div className="flex items-center justify-between">
<div className="text-slate-900 text-2xl">{pendingCount}</div>
<Calculator className="w-8 h-8 text-amber-600" />
<Calculator className="w-8 h-8 text-re-red" />
</div>
</CardContent>
</Card>
@ -196,7 +196,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<CardContent>
<div className="flex items-center justify-between">
<div className="text-slate-900 text-2xl">{displaySettlements.length}</div>
<FileText className="w-8 h-8 text-blue-600" />
<FileText className="w-8 h-8 text-re-red" />
</div>
</CardContent>
</Card>
@ -224,21 +224,21 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<Button
variant={filterStatus === 'all' ? 'default' : 'outline'}
onClick={() => setFilterStatus('all')}
className={filterStatus === 'all' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={filterStatus === 'all' ? 'bg-re-red hover:bg-re-red-hover' : ''}
>
All Cases ({displaySettlements.length})
</Button>
<Button
variant={filterStatus === 'pending' ? 'default' : 'outline'}
onClick={() => setFilterStatus('pending')}
className={filterStatus === 'pending' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={filterStatus === 'pending' ? 'bg-re-red hover:bg-re-red-hover' : ''}
>
Pending Review ({pendingCount})
</Button>
<Button
variant={filterStatus === 'approved' ? 'default' : 'outline'}
onClick={() => setFilterStatus('approved')}
className={filterStatus === 'approved' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={filterStatus === 'approved' ? 'bg-re-red hover:bg-re-red-hover' : ''}
>
Approved ({approvedCount})
</Button>
@ -314,7 +314,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<TableCell>
<Badge
variant={fnfCase.status === 'Settlement Approved' ? 'default' : 'secondary'}
className={fnfCase.status === 'Settlement Approved' ? 'bg-green-600 text-white' : 'bg-amber-600 text-white'}
className={fnfCase.status === 'Settlement Approved' ? 'bg-green-600 text-white' : 'bg-re-red text-white'}
>
{fnfCase.status}
</Badge>
@ -323,7 +323,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<Button
size="sm"
variant={fnfCase.status === 'Pending Finance Review' ? 'default' : 'outline'}
className={fnfCase.status === 'Pending Finance Review' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={fnfCase.status === 'Pending Finance Review' ? 'bg-re-red hover:bg-re-red-hover' : ''}
onClick={() => handleViewDetails(fnfCase)}
>
<FileText className="w-4 h-4 mr-2" />
@ -442,10 +442,10 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
</div>
{/* Deductions */}
<Card className="border-amber-200 bg-amber-50">
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-amber-600" />
<AlertCircle className="w-5 h-5 text-re-red" />
Deductions
</CardTitle>
<CardDescription>Pending claims and deductions</CardDescription>
@ -479,9 +479,9 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<span className="text-slate-900">Total Receivables (from Dealer)</span>
<span className="text-red-600 text-lg">- {settlement.receivables.toLocaleString()}</span>
</div>
<div className="flex justify-between items-center p-3 bg-amber-50 rounded">
<div className="flex justify-between items-center p-3 bg-red-50 rounded">
<span className="text-slate-900">Total Deductions</span>
<span className="text-amber-600 text-lg">- {settlement.deductions.toLocaleString()}</span>
<span className="text-re-red text-lg">- {settlement.deductions.toLocaleString()}</span>
</div>
</div>
@ -513,8 +513,8 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
</div>
</div>
<div className="flex items-start gap-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-blue-600 mt-0.5" />
<div className="flex items-start gap-3 p-4 bg-red-50 border border-red-200 rounded-lg">
<AlertCircle className="w-5 h-5 text-re-red mt-0.5" />
<div>
<p className="text-sm text-slate-900 mb-1">Calculation Formula</p>
<p className="text-sm text-slate-600">
@ -695,7 +695,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<p className="text-sm text-slate-500">Status</p>
<Badge
variant={selectedCase.status === 'Settlement Approved' ? 'default' : 'secondary'}
className={selectedCase.status === 'Settlement Approved' ? 'bg-green-600 text-white' : 'bg-amber-600 text-white'}
className={selectedCase.status === 'Settlement Approved' ? 'bg-green-600 text-white' : 'bg-re-red text-white'}
>
{selectedCase.status}
</Badge>
@ -793,10 +793,10 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
</Card>
{/* Deductions */}
<Card className="border-amber-200 bg-amber-50">
<Card className="border-red-200 bg-red-50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-amber-600" />
<AlertCircle className="w-5 h-5 text-re-red" />
Deductions
</CardTitle>
</CardHeader>
@ -850,7 +850,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
</Button>
{selectedCase?.status === 'Pending Finance Review' && (
<Button
className="bg-amber-600 hover:bg-amber-700"
className="bg-re-red hover:bg-re-red-hover"
onClick={() => {
setShowDetailsDialog(false);
handleReviewCase(selectedCase);

View File

@ -146,7 +146,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-re-red"></div>
</div>
);
}
@ -168,7 +168,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Button
size="sm"
variant={activeType === 'SECURITY_DEPOSIT' ? 'default' : 'outline'}
className={activeType === 'SECURITY_DEPOSIT' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={activeType === 'SECURITY_DEPOSIT' ? 'bg-re-red hover:bg-re-red-hover' : ''}
onClick={() => setActiveType('SECURITY_DEPOSIT')}
>
Security Deposit
@ -176,7 +176,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Button
size="sm"
variant={activeType === 'FIRST_FILL' ? 'default' : 'outline'}
className={activeType === 'FIRST_FILL' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={activeType === 'FIRST_FILL' ? 'bg-re-red hover:bg-re-red-hover' : ''}
onClick={() => setActiveType('FIRST_FILL')}
>
First Fill
@ -190,7 +190,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
"border",
activeDeposit?.status === 'Verified' ? "border-green-200 bg-green-50" :
activeDeposit?.status === 'Rejected' ? "border-red-200 bg-red-50" :
"border-amber-200 bg-amber-50"
"border-red-200 bg-red-50"
)}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
@ -199,13 +199,13 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
"w-12 h-12 rounded-full flex items-center justify-center",
activeDeposit?.status === 'Verified' ? "bg-green-100" :
activeDeposit?.status === 'Rejected' ? "bg-red-100" :
"bg-amber-100"
"bg-red-50"
)}>
<IndianRupee className={cn(
"w-6 h-6",
activeDeposit?.status === 'Verified' ? "text-green-600" :
activeDeposit?.status === 'Rejected' ? "text-red-600" :
"text-amber-600"
"text-re-red"
)} />
</div>
<div>
@ -224,7 +224,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Badge className={cn(
activeDeposit?.status === 'Verified' ? "bg-green-600" :
activeDeposit?.status === 'Rejected' ? "bg-red-600" :
"bg-amber-600 text-white"
"bg-re-red text-white"
)}>
{activeDeposit?.status || 'No Record'}
</Badge>
@ -237,7 +237,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<User className="w-5 h-5 text-amber-600" />
<User className="w-5 h-5 text-re-red" />
Applicant Information
</CardTitle>
</CardHeader>
@ -265,7 +265,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<CreditCard className="w-5 h-5 text-amber-600" />
<CreditCard className="w-5 h-5 text-re-red" />
Deposit Tracking
</CardTitle>
</CardHeader>
@ -273,7 +273,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-200">
<Label className="text-slate-500 block mb-1">Expected Amount</Label>
<p className="text-2xl font-bold text-amber-900">
<p className="text-2xl font-bold text-red-900">
{(activeType === 'SECURITY_DEPOSIT'
? (configs.SECURITY_DEPOSIT?.amount || 500000)
: (configs.FIRST_FILL?.amount || 1500000)
@ -282,12 +282,12 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
</div>
<div className={cn(
"p-4 rounded-lg border",
activeDeposit?.status === 'Verified' ? "bg-green-50 border-green-200" : "bg-blue-50 border-blue-200"
activeDeposit?.status === 'Verified' ? "bg-green-50 border-green-200" : "bg-red-50 border-red-200"
)}>
<Label className="text-slate-500 block mb-1">Receipt Status</Label>
<p className={cn(
"text-2xl font-bold",
activeDeposit?.status === 'Verified' ? "text-green-700" : "text-blue-700"
activeDeposit?.status === 'Verified' ? "text-green-700" : "text-re-red-hover"
)}>
{activeDeposit?.status || 'Not Started'}
</p>
@ -312,7 +312,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<FileText className="w-5 h-5 text-amber-600" />
<FileText className="w-5 h-5 text-re-red" />
Verification Evidence
</CardTitle>
<CardDescription>Documents uploaded by the applicant for payment proof</CardDescription>
@ -342,7 +342,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Button
variant="ghost"
size="sm"
className="text-amber-600 hover:text-amber-700 hover:bg-amber-50"
className="text-re-red hover:text-re-red-hover hover:bg-red-50"
onClick={() => {
setPreviewDoc(doc);
setShowPreviewModal(true);
@ -364,10 +364,10 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
</div>
<div className="space-y-6">
<Card className="border-amber-100 shadow-sm">
<CardHeader className="bg-amber-50/50">
<Card className="border-red-100 shadow-sm">
<CardHeader className="bg-red-50/50">
<CardTitle className="flex items-center gap-2 text-lg">
<Wallet className="w-5 h-5 text-amber-600" />
<Wallet className="w-5 h-5 text-re-red" />
Finance Action
</CardTitle>
</CardHeader>
@ -431,7 +431,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Button
className={cn(
"w-full transition-all duration-200",
activeDeposit?.status === 'Verified' ? "bg-green-600 hover:bg-green-600 opacity-90" : "bg-amber-600 hover:bg-amber-700"
activeDeposit?.status === 'Verified' ? "bg-green-600 hover:bg-green-600 opacity-90" : "bg-re-red hover:bg-re-red-hover"
)}
onClick={handleApprovePayment}
disabled={isSubmitting || activeDeposit?.status === 'Verified'}
@ -461,7 +461,7 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
<Card className="bg-slate-900 text-white border-none shadow-xl">
<CardHeader>
<CardTitle className="text-base font-medium flex items-center gap-2">
<Clock className="w-4 h-4 text-amber-400" />
<Clock className="w-4 h-4 text-re-red" />
Next Steps
</CardTitle>
</CardHeader>

View File

@ -10,6 +10,7 @@ import {
CheckCircle2,
AlertCircle,
Loader2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@ -40,7 +41,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { Progress } from "@/components/ui/progress";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { toast } from "sonner";
import { API } from "@/api/API";
@ -86,6 +87,12 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
type: "Receivable",
});
const [clearanceFile, setClearanceFile] = useState<File | null>(null);
const [isUploadingDocs, setIsUploadingDocs] = useState(false);
const documentInputRef = useRef<HTMLInputElement>(null);
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
const [uploadDocName, setUploadDocName] = useState("");
const [uploadDocType, setUploadDocType] = useState("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
useEffect(() => {
@ -300,6 +307,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
status: "Finance",
url: doc.filePath,
})),
...(s.clearanceDocuments || [])
.filter((d: any) => d?.supportingDocument)
.map((d: any, idx: number) => ({
id: d.id || `fnf-doc-${idx}`,
name: d.name || (d.supportingDocument || "").split("/").pop(),
type: d.documentType || d.department || "F&F Document",
uploadDate: d.clearedAt ? formatDateTime(d.clearedAt) : "-",
status: "Attached",
url: d.supportingDocument,
})),
],
participants: s.participants || []
};
@ -396,7 +413,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -423,9 +440,13 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
const fnfAge = calculateAge(fnfCase.submittedOn);
const currentUserRole = String(currentUser?.role || "").toLowerCase();
const canViewDocuments =
currentUserRole.includes("super admin") || currentUserRole.includes("dd admin");
const canRespondToDepartment = (dept: any) => {
if (!fnfCase || !dept) return false;
const role = String(currentUser?.role || "").toLowerCase();
const role = currentUserRole;
if (!role) return false;
// 1. If any user (including Admin) has already responded, hide the button to prevent double-submission
@ -486,6 +507,61 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
}
};
const handlePickDocumentFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0] || null;
setUploadFile(file);
};
const resetUploadDialog = () => {
setUploadDialogOpen(false);
setUploadDocName("");
setUploadDocType("");
setUploadFile(null);
if (documentInputRef.current) documentInputRef.current.value = "";
};
const handleSubmitDocumentUpload = async () => {
const trimmedName = uploadDocName.trim();
if (!trimmedName) {
toast.error("Please enter a document name.");
return;
}
if (!uploadFile) {
toast.error("Please choose a file to upload.");
return;
}
if (!fnfId) {
toast.error("Cannot upload — settlement id is missing.");
return;
}
setIsUploadingDocs(true);
try {
const formData = new FormData();
formData.append("file", uploadFile);
formData.append("documentName", trimmedName);
if (uploadDocType.trim()) {
formData.append("documentType", uploadDocType.trim());
}
const response: any = await API.uploadFnFDocument(fnfId, formData);
if (response.data?.success) {
toast.success("Document uploaded successfully");
resetUploadDialog();
fetchFnFDetails(false);
} else {
toast.error(response.data?.message || "Failed to upload document");
}
} catch (error: any) {
console.error("Upload F&F document error:", error);
toast.error(
error?.response?.data?.message || "Failed to upload document",
);
} finally {
setIsUploadingDocs(false);
}
};
const handleSendToStakeholders = () => {
toast.success("Notifications sent to all 16 departments");
setSendStakeholdersDialog(false);
@ -494,7 +570,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
const getStatusColor = (status: string) => {
switch (status) {
case "New":
return "bg-amber-100 text-blue-700 border-blue-300";
return "bg-red-50 text-re-red-hover border-red-300";
case "In Progress":
return "bg-yellow-100 text-yellow-700 border-yellow-300";
case "Under Review":
@ -599,7 +675,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<Badge
className={
fnfCase.requestType === "Resignation"
? "bg-amber-100 text-amber-700 border-amber-300"
? "bg-red-50 text-re-red-hover border-red-300"
: "bg-red-100 text-red-700 border-red-300"
}
>
@ -626,7 +702,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
{/* {canSendToStakeholders && fnfCase.status === "New" && (
<Button
className="bg-amber-600 hover:bg-blue-700"
className="bg-re-red hover:bg-re-red-hover"
onClick={() => setSendStakeholdersDialog(true)}
>
<Send className="w-4 h-4 mr-2" />
@ -691,9 +767,11 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<TabsTrigger value="progress">Progress</TabsTrigger>
<TabsTrigger value="details">Case Details</TabsTrigger>
<TabsTrigger value="departments">Department Responses</TabsTrigger>
<TabsTrigger value="financial">Financial Summary</TabsTrigger>
{canViewDocuments && (
<TabsTrigger value="documents">Documents</TabsTrigger>
<TabsTrigger value="bank">Bank Details</TabsTrigger>
)}
{/* Bank Details tab hidden temporarily */}
{/* <TabsTrigger value="bank">Bank Details</TabsTrigger> */}
<TabsTrigger value="audit">Audit Trail</TabsTrigger>
</TabsList>
@ -775,7 +853,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
)
? "bg-green-100 border-green-600"
: responsesReceived > 0
? "bg-amber-100 border-amber-600"
? "bg-red-50 border-re-red"
: "bg-slate-100 border-slate-300"
}`}
>
@ -785,7 +863,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
) ? (
<Check className="w-6 h-6 text-green-600" />
) : responsesReceived > 0 ? (
<Users className="w-6 h-6 text-amber-600" />
<Users className="w-6 h-6 text-re-red" />
) : (
<Clock className="w-6 h-6 text-slate-400" />
)}
@ -814,7 +892,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
)
? "bg-green-600"
: responsesReceived > 0
? "bg-amber-600"
? "bg-re-red"
: "bg-slate-400"
}
>
@ -843,7 +921,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
fnfCase.status,
)
? "bg-green-50 border-green-200"
: "bg-blue-50 border-amber-200"
: "bg-red-50 border-red-200"
}
>
<CardContent className="p-4">
@ -910,14 +988,14 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${fnfCase.status === "Completed"
? "bg-green-100 border-green-600"
: fnfCase.status === "Finance Approval"
? "bg-amber-100 border-amber-600"
? "bg-red-50 border-re-red"
: "bg-slate-100 border-slate-300"
}`}
>
{fnfCase.status === "Completed" ? (
<Check className="w-6 h-6 text-green-600" />
) : fnfCase.status === "Finance Approval" ? (
<FileCheck className="w-6 h-6 text-amber-600" />
<FileCheck className="w-6 h-6 text-re-red" />
) : (
<Clock className="w-6 h-6 text-slate-400" />
)}
@ -940,7 +1018,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
fnfCase.status === "Completed"
? "bg-green-600"
: fnfCase.status === "Finance Approval"
? "bg-amber-600"
? "bg-re-red"
: "bg-slate-400"
}
>
@ -964,7 +1042,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
className={
fnfCase.status === "Completed"
? "bg-green-50 border-green-200"
: "bg-blue-50 border-amber-200"
: "bg-red-50 border-red-200"
}
>
<CardContent className="p-4">
@ -989,8 +1067,8 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
"0"}
</p>
</div>
<div className="text-center p-3 bg-amber-100 rounded-lg">
<p className="text-xs text-blue-700 mb-1">
<div className="text-center p-3 bg-red-50 rounded-lg">
<p className="text-xs text-re-red-hover mb-1">
Net Amount
</p>
<p
@ -1022,14 +1100,14 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${fnfCase.status === "Completed"
? "bg-green-100 border-green-600"
: fnfCase.status === "Finance Approval"
? "bg-amber-100 border-amber-600"
? "bg-red-50 border-re-red"
: "bg-slate-100 border-slate-300"
}`}
>
{fnfCase.status === "Completed" ? (
<Check className="w-6 h-6 text-green-600" />
) : fnfCase.status === "Finance Approval" ? (
<MessageSquare className="w-6 h-6 text-amber-600" />
<MessageSquare className="w-6 h-6 text-re-red" />
) : (
<Clock className="w-6 h-6 text-slate-400" />
)}
@ -1052,7 +1130,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
fnfCase.status === "Completed"
? "bg-green-600"
: fnfCase.status === "Finance Approval"
? "bg-amber-600"
? "bg-re-red"
: "bg-slate-400"
}
>
@ -1184,7 +1262,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
Case closed. All obligations fulfilled.
</p>
{fnfCase.status === "Completed" && (
<Card className="bg-gradient-to-r from-green-50 to-blue-50 border-green-300">
<Card className="bg-gradient-to-r from-green-50 to-red-50 border-green-300">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="size-12 shrink-0 aspect-square rounded-full bg-green-600 flex items-center justify-center">
@ -1255,9 +1333,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</CardContent>
</Card>
<Card className="border-amber-200 bg-blue-50/30">
<Card className="border-red-200 bg-red-50/30">
<CardHeader>
<CardTitle className="text-blue-900">
<CardTitle className="text-re-red-hover">
F&F Settlement Information
</CardTitle>
</CardHeader>
@ -1426,7 +1504,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<Button
variant="ghost"
size="sm"
className="text-amber-600 hover:text-blue-700"
className="text-re-red hover:text-re-red-hover"
onClick={() => {
setSelectedDept(dept);
setClearanceForm({
@ -1451,12 +1529,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</Table>
</CardContent>
</Card>
</TabsContent>
{/* Financial Summary Tab */}
<TabsContent value="financial">
<div className="space-y-6">
<Card className="border-blue-200 bg-blue-50">
{/* Department Claim vs Finance Validation */}
<Card className="border-red-200 bg-red-50 mt-6">
<CardHeader>
<CardTitle>Department Claim vs Finance Validation</CardTitle>
<CardDescription>
@ -1489,7 +1564,8 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</CardContent>
</Card>
<Card>
{/* Financial Summary */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Financial Summary</CardTitle>
<CardDescription>
@ -1520,19 +1596,19 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
Amount receivable from dealer
</p>
</div>
<div className="p-6 bg-amber-50 rounded-lg border border-amber-200">
<p className="text-sm text-amber-700 mb-2">
<div className="p-6 bg-red-50 rounded-lg border border-red-200">
<p className="text-sm text-re-red-hover mb-2">
Total Deductions
</p>
<p className="text-3xl text-amber-600 font-bold">
<p className="text-3xl text-re-red font-bold">
{fnfCase.totalDeductions?.toLocaleString() || "0"}
</p>
<p className="text-xs text-amber-600 mt-1">
<p className="text-xs text-re-red mt-1">
Warranty holdbacks / Policy penalties
</p>
</div>
<div className="p-6 bg-blue-50 rounded-lg border border-blue-200">
<p className="text-sm text-blue-700 mb-2">Net Settlement Amount</p>
<div className="p-6 bg-red-50 rounded-lg border border-red-200">
<p className="text-sm text-re-red-hover mb-2">Net Settlement Amount</p>
<p
className={`text-3xl font-extrabold ${(fnfCase.netAmount || 0) < 0
? "text-red-600"
@ -1541,7 +1617,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
>
{Math.abs(fnfCase.netAmount || 0).toLocaleString()}
</p>
<p className="text-xs text-blue-600 mt-1">
<p className="text-xs text-re-red mt-1">
{(fnfCase.netAmount || 0) < 0
? "Receivable from dealer"
: "Payment to dealer"}
@ -1551,7 +1627,8 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</CardContent>
</Card>
<Card>
{/* Finance Report Status */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Finance Report Status</CardTitle>
</CardHeader>
@ -1589,17 +1666,34 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Documents Tab */}
{/* Documents Tab — admin / super admin only */}
{canViewDocuments && (
<TabsContent value="documents">
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-start justify-between gap-4">
<div>
<CardTitle>Documents</CardTitle>
<CardDescription>
All NOC documents and due statements from departments
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
onClick={() => setUploadDialogOpen(true)}
disabled={isUploadingDocs}
className="bg-re-red hover:bg-re-red-hover text-white"
data-testid="fnf-upload-docs-btn"
>
{isUploadingDocs ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploadingDocs ? "Uploading..." : "Upload Document"}
</Button>
</div>
</CardHeader>
<CardContent>
<Table>
@ -1613,7 +1707,17 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</TableRow>
</TableHeader>
<TableBody>
{fnfCase.documents.map((doc: any) => (
{(fnfCase.documents || []).length === 0 && (
<TableRow>
<TableCell
colSpan={5}
className="text-center text-slate-500 py-8"
>
No documents uploaded yet. Click <span className="font-medium">Upload Documents</span> to add files.
</TableCell>
</TableRow>
)}
{(fnfCase.documents || []).map((doc: any) => (
<TableRow key={doc.id}>
<TableCell>
<div className="flex items-center gap-2">
@ -1663,6 +1767,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</CardContent>
</Card>
</TabsContent>
)}
{/* Bank Details Tab */}
<TabsContent value="bank">
@ -1679,7 +1784,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
setEditingBank(null);
setIsBankModalOpen(true);
}}
className="bg-amber-600"
className="bg-re-red"
>
<Plus className="w-4 h-4 mr-2" />
Add Account
@ -1689,16 +1794,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{bankDetails.length > 0 ? (
bankDetails.map((bank: any) => (
<Card key={bank.id} className={`relative overflow-hidden ${bank.isPrimary ? 'border-amber-500 bg-blue-50/30' : ''}`}>
<Card key={bank.id} className={`relative overflow-hidden ${bank.isPrimary ? 'border-re-red bg-red-50/30' : ''}`}>
{bank.isPrimary && (
<div className="absolute top-0 right-0 p-1 bg-amber-600 text-white text-[10px] uppercase font-bold px-2 rounded-bl">
<div className="absolute top-0 right-0 p-1 bg-re-red text-white text-[10px] uppercase font-bold px-2 rounded-bl">
Primary
</div>
)}
<CardContent className="p-5">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-amber-100 rounded-lg">
<Building2 className="w-5 h-5 text-amber-600" />
<div className="p-2 bg-red-50 rounded-lg">
<Building2 className="w-5 h-5 text-re-red" />
</div>
<div>
<p className="font-bold text-slate-900">{bank.bankName}</p>
@ -1729,7 +1834,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<Button
variant="ghost"
size="sm"
className="h-8 text-amber-600"
className="h-8 text-re-red"
onClick={() => {
setEditingBank(bank);
setIsBankModalOpen(true);
@ -1789,12 +1894,12 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<p className="font-semibold text-slate-900 flex items-center gap-2">
{log.action === 'FNF_CREATED' && <Badge className="bg-amber-600 h-2 w-2 p-0 rounded-full" />}
{log.action === 'FNF_CREATED' && <Badge className="bg-re-red h-2 w-2 p-0 rounded-full" />}
{(log.description && !log.newData?.action) ? log.description : (
<>
{getFriendlyActionName(log.newData?.action || log.action)}
{log.newData?.department && (
<span className="text-amber-600 ml-1 font-bold">
<span className="text-re-red ml-1 font-bold">
- {log.newData.department}
</span>
)}
@ -1857,11 +1962,11 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</DialogHeader>
<div className="space-y-4">
<div className="p-4 bg-blue-50 rounded-lg border border-amber-200">
<p className="text-sm text-blue-900 mb-2">
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<p className="text-sm text-re-red-hover mb-2">
Notifications will be sent to:
</p>
<ul className="text-sm text-blue-800 space-y-1 ml-4">
<ul className="text-sm text-re-red-hover space-y-1 ml-4">
<li> All 16 departments</li>
<li> Case Number: {fnfCase.caseNumber}</li>
<li> Dealer: {fnfCase.dealerName}</li>
@ -1879,7 +1984,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</Button>
<Button
onClick={handleSendToStakeholders}
className="bg-amber-600 hover:bg-blue-700"
className="bg-re-red hover:bg-re-red-hover"
>
<Send className="w-4 h-4 mr-2" />
Send Notifications
@ -1953,7 +2058,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
Cancel
</Button>
<Button
className="bg-amber-600 hover:bg-blue-700"
className="bg-re-red hover:bg-re-red-hover"
onClick={handleUpdateClearance}
disabled={isUpdatingClearance}
>
@ -1963,6 +2068,96 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</DialogContent>
</Dialog>
{/* Upload Document Dialog */}
<Dialog
open={uploadDialogOpen}
onOpenChange={(open) => {
if (!open) resetUploadDialog();
else setUploadDialogOpen(true);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Provide a name for this document and attach a file. The name is required.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1.5">
<Label htmlFor="fnf-upload-doc-name">
Document Name <span className="text-red-600">*</span>
</Label>
<input
id="fnf-upload-doc-name"
type="text"
placeholder="e.g. Final NOC — Sales"
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
value={uploadDocName}
onChange={(e) => setUploadDocName(e.target.value)}
data-testid="fnf-upload-doc-name"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fnf-upload-doc-type">Document Type (optional)</Label>
<input
id="fnf-upload-doc-type"
type="text"
placeholder="e.g. NOC, Statement, Receipt"
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
value={uploadDocType}
onChange={(e) => setUploadDocType(e.target.value)}
data-testid="fnf-upload-doc-type"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="fnf-upload-doc-file">
File <span className="text-red-600">*</span>
</Label>
<input
id="fnf-upload-doc-file"
ref={documentInputRef}
type="file"
className="flex w-full text-sm"
onChange={handlePickDocumentFile}
data-testid="fnf-upload-docs-input"
/>
{uploadFile && (
<p className="text-xs text-slate-500 truncate">
Selected: <span className="font-medium">{uploadFile.name}</span>
</p>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={resetUploadDialog}
disabled={isUploadingDocs}
>
Cancel
</Button>
<Button
className="bg-re-red hover:bg-re-red-hover text-white"
onClick={handleSubmitDocumentUpload}
disabled={isUploadingDocs || !uploadDocName.trim() || !uploadFile}
data-testid="fnf-upload-doc-submit"
>
{isUploadingDocs ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isUploadingDocs ? "Uploading..." : "Upload"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bank Details Modal */}
<BankDetailsModal
isOpen={isBankModalOpen}

View File

@ -17,7 +17,7 @@ interface FnFPageProps {
const getStatusColor = (status: string) => {
switch (status) {
case 'Initiated':
return 'bg-blue-100 text-blue-700 border-blue-300';
return 'bg-red-100 text-re-red-hover border-red-300';
case 'DD Clearance':
case 'Legal Clearance':
return 'bg-yellow-100 text-yellow-700 border-yellow-300';
@ -34,7 +34,7 @@ const getStatusColor = (status: string) => {
const getTypeColor = (type: string) => {
return type === 'Resignation'
? 'bg-amber-100 text-amber-700 border-amber-300'
? 'bg-red-50 text-re-red-hover border-red-300'
: 'bg-red-100 text-red-700 border-red-300';
};
@ -65,7 +65,7 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
if (loading) {
return (
<div className="flex items-center justify-center p-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -102,7 +102,7 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
<Card>
<CardHeader className="pb-3">
<CardDescription>Initiated</CardDescription>
<CardTitle className="text-3xl text-blue-600">
<CardTitle className="text-3xl text-re-red">
{initiatedCases.length}
</CardTitle>
</CardHeader>
@ -186,8 +186,8 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<div className="p-3 bg-blue-100 rounded-lg">
<FileCheck className="w-6 h-6 text-blue-600" />
<div className="p-3 bg-red-100 rounded-lg">
<FileCheck className="w-6 h-6 text-re-red" />
</div>
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
@ -239,7 +239,7 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
<Button
size="sm"
variant="outline"
className="text-blue-600 border-blue-300 hover:bg-blue-50"
className="text-re-red border-red-300 hover:bg-red-50"
onClick={() => handleSendToStakeholders(fnfCase.id)}
>
<Send className="w-4 h-4 mr-2" />
@ -276,12 +276,12 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<div className={`p-3 rounded-lg ${fnfCase.status === 'Initiated' ? 'bg-blue-100' :
<div className={`p-3 rounded-lg ${fnfCase.status === 'Initiated' ? 'bg-red-100' :
(fnfCase.status === 'DD Clearance' || fnfCase.status === 'Legal Clearance') ? 'bg-yellow-100' :
(fnfCase.status === 'Finance Approval' || fnfCase.status === 'Calculated') ? 'bg-orange-100' :
'bg-green-100'
}`}>
<IndianRupee className={`w-6 h-6 ${fnfCase.status === 'Initiated' ? 'text-blue-600' :
<IndianRupee className={`w-6 h-6 ${fnfCase.status === 'Initiated' ? 'text-re-red' :
(fnfCase.status === 'DD Clearance' || fnfCase.status === 'Legal Clearance') ? 'text-yellow-600' :
(fnfCase.status === 'Finance Approval' || fnfCase.status === 'Calculated') ? 'text-orange-600' :
'text-green-600'
@ -322,7 +322,7 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
<Button
size="sm"
variant="outline"
className="text-blue-600 border-blue-300 hover:bg-blue-50"
className="text-re-red border-red-300 hover:bg-red-50"
onClick={() => handleSendToStakeholders(fnfCase.id)}
>
<Send className="w-4 h-4 mr-2" />

View File

@ -181,7 +181,7 @@ export const ASMDialog: React.FC<ASMDialogProps> = ({
return (
<div key={state} className="mb-4 last:mb-0">
<h4 className="text-sm text-amber-700 mb-2 pb-1 border-b border-slate-200">{state}</h4>
<h4 className="text-sm text-re-red-hover mb-2 pb-1 border-b border-slate-200">{state}</h4>
<div className="space-y-2 ml-2">
{districts.map((district: any) => (
<div key={district.id}>
@ -280,7 +280,7 @@ export const ASMDialog: React.FC<ASMDialogProps> = ({
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save DD-AM</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save DD-AM</Button>
</div>
</div>
</DialogContent>

View File

@ -41,7 +41,7 @@ export const ASMManagement: React.FC<ASMManagementProps> = ({
<CardTitle>District Development Area Managers (DD-AM)</CardTitle>
<CardDescription>Manage DD-AM users across districts (multi-district)</CardDescription>
</div>
<Button onClick={onAddASM} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddASM} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add DD-AM
</Button>
@ -86,7 +86,7 @@ export const ASMManagement: React.FC<ASMManagementProps> = ({
<Badge
key={idx}
variant={isShared ? "outline" : "secondary"}
className={`text-xs ${isShared ? "border-amber-300 bg-amber-50 text-amber-700 font-medium" : ""}`}
className={`text-xs ${isShared ? "border-red-300 bg-red-50 text-re-red-hover font-medium" : ""}`}
title={isShared ? `Also managed by: ${otherManagers.join(', ')}` : undefined}
>
{areaName}
@ -112,7 +112,7 @@ export const ASMManagement: React.FC<ASMManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditASM(asm)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteASM(asm.id, asm.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteASM(asm.id, asm.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -83,7 +83,7 @@ export const AutoAssignmentSettings: React.FC = () => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-12 space-y-4">
<RefreshCcw className="w-8 h-8 animate-spin text-amber-600" />
<RefreshCcw className="w-8 h-8 animate-spin text-re-red" />
<p className="text-slate-500 font-medium">Loading governance controls...</p>
</div>
);
@ -94,8 +94,8 @@ export const AutoAssignmentSettings: React.FC = () => {
<Card className="border-none shadow-md overflow-hidden bg-white/50 backdrop-blur-sm">
<CardHeader className="bg-gradient-to-r from-slate-900 to-slate-800 text-white p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-500/20 rounded-lg">
<Settings2 className="w-6 h-6 text-amber-400" />
<div className="p-2 bg-red-500/20 rounded-lg">
<Settings2 className="w-6 h-6 text-red-400" />
</div>
<div>
<CardTitle className="text-xl font-bold">Auto-Assignment Governance</CardTitle>
@ -148,6 +148,7 @@ export const AutoAssignmentSettings: React.FC = () => {
<div className="flex items-center gap-4">
<Switch
className="data-[state=checked]:bg-re-red"
checked={isEnabled}
onCheckedChange={(val) => {
handleToggle(mod.key, val);
@ -164,9 +165,9 @@ export const AutoAssignmentSettings: React.FC = () => {
})}
</div>
<div className="mt-8 p-4 bg-amber-50 border border-amber-100 rounded-lg flex items-start gap-3">
<Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="text-sm text-amber-800">
<div className="mt-8 p-4 bg-red-50 border border-red-100 rounded-lg flex items-start gap-3">
<Info className="w-5 h-5 text-re-red shrink-0 mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-semibold mb-1">Impact of Manual Mode:</p>
<p>Turning OFF auto-assignment will ONLY affect new requests. Existing requests will retain their current participant mappings. You will need to use the "Add Participant" button in the worknotes or application details to grant access to stakeholders.</p>
</div>

View File

@ -127,7 +127,7 @@ export const DDLeadDialog: React.FC<DDLeadDialogProps> = ({
<div className="flex gap-3 pt-6">
<Button variant="outline" className="flex-1 border-slate-200" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700 shadow-sm" onClick={onSave}>Save DD Lead</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover shadow-sm" onClick={onSave}>Save DD Lead</Button>
</div>
</div>
</DialogContent>

View File

@ -36,7 +36,7 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
<CardTitle>DD-Leads (Dealer Development Lead)</CardTitle>
<CardDescription>Manage DD-Leads and their zonal assignments</CardDescription>
</div>
<Button onClick={onAddLead} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddLead} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add DD-Lead
</Button>
@ -60,7 +60,7 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
<TableRow key={lead.id}>
<TableCell>
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-amber-600" />
<Users className="w-4 h-4 text-re-red" />
<span className="font-medium">{lead.leadCode || 'N/A'}</span>
</div>
</TableCell>
@ -95,7 +95,7 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditLead(lead)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteLead(lead.id, lead.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteLead(lead.id, lead.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -193,7 +193,10 @@ export const DealerAsmAssignment: React.FC = () => {
</TableCell>
<TableCell>{dealer.dealerCode || 'N/A'}</TableCell>
<TableCell>
<Badge variant={String(dealer.status || '').toLowerCase() === 'active' ? 'default' : 'secondary'}>
<Badge
variant={String(dealer.status || '').toLowerCase() === 'active' ? 'default' : 'secondary'}
className={String(dealer.status || '').toLowerCase() === 'active' ? 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200' : ''}
>
{dealer.status || 'Unknown'}
</Badge>
</TableCell>
@ -215,7 +218,7 @@ export const DealerAsmAssignment: React.FC = () => {
onChange={(val) => setDraft((prev) => ({ ...prev, [dealer.dealerId]: val }))}
className="flex-1 min-w-[180px]"
/>
<Button size="sm" className="shrink-0" onClick={() => saveMapping(dealer.dealerId)}>
<Button size="sm" className="shrink-0 bg-re-red hover:bg-re-red-hover text-white" onClick={() => saveMapping(dealer.dealerId)}>
Assign
</Button>
</div>

View File

@ -179,7 +179,7 @@ export const DocumentConfigManagement: React.FC = () => {
if (metadataLoading) {
return (
<div className="h-96 flex flex-col items-center justify-center gap-4">
<Database className="w-10 h-10 text-amber-600 animate-bounce" />
<Database className="w-10 h-10 text-re-red animate-bounce" />
<p className="text-slate-500 font-bold uppercase tracking-widest text-xs">Connecting to Governance Engine...</p>
</div>
);
@ -189,8 +189,8 @@ export const DocumentConfigManagement: React.FC = () => {
<Card className="border-slate-200 shadow-sm overflow-hidden bg-white">
<CardHeader className="bg-slate-50/80 border-b border-slate-200 py-4 relative">
{backgroundLoading && (
<div className="absolute top-0 left-0 right-0 h-1 bg-amber-100 overflow-hidden">
<div className="h-full bg-amber-600 animate-[loading_1.5s_infinite_linear]" style={{width: '30%', transformOrigin: 'left'}} />
<div className="absolute top-0 left-0 right-0 h-1 bg-red-100 overflow-hidden">
<div className="h-full bg-re-red animate-[loading_1.5s_infinite_linear]" style={{width: '30%', transformOrigin: 'left'}} />
<style>{`
@keyframes loading {
0% { left: -30%; }
@ -202,7 +202,7 @@ export const DocumentConfigManagement: React.FC = () => {
<div className="flex flex-row items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-white rounded-xl shadow-sm ring-1 ring-slate-200 text-amber-600">
<div className="p-2 bg-white rounded-xl shadow-sm ring-1 ring-slate-200 text-re-red">
<Layers className="w-5 h-5" />
</div>
<div>
@ -218,7 +218,7 @@ export const DocumentConfigManagement: React.FC = () => {
<div className="flex gap-4">
<div className="w-64">
<Select value={selectedModule} onValueChange={(val) => { setSelectedModule(val); setPage(1); }}>
<SelectTrigger className="h-10 rounded-xl bg-white border-slate-200 focus:ring-amber-500 font-bold text-slate-700 shadow-sm">
<SelectTrigger className="h-10 rounded-xl bg-white border-slate-200 focus:ring-red-500 font-bold text-slate-700 shadow-sm">
<SelectValue placeholder="Target Module" />
</SelectTrigger>
<SelectContent className="rounded-xl shadow-2xl border-none">
@ -236,7 +236,7 @@ export const DocumentConfigManagement: React.FC = () => {
placeholder="Search policies, stages or documents..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="pl-10 h-10 rounded-xl bg-white border-slate-200 focus:ring-amber-500 shadow-sm font-medium"
className="pl-10 h-10 rounded-xl bg-white border-slate-200 focus:ring-red-500 shadow-sm font-medium"
/>
</div>
</div>
@ -244,7 +244,7 @@ export const DocumentConfigManagement: React.FC = () => {
<CardContent className="p-0 min-h-[400px] relative">
{loading ? (
<div className="absolute inset-0 z-10 bg-white/60 backdrop-blur-[1px] flex flex-col items-center justify-center gap-3">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
<span className="text-slate-500 text-sm font-bold animate-pulse">Syncing Policies...</span>
</div>
) : null}
@ -274,7 +274,7 @@ export const DocumentConfigManagement: React.FC = () => {
) : configs.map((config) => (
<TableRow key={config.id} className="hover:bg-slate-50/80 transition-colors group h-14">
<TableCell>
<div className="font-bold text-slate-900 group-hover:text-amber-700 transition-colors uppercase text-[12px]">{config.documentType}</div>
<div className="font-bold text-slate-900 group-hover:text-re-red-hover transition-colors uppercase text-[12px]">{config.documentType}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="bg-blue-50/50 border-blue-100 text-blue-700 font-bold px-2 py-0.5 whitespace-nowrap text-[10px] rounded-md uppercase">
@ -305,7 +305,7 @@ export const DocumentConfigManagement: React.FC = () => {
<TableCell>
<div className="flex gap-2">
{config.isMandatory && (
<Badge className="bg-red-600 text-white border-transparent text-[10px] font-bold h-5 px-1.5 rounded-sm uppercase tracking-tighter">BLOCKING</Badge>
<Badge className="bg-re-red text-white border-transparent text-[10px] font-bold h-5 px-1.5 rounded-sm uppercase tracking-tighter">BLOCKING</Badge>
)}
{!config.isActive && (
<Badge className="bg-slate-200 text-slate-500 border-transparent text-[10px] h-5 px-1.5 rounded-sm uppercase tracking-tighter">DORMANT</Badge>
@ -320,7 +320,7 @@ export const DocumentConfigManagement: React.FC = () => {
<Button variant="ghost" size="icon" onClick={() => openEdit(config)} className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-transform active:scale-90">
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => handleDelete(config.id)} className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-transform active:scale-90">
<Button variant="ghost" size="icon" onClick={() => handleDelete(config.id)} className="h-8 w-8 text-slate-400 hover:text-re-red hover:bg-red-50 rounded-lg transition-transform active:scale-90">
<Trash2 className="w-4 h-4" />
</Button>
</div>
@ -333,7 +333,7 @@ export const DocumentConfigManagement: React.FC = () => {
{/* Pagination Controls */}
<div className="flex items-center justify-between px-6 py-4 bg-slate-50/50 border-t border-slate-200 mt-auto">
<div className="text-[11px] text-slate-500 font-bold uppercase tracking-tight">
Dataset Index <span className="text-slate-900 border-b border-slate-300 mx-1">{configs.length > 0 ? (page - 1) * limit + 1 : 0} - {Math.min(page * limit, pagination.total)}</span> Total Found <span className="text-amber-700 font-extrabold ml-1">{pagination.total}</span>
Dataset Index <span className="text-slate-900 border-b border-slate-300 mx-1">{configs.length > 0 ? (page - 1) * limit + 1 : 0} - {Math.min(page * limit, pagination.total)}</span> Total Found <span className="text-re-red-hover font-extrabold ml-1">{pagination.total}</span>
</div>
<div className="flex gap-3 items-center">
<Button
@ -346,7 +346,7 @@ export const DocumentConfigManagement: React.FC = () => {
<ChevronLeft className="w-4 h-4 mr-1 text-slate-600" /> Prev
</Button>
<div className="flex items-center px-4 h-9 bg-white border border-slate-200 rounded-xl text-xs font-extrabold text-slate-800 shadow-inner">
<span className="text-amber-600">{page}</span> <span className="mx-2 text-slate-300">/</span> {pagination.pages}
<span className="text-re-red">{page}</span> <span className="mx-2 text-slate-300">/</span> {pagination.pages}
</div>
<Button
variant="outline"
@ -366,7 +366,7 @@ export const DocumentConfigManagement: React.FC = () => {
<DialogHeader className="bg-slate-900 text-white p-7">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/10 rounded-2xl backdrop-blur-md ring-1 ring-white/20">
<Settings2 className="w-7 h-7 text-amber-400" />
<Settings2 className="w-7 h-7 text-red-400" />
</div>
<div>
<DialogTitle className="text-2xl font-black tracking-tight uppercase">
@ -385,12 +385,12 @@ export const DocumentConfigManagement: React.FC = () => {
value={formData.module}
onValueChange={(val) => setFormData(prev => ({ ...prev, module: val, stageCode: 'General' }))}
>
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-amber-500 shadow-sm bg-slate-50 font-black text-xs uppercase">
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-red-500 shadow-sm bg-slate-50 font-black text-xs uppercase">
<SelectValue placeholder="Module" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
{modules.map(m => (
<SelectItem key={m} value={m} className="py-3 px-4 rounded-lg focus:bg-amber-50 font-black text-[10px] uppercase">
<SelectItem key={m} value={m} className="py-3 px-4 rounded-lg focus:bg-red-50 font-black text-[10px] uppercase">
{m.replace(/_/g, ' ')}
</SelectItem>
))}
@ -403,12 +403,12 @@ export const DocumentConfigManagement: React.FC = () => {
value={formData.stageCode}
onValueChange={(val) => setFormData(prev => ({ ...prev, stageCode: val }))}
>
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-amber-500 shadow-sm bg-white font-black text-xs uppercase">
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-red-500 shadow-sm bg-white font-black text-xs uppercase">
<SelectValue placeholder="Select Stage" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
{(stagesMap[formData.module] || ['General']).map(stage => (
<SelectItem key={stage} value={stage} className="py-3 px-4 rounded-lg focus:bg-amber-50 font-black text-[10px] uppercase">{stage}</SelectItem>
<SelectItem key={stage} value={stage} className="py-3 px-4 rounded-lg focus:bg-red-50 font-black text-[10px] uppercase">{stage}</SelectItem>
))}
</SelectContent>
</Select>
@ -421,28 +421,28 @@ export const DocumentConfigManagement: React.FC = () => {
value={formData.documentType}
onChange={(e) => setFormData(prev => ({ ...prev, documentType: e.target.value }))}
placeholder="e.g., PAN Card, Blueprint"
className="h-12 rounded-xl border-slate-200 focus:ring-amber-500 shadow-sm font-black text-sm uppercase placeholder:font-bold placeholder:text-slate-300"
className="h-12 rounded-xl border-slate-200 focus:ring-red-500 shadow-sm font-black text-sm uppercase placeholder:font-bold placeholder:text-slate-300"
/>
</div>
<div className="space-y-4 p-5 bg-slate-50 rounded-2xl border border-slate-100 shadow-inner">
<Label className="text-slate-900 font-black flex items-center gap-2 mb-2 text-[11px] uppercase tracking-wider">
<ShieldCheck className="w-4 h-4 text-amber-600" /> Visibility Matrix
<ShieldCheck className="w-4 h-4 text-re-red" /> Visibility Matrix
</Label>
<div className="grid grid-cols-3 gap-3">
{ROLE_LIST.map((role: string) => (
<div
key={role}
className={`flex items-center space-x-2 p-3 rounded-xl border transition-all cursor-pointer group active:scale-95 ${formData.allowedRoles.includes(role) ? 'bg-amber-50 border-amber-300 shadow-sm' : 'bg-white border-slate-200 hover:border-amber-200 hover:shadow-sm'}`}
className={`flex items-center space-x-2 p-3 rounded-xl border transition-all cursor-pointer group active:scale-95 ${formData.allowedRoles.includes(role) ? 'bg-red-50 border-red-300 shadow-sm' : 'bg-white border-slate-200 hover:border-red-200 hover:shadow-sm'}`}
onClick={() => toggleRole(role)}
>
<Checkbox
id={`role-${role}`}
checked={formData.allowedRoles.includes(role)}
onCheckedChange={() => toggleRole(role)}
className="w-4 h-4 data-[state=checked]:bg-amber-600 data-[state=checked]:border-amber-600 rounded"
className="w-4 h-4 data-[state=checked]:bg-re-red data-[state=checked]:border-re-red rounded"
/>
<Label htmlFor={`role-${role}`} className={`text-[10px] font-black cursor-pointer uppercase truncate ${formData.allowedRoles.includes(role) ? 'text-amber-800' : 'text-slate-500 group-hover:text-amber-700'}`}>{role}</Label>
<Label htmlFor={`role-${role}`} className={`text-[10px] font-black cursor-pointer uppercase truncate ${formData.allowedRoles.includes(role) ? 'text-red-800' : 'text-slate-500 group-hover:text-re-red-hover'}`}>{role}</Label>
</div>
))}
</div>
@ -457,7 +457,7 @@ export const DocumentConfigManagement: React.FC = () => {
id="mandatory"
checked={formData.isMandatory}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isMandatory: !!checked }))}
className="w-5 h-5 border-slate-300 data-[state=checked]:bg-red-600 data-[state=checked]:border-red-600 rounded-md"
className="w-5 h-5 border-slate-300 data-[state=checked]:bg-re-red data-[state=checked]:border-re-red rounded-md"
/>
<div className="space-y-0.5">
<Label htmlFor="mandatory" className="text-xs font-black text-slate-800 cursor-pointer group-hover:text-red-900 transition-colors uppercase tracking-tight">Mandatory Policy</Label>

View File

@ -89,9 +89,9 @@ export const EmailTemplateBodyEditor = React.forwardRef<EmailTemplateBodyEditorH
</TabsList>
{advanced && (
<Alert className="border-amber-200 bg-amber-50 py-2">
<Info className="h-4 w-4 text-amber-700" />
<AlertDescription className="text-[11px] text-amber-900">
<Alert className="border-red-200 bg-red-50 py-2">
<Info className="h-4 w-4 text-re-red-hover" />
<AlertDescription className="text-[11px] text-red-900">
This template uses layout partials (<code className="font-mono">{'{{> ...}}'}</code>), block helpers, or a full HTML
document. Edit it in <strong>HTML source</strong> so nothing is stripped. Use placeholders on the left to insert
fields safely.

View File

@ -45,8 +45,8 @@ export const EmailTemplates: React.FC<EmailTemplatesProps> = ({
<TableRow key={template.id}>
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-amber-50 rounded-lg flex items-center justify-center">
<Mail className="w-4 h-4 text-amber-600" />
<div className="w-8 h-8 bg-red-50 rounded-lg flex items-center justify-center">
<Mail className="w-4 h-4 text-re-red" />
</div>
<span className="font-medium text-slate-900">{template.name || template.templateCode}</span>
</div>
@ -68,7 +68,7 @@ export const EmailTemplates: React.FC<EmailTemplatesProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditTemplate(template)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteTemplate(template.id)} className="text-red-500 hover:text-red-600 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteTemplate(template.id)} className="text-red-500 hover:text-re-red hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -316,7 +316,7 @@ const InterviewConfigManagement: React.FC = () => {
</div>
</div>
) : (
<div className="mb-6 p-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-800">
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg text-red-800">
No active configuration found. Click "Publish New Version" to create one, or "Reset to Defaults" to initialize system defaults.
</div>
)}
@ -338,7 +338,7 @@ const InterviewConfigManagement: React.FC = () => {
<Button variant="ghost" size="sm" onClick={() => cfg.id && handleEdit(cfg.id)}>
<Edit3 size={14} />
</Button>
<Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700" onClick={() => cfg.id && handleDelete(cfg.id)}>
<Button variant="ghost" size="sm" className="text-re-red hover:text-re-red-hover" onClick={() => cfg.id && handleDelete(cfg.id)}>
<Trash2 size={14} />
</Button>
</div>
@ -366,7 +366,7 @@ const InterviewConfigManagement: React.FC = () => {
</DialogDescription>
</div>
{editingConfig?.configType === 'KT_MATRIX' && (
<div className={`px-3 py-1.5 rounded-md text-[10px] font-black uppercase tracking-tight ${totalWeight === 100 ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' : 'bg-amber-50 text-amber-700 border border-amber-100'}`}>
<div className={`px-3 py-1.5 rounded-md text-[10px] font-black uppercase tracking-tight ${totalWeight === 100 ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' : 'bg-red-50 text-re-red-hover border border-red-100'}`}>
Weight: {totalWeight}% / 100%
</div>
)}
@ -404,7 +404,7 @@ const InterviewConfigManagement: React.FC = () => {
</div>
{/* Items Area with Horizontal Guard */}
<div className="overflow-x-auto pb-4 -mx-1 px-1">
<div className="overflow-x-auto custom-scrollbar-x-slim pb-4 -mx-1 px-1">
<div className="min-w-[900px] border border-slate-100 rounded-xl">
<table className="w-full text-left border-collapse table-fixed">
<thead>
@ -449,9 +449,9 @@ const InterviewConfigManagement: React.FC = () => {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="select">Selection</SelectItem>
<SelectItem value="text">Text</SelectItem>
<SelectItem value="textarea">Comment</SelectItem>
<SelectItem value="select">Options</SelectItem>
<SelectItem value="text">One Liner</SelectItem>
<SelectItem value="textarea">Paragraph</SelectItem>
<SelectItem value="number">Numeric</SelectItem>
</SelectContent>
</Select>
@ -535,7 +535,7 @@ const InterviewConfigManagement: React.FC = () => {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between border-b border-slate-200/50 pb-2 mb-2">
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest flex items-center gap-2">
<div className="w-1 h-3 bg-amber-400 rounded-full" /> Selection Choices Profile
<div className="w-1 h-3 bg-red-400 rounded-full" /> Options
</p>
<Button variant="ghost" size="sm" className="h-7 px-3 text-[10px] font-bold uppercase text-slate-600 hover:bg-white border border-transparent hover:border-slate-200" onClick={() => addOption(index)}>
<Plus className="w-3 h-3 mr-1.5" /> Append Option

View File

@ -55,7 +55,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
setLocationDistrict('');
}}
>
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-red-500/30 focus:border-red-500">
<SelectValue placeholder="Select state" />
</SelectTrigger>
<SelectContent>
@ -71,7 +71,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">City</Label>
<Input
placeholder="Enter city name"
className="mt-2 text-slate-900 border-slate-200 focus-visible:ring-amber-500/30 focus-visible:border-amber-500"
className="mt-2 text-slate-900 border-slate-200 focus-visible:ring-red-500/30 focus-visible:border-red-500"
value={locationCity}
onChange={(e) => setLocationCity(e.target.value)}
/>
@ -79,7 +79,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<div>
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">District</Label>
<Select value={locationDistrict} onValueChange={setLocationDistrict} disabled={!locationState}>
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-red-500/30 focus:border-red-500">
<SelectValue placeholder={locationState ? 'Select district' : 'Select state first'} />
</SelectTrigger>
<SelectContent>
@ -120,7 +120,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<div>
<Label className="flex items-center gap-2 text-sm leading-none font-medium text-slate-700">Opportunity</Label>
<Select value={locationStatus} onValueChange={setLocationStatus}>
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-amber-500/30 focus:border-amber-500">
<SelectTrigger className="mt-2 text-slate-900 border-slate-200 focus:ring-red-500/30 focus:border-red-500">
<SelectValue placeholder="Is this an active Opportunity?" />
</SelectTrigger>
<SelectContent>
@ -132,7 +132,7 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700 text-white shadow-md hover:shadow-lg transition-all" onClick={onSave}>Save Location</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover text-white shadow-md hover:shadow-lg transition-all" onClick={onSave}>Save Location</Button>
</div>
</div>
</DialogContent>

View File

@ -53,7 +53,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
placeholder="Search locations..."
value={searchTerm}
onChange={(e) => onSearch(e.target.value)}
className="pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-amber-500 w-64 transition-all"
className="pl-9 pr-4 py-2 bg-slate-50 border border-slate-200 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-red-500 w-64 transition-all"
/>
</div>
@ -80,7 +80,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
</SelectContent>
</Select>
<Button onClick={onAddLocation} className="bg-amber-600 hover:bg-amber-700 whitespace-nowrap">
<Button onClick={onAddLocation} className="bg-re-red hover:bg-re-red-hover whitespace-nowrap">
<Plus className="w-4 h-4 mr-2" />
Add Location
</Button>
@ -112,7 +112,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
<TableRow key={district.id}>
<TableCell>
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-amber-600" />
<MapPin className="w-4 h-4 text-re-red" />
<span className="font-medium">{district.stateName || 'N/A'}</span>
</div>
</TableCell>
@ -151,7 +151,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
<Button variant="outline" size="sm" onClick={() => onEditLocation(district)} className="h-8 w-8 p-0">
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="outline" size="sm" onClick={() => onDeleteLocation(district.id)} className="h-8 w-8 p-0 text-red-600 hover:bg-red-50 hover:text-red-700">
<Button variant="outline" size="sm" onClick={() => onDeleteLocation(district.id)} className="h-8 w-8 p-0 text-re-red hover:bg-red-50 hover:text-re-red-hover">
<Trash2 className="w-4 h-4" />
</Button>
</div>
@ -163,7 +163,7 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
</Table>
{isAreasLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-white/10 backdrop-blur-[1px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-re-red"></div>
</div>
)}
</div>

View File

@ -189,7 +189,7 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
<div>
<Label>States Covered</Label>
{!selectedRegionZone && (
<p className="text-xs text-amber-600 mt-1">Select a zone first to see available states</p>
<p className="text-xs text-re-red mt-1">Select a zone first to see available states</p>
)}
<div className="mt-2 border rounded-lg p-3 max-h-40 overflow-y-auto bg-slate-50">
{statesForZone.length === 0 ? (
@ -234,7 +234,7 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
<TooltipProvider>
{districtsByState.map(({ stateName, districts }) => (
<div key={stateName} className="mb-4 last:mb-0">
<h4 className="text-xs font-semibold text-amber-700 uppercase tracking-wide mb-2 pb-1 border-b border-slate-200">
<h4 className="text-xs font-semibold text-re-red-hover uppercase tracking-wide mb-2 pb-1 border-b border-slate-200">
{stateName}
</h4>
<div className="space-y-2 ml-1">
@ -302,7 +302,7 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
{/* Actions */}
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save Regional Office</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save Regional Office</Button>
</div>
</div>
</DialogContent>

View File

@ -29,7 +29,7 @@ export const RegionalManagement: React.FC<RegionalManagementProps> = ({
<CardTitle>Regional Offices</CardTitle>
<CardDescription>Manage regional offices within zones</CardDescription>
</div>
<Button onClick={onAddRegion} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddRegion} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add Regional Office
</Button>
@ -114,7 +114,7 @@ export const RegionalManagement: React.FC<RegionalManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditRegion(region)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteRegion(region.id, region.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteRegion(region.id, region.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -79,8 +79,8 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
},
{
title: "Application Stage Access",
color: "from-amber-50 to-orange-50 border-amber-200",
textColor: "text-amber-900",
color: "from-red-50 to-orange-50 border-red-200",
textColor: "text-red-900",
permissions: [
{ id: "stage:initial_review", label: "Initial Review" },
{ id: "stage:field_verification", label: "Field Verification" },
@ -126,7 +126,7 @@ export const RoleDialog: React.FC<RoleDialogProps> = ({
<div className="space-y-5">
<h4 className="text-sm font-bold text-slate-800 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-amber-500 rounded-full"></span>
<span className="w-1.5 h-1.5 bg-red-500 rounded-full"></span>
Configure Default Permissions
</h4>

View File

@ -22,7 +22,7 @@ export const RolePermissions: React.FC<RolePermissionsProps> = ({ onAddRole, onE
<CardTitle className="leading-none text-xl font-bold">Role Definitions</CardTitle>
<CardDescription className="text-muted-foreground mt-1.5">Overview of available roles and their access levels</CardDescription>
</div>
<Button onClick={onAddRole} className="bg-amber-600 hover:bg-amber-700 h-9">
<Button onClick={onAddRole} className="bg-re-red hover:bg-re-red-hover h-9">
<Plus className="w-4 h-4 mr-2" />
Add Role
</Button>
@ -34,7 +34,7 @@ export const RolePermissions: React.FC<RolePermissionsProps> = ({ onAddRole, onE
<div key={role.id} className="border rounded-lg p-4 space-y-3 bg-gradient-to-br from-white to-slate-50 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600" />
<Shield className="w-5 h-5 text-re-red" />
<h3 className="text-slate-900 font-bold">{role.name}</h3>
</div>
<Badge variant="secondary" className="border-transparent bg-secondary text-secondary-foreground text-xs font-medium">

View File

@ -29,7 +29,7 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
<div key={sla.id} className="border rounded-lg p-4 bg-gradient-to-br from-white to-slate-50 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-amber-600" />
<Clock className="w-5 h-5 text-re-red" />
<div>
<h4 className="text-slate-900 font-medium">{sla.activityName}</h4>
<p className="text-xs text-slate-600">TAT: {sla.tatHours} {sla.tatUnit}</p>
@ -73,7 +73,7 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
<div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<AlertTriangle className="w-4 h-4 text-re-red" />
<span className="text-xs text-slate-700">Escalations ({sla.escalationConfigs?.length || 0})</span>
</div>
<div className="space-y-1">

View File

@ -184,6 +184,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
<div className="flex items-center space-x-2 pt-8">
<Switch
id="isActive"
className="data-[state=checked]:bg-re-red"
checked={formData.isActive}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
@ -252,7 +253,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
<div className="space-y-4">
<div className="flex items-center justify-between border-b pb-2">
<div className="flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<AlertTriangle className="w-4 h-4 text-re-red" />
<h4 className="font-medium text-sm">Escalation Levels</h4>
</div>
<Button type="button" variant="outline" size="sm" onClick={handleAddEscalation} className="h-7 text-xs">
@ -264,7 +265,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
{formData.escalationConfigs.map((esc: any, idx: number) => (
<div key={idx} className="bg-slate-50 p-3 rounded-lg border border-slate-100 space-y-3">
<div className="flex items-center justify-between">
<Badge variant="outline" className="bg-red-50 text-red-700 border-red-100">
<Badge variant="outline" className="bg-red-50 text-re-red-hover border-red-100">
Level {esc.level}
</Badge>
<Button
@ -345,7 +346,7 @@ export const SLADialog: React.FC<SLADialogProps> = ({ isOpen, onClose, sla, onSa
<Button type="button" variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" disabled={loading}>
<Button type="submit" disabled={loading} className="bg-re-red hover:bg-re-red-hover text-white">
{loading ? 'Saving...' : 'Save Configuration'}
</Button>
</DialogFooter>

View File

@ -0,0 +1,627 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Activity,
AlertTriangle,
BarChart3,
CheckCircle2,
Clock,
Download,
ExternalLink,
Mail,
RefreshCw,
Server,
Timer
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
import {
slaService,
SlaBucket,
SlaOperationsDashboard,
SlaQueueItem,
QuestionnaireReminderSettings
} from '@/services/sla.service';
const BUCKET_LABEL: Record<SlaBucket, string> = {
healthy: '025% elapsed',
warning: '2675% elapsed',
critical: '7699% elapsed',
breached: 'Breached'
};
const BUCKET_CLASS: Record<SlaBucket, string> = {
healthy: 'bg-emerald-100 text-emerald-800 border-emerald-200',
warning: 'bg-red-50 text-red-800 border-red-200',
critical: 'bg-orange-100 text-orange-800 border-orange-200',
breached: 'bg-red-100 text-red-800 border-red-200'
};
const MODULES = ['ALL', 'ONBOARDING', 'TERMINATION', 'RESIGNATION', 'RELOCATION', 'CONSTITUTIONAL', 'FNF'];
function BucketBadge({ bucket }: { bucket: SlaBucket }) {
return (
<Badge variant="outline" className={`text-[10px] font-semibold ${BUCKET_CLASS[bucket]}`}>
{BUCKET_LABEL[bucket]}
</Badge>
);
}
function ProgressCell({ percent, bucket }: { percent: number; bucket: SlaBucket }) {
const barColor =
bucket === 'breached'
? 'bg-red-500'
: bucket === 'critical'
? 'bg-orange-500'
: bucket === 'warning'
? 'bg-red-500'
: 'bg-emerald-500';
const capped = Math.min(percent, 100);
return (
<div className="min-w-[100px]">
<div className="h-1.5 w-full bg-slate-100 rounded-full overflow-hidden mb-1">
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${capped}%` }} />
</div>
<span className="text-[10px] text-slate-500">{percent}% of TAT</span>
<div className="mt-1">
<BucketBadge bucket={bucket} />
</div>
</div>
);
}
function QueueTable({ items, emptyMessage }: { items: SlaQueueItem[]; emptyMessage: string }) {
if (items.length === 0) {
return <p className="text-sm text-slate-500 py-8 text-center">{emptyMessage}</p>;
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>Case</TableHead>
<TableHead>Module</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Owner</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Time</TableHead>
<TableHead className="w-10" />
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.trackingId} className={row.bucket === 'breached' ? 'bg-red-50/40' : undefined}>
<TableCell className="font-medium text-slate-900">{row.caseRef}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">
{row.module}
</Badge>
</TableCell>
<TableCell className="text-slate-700 max-w-[200px] truncate" title={row.stageName}>
{row.stageName}
</TableCell>
<TableCell className="text-slate-600 text-xs">{row.ownerRole}</TableCell>
<TableCell>
<ProgressCell percent={row.percentUsed} bucket={row.bucket} />
</TableCell>
<TableCell className="text-xs text-slate-600">
<div>{row.remainingLabel}</div>
<span className="text-[10px] text-slate-400 block">
Due {new Date(row.deadline).toLocaleString()}
</span>
</TableCell>
<TableCell>
<a
href={row.link}
target="_blank"
rel="noopener noreferrer"
className="text-re-red hover:text-red-800"
title="Open case"
>
<ExternalLink className="w-4 h-4" />
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
export const SLAMonitorPanel: React.FC = () => {
const [data, setData] = useState<SlaOperationsDashboard | null>(null);
const [loading, setLoading] = useState(true);
const [moduleFilter, setModuleFilter] = useState('ALL');
const [tab, setTab] = useState('queue');
const [mineOnly, setMineOnly] = useState(false);
const [qSettings, setQSettings] = useState<QuestionnaireReminderSettings | null>(null);
const [qDraft, setQDraft] = useState<Partial<QuestionnaireReminderSettings>>({});
const [savingQ, setSavingQ] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await slaService.getOperationsDashboard({
module: moduleFilter === 'ALL' ? undefined : moduleFilter,
mineOnly
});
if (res?.success) {
setData(res.data);
} else {
toast.error('Failed to load SLA monitor');
}
} catch {
toast.error('Failed to load SLA monitor');
} finally {
setLoading(false);
}
}, [moduleFilter, tab, mineOnly]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
slaService
.getQuestionnaireReminderSettings()
.then((res) => {
if (res?.success && res.data) {
setQSettings(res.data);
setQDraft(res.data);
}
})
.catch(() => undefined);
}, []);
const saveQuestionnaireSettings = async () => {
setSavingQ(true);
try {
const res = await slaService.updateQuestionnaireReminderSettings(qDraft);
if (res?.success) {
setQSettings(res.data);
setQDraft(res.data);
toast.success('Questionnaire reminder settings saved');
}
} catch {
toast.error('Failed to save settings');
} finally {
setSavingQ(false);
}
};
const summary = data?.summary;
const analytics = data?.analytics;
const queue = data?.activeQueue ?? [];
const breachedOnly = queue.filter((q) => q.bucket === 'breached');
const dueSoon = queue.filter((q) => q.bucket === 'warning' || q.bucket === 'critical');
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Activity className="w-5 h-5 text-re-red" />
SLA Operations Monitor
</h2>
<p className="text-sm text-slate-500">
Live queue, aging buckets, breaches, and scheduler health
{data?.generatedAt && (
<span className="ml-2 text-slate-400">
· Updated {new Date(data.generatedAt).toLocaleTimeString()}
</span>
)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-2 mr-2">
<Checkbox id="sla-mine-only" checked={mineOnly} onCheckedChange={(v) => setMineOnly(Boolean(v))} />
<Label htmlFor="sla-mine-only" className="text-sm cursor-pointer">
My queue only
</Label>
</div>
<Select value={moduleFilter} onValueChange={setModuleFilter}>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Module" />
</SelectTrigger>
<SelectContent>
{MODULES.map((m) => (
<SelectItem key={m} value={m}>
{m === 'ALL' ? 'All modules' : m}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() =>
slaService
.exportOperationsCsv({
module: moduleFilter === 'ALL' ? undefined : moduleFilter,
mineOnly
})
.catch(() => toast.error('Export failed'))
}
>
<Download className="w-4 h-4 mr-1" />
Export CSV
</Button>
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-3">
<SummaryCard label="In queue" value={summary?.activeCount ?? '—'} icon={<Clock className="w-4 h-4 text-blue-600" />} />
<SummaryCard
label="Breached"
value={summary?.breachedCount ?? '—'}
icon={<AlertTriangle className="w-4 h-4 text-red-600" />}
highlight="red"
/>
<SummaryCard label="Due soon" value={summary?.dueSoonCount ?? '—'} icon={<Timer className="w-4 h-4 text-re-red" />} />
<SummaryCard
label="On track"
value={summary?.onTrackCount ?? '—'}
icon={<CheckCircle2 className="w-4 h-4 text-emerald-600" />}
/>
<SummaryCard
label="Open breaches"
value={summary?.openBreachesCount ?? '—'}
icon={<AlertTriangle className="w-4 h-4 text-re-red" />}
/>
</div>
{analytics && (
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-re-red" />
Analytics (last {analytics.periodDays} days)
</CardTitle>
<CardDescription>Breach rate, resolution time, and top delayed stages</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<AnalyticsStat label="Tracks started" value={analytics.tracksStarted} />
<AnalyticsStat label="Breach rate" value={`${analytics.breachRatePercent}%`} highlight />
<AnalyticsStat
label="Avg resolution"
value={analytics.avgResolutionHours != null ? `${analytics.avgResolutionHours}h` : '—'}
/>
<AnalyticsStat label="Completed tracks" value={analytics.completedTracks} />
</div>
{analytics.topDelayedStages.length > 0 && (
<div>
<p className="text-xs font-semibold text-slate-500 uppercase mb-2">Top delayed stages</p>
<Table>
<TableHeader>
<TableRow>
<TableHead>Stage</TableHead>
<TableHead>Breaches (30d)</TableHead>
<TableHead>Active breached</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{analytics.topDelayedStages.map((row) => (
<TableRow key={row.stageName}>
<TableCell className="font-medium">{row.stageName}</TableCell>
<TableCell>{row.breachCount}</TableCell>
<TableCell>{row.currentlyBreached}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{Object.keys(analytics.breachesByModule).length > 0 && (
<div className="flex flex-wrap gap-2">
{Object.entries(analytics.breachesByModule).map(([mod, count]) => (
<Badge key={mod} variant="outline">
{mod}: {count} breaches
</Badge>
))}
</div>
)}
</CardContent>
</Card>
)}
{summary && (
<Card className="border-slate-200">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Aging buckets</CardTitle>
<CardDescription>Percent of configured TAT elapsed</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{(Object.keys(BUCKET_LABEL) as SlaBucket[]).map((b) => (
<Badge key={b} variant="outline" className={`px-3 py-1 ${BUCKET_CLASS[b]}`}>
{BUCKET_LABEL[b]}: {summary.buckets[b] ?? 0}
</Badge>
))}
{summary.tracksWithoutConfig > 0 && (
<Badge variant="outline" className="bg-slate-100 text-slate-600">
No config match: {summary.tracksWithoutConfig}
</Badge>
)}
</CardContent>
</Card>
)}
<Tabs value={tab} onValueChange={setTab}>
<TabsList className="bg-slate-100">
<TabsTrigger value="queue">Active queue ({queue.length})</TabsTrigger>
<TabsTrigger value="breached">Breached ({breachedOnly.length})</TabsTrigger>
<TabsTrigger value="due">Due soon ({dueSoon.length})</TabsTrigger>
<TabsTrigger value="breach-log">Open breaches ({data?.breaches?.length ?? 0})</TabsTrigger>
<TabsTrigger value="schedulers">Schedulers</TabsTrigger>
</TabsList>
<TabsContent value="queue" className="mt-4">
<Card>
<CardContent className="pt-4">
<QueueTable items={queue} emptyMessage="No active SLA timers in queue." />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="breached" className="mt-4">
<Card>
<CardContent className="pt-4">
<QueueTable items={breachedOnly} emptyMessage="No breached items — all active SLAs are within TAT." />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="due" className="mt-4">
<Card>
<CardContent className="pt-4">
<QueueTable items={dueSoon} emptyMessage="Nothing in the 2699% window right now." />
</CardContent>
</Card>
</TabsContent>
<TabsContent value="breach-log" className="mt-4">
<Card>
<CardContent className="pt-4">
{!data?.breaches?.length ? (
<p className="text-sm text-slate-500 py-8 text-center">No open breach records.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Case</TableHead>
<TableHead>Module</TableHead>
<TableHead>Stage</TableHead>
<TableHead>Breached at</TableHead>
<TableHead>Status</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{data.breaches.map((b) => (
<TableRow key={b.id}>
<TableCell className="font-medium">{b.caseRef}</TableCell>
<TableCell>
<Badge variant="secondary" className="text-[10px]">
{b.module}
</Badge>
</TableCell>
<TableCell className="truncate max-w-[200px]">{b.stageName}</TableCell>
<TableCell className="text-xs">{new Date(b.breachedAt).toLocaleString()}</TableCell>
<TableCell>
<Badge variant="outline">{b.status}</Badge>
</TableCell>
<TableCell>
<a href={b.link} target="_blank" rel="noopener noreferrer" className="text-re-red">
<ExternalLink className="w-4 h-4" />
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="schedulers" className="mt-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Mail className="w-4 h-4" />
Prospect questionnaire reminders
</CardTitle>
<CardDescription>
Email/WhatsApp to applicants in Questionnaire Pending (not internal SLA)
{qSettings?.source && (
<span className="block text-slate-400">Source: {qSettings.source}</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<Checkbox
id="q-enabled"
checked={Boolean(qDraft.enabled)}
onCheckedChange={(v) => setQDraft((d) => ({ ...d, enabled: Boolean(v) }))}
/>
<Label htmlFor="q-enabled">Scheduler enabled</Label>
</div>
<div className="grid grid-cols-3 gap-2">
<div>
<Label className="text-xs">First after (days)</Label>
<Input
type="number"
min={1}
value={qDraft.firstAfterDays ?? ''}
onChange={(e) =>
setQDraft((d) => ({ ...d, firstAfterDays: Number(e.target.value) }))
}
/>
</div>
<div>
<Label className="text-xs">Interval (days)</Label>
<Input
type="number"
min={1}
value={qDraft.intervalDays ?? ''}
onChange={(e) =>
setQDraft((d) => ({ ...d, intervalDays: Number(e.target.value) }))
}
/>
</div>
<div>
<Label className="text-xs">Max reminders</Label>
<Input
type="number"
min={1}
value={qDraft.maxCount ?? ''}
onChange={(e) =>
setQDraft((d) => ({ ...d, maxCount: Number(e.target.value) }))
}
/>
</div>
</div>
<Button size="sm" onClick={saveQuestionnaireSettings} disabled={savingQ}>
{savingQ ? 'Saving…' : 'Save reminder settings'}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm flex items-center gap-2">
<Server className="w-4 h-4" />
Infrastructure
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<StatusRow label="Redis" value={data?.scheduler?.redisEnabled ? 'Enabled' : 'Disabled'} ok={data?.scheduler?.redisEnabled} />
<StatusRow label="SLA fast mode" value={data?.scheduler?.slaFastMode ? 'On' : 'Off'} ok={!data?.scheduler?.slaFastMode} />
<StatusRow
label="Questionnaire fast mode"
value={data?.scheduler?.questionnaireFastMode ? 'On' : 'Off'}
ok={!data?.scheduler?.questionnaireFastMode}
/>
</CardContent>
</Card>
{(data?.scheduler?.queues ?? []).map((q) => (
<Card key={q.name}>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{q.name}</CardTitle>
{q.key && <CardDescription>{q.key}</CardDescription>}
</CardHeader>
<CardContent className="text-sm">
{q.error ? (
<p className="text-red-600">{q.error}</p>
) : (
<>
{q.counts && (
<div className="flex flex-wrap gap-2 mb-3">
{Object.entries(q.counts).map(([k, v]) => (
<Badge key={k} variant="outline">
{k}: {v}
</Badge>
))}
</div>
)}
{q.repeatable?.length ? (
<ul className="text-xs text-slate-600 space-y-1">
{q.repeatable.map((r, i) => (
<li key={i}>
{r.name || r.pattern}
{r.next ? ` · next ${new Date(r.next).toLocaleString()}` : ''}
</li>
))}
</ul>
) : (
<p className="text-slate-400 text-xs">No repeatable jobs registered</p>
)}
</>
)}
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
</div>
);
};
function AnalyticsStat({
label,
value,
highlight
}: {
label: string;
value: string | number;
highlight?: boolean;
}) {
return (
<div
className={`rounded-lg border p-3 ${highlight ? 'border-red-200 bg-red-50/40' : 'border-slate-200 bg-slate-50/50'}`}
>
<p className="text-[10px] uppercase tracking-wide text-slate-500">{label}</p>
<p className="text-xl font-bold text-slate-900">{value}</p>
</div>
);
}
function SummaryCard({
label,
value,
icon,
highlight
}: {
label: string;
value: number | string;
icon: React.ReactNode;
highlight?: 'red';
}) {
return (
<Card className={highlight === 'red' ? 'border-red-200 bg-red-50/30' : 'border-slate-200'}>
<CardContent className="pt-4 pb-3">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-slate-500 uppercase tracking-wide">{label}</p>
<p className="text-2xl font-bold text-slate-900">{value}</p>
</div>
{icon}
</div>
</CardContent>
</Card>
);
}
function StatusRow({ label, value, ok }: { label: string; value: string; ok?: boolean }) {
return (
<div className="flex justify-between">
<span className="text-slate-600">{label}</span>
<span className={ok === false ? 'text-re-red-hover font-medium' : 'text-slate-900'}>{value}</span>
</div>
);
}

View File

@ -54,7 +54,7 @@ export const SecurityDepositMaster: React.FC = () => {
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-20 space-y-4">
<div className="w-10 h-10 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<div className="w-10 h-10 border-4 border-re-red border-t-transparent rounded-full animate-spin"></div>
<p className="text-slate-600 animate-pulse">Loading settings...</p>
</div>
);
@ -65,15 +65,15 @@ export const SecurityDepositMaster: React.FC = () => {
<Card className="border-none shadow-lg bg-white/80 backdrop-blur-md">
<CardHeader className="py-4 border-b bg-slate-50/50">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-amber-100 flex items-center justify-center">
<Settings className="w-5 h-5 text-amber-600" />
<div className="w-8 h-8 rounded-lg bg-red-100 flex items-center justify-center">
<Settings className="w-5 h-5 text-re-red" />
</div>
<div>
<CardTitle className="text-lg font-bold text-slate-900">
Global Payment Settings
</CardTitle>
<CardDescription className="text-xs">
Configure base security deposit amounts for onboarding workflows.
Configure base Security Deposit amounts for onboarding workflows.
</CardDescription>
</div>
</div>
@ -98,7 +98,7 @@ export const SecurityDepositMaster: React.FC = () => {
<Input
id={`amount-${config.id}`}
type="number"
className="pl-8 h-9 text-base font-bold bg-slate-50/50 border-slate-200 focus:ring-amber-500 focus:border-amber-500 rounded-lg"
className="pl-8 h-9 text-base font-bold bg-slate-50/50 border-slate-200 focus:ring-red-500 focus:border-red-500 rounded-lg"
value={config.value?.amount || ''}
onChange={(e) => handleUpdateAmount(config.id, e.target.value)}
/>
@ -109,7 +109,7 @@ export const SecurityDepositMaster: React.FC = () => {
<Button
onClick={() => handleSave(config)}
disabled={isSaving === config.id}
className="h-9 px-4 bg-amber-600 hover:bg-amber-700 text-white rounded-lg shadow-md shadow-amber-600/10 active:scale-95 transition-all flex items-center gap-1.5 group"
className="h-9 px-4 bg-re-red hover:bg-re-red-hover text-white rounded-lg shadow-md shadow-re-red/10 active:scale-95 transition-all flex items-center gap-1.5 group"
>
{isSaving === config.id ? (
<RefreshCw className="w-4 h-4 animate-spin" />
@ -140,13 +140,13 @@ export const SecurityDepositMaster: React.FC = () => {
</CardContent>
</Card>
<div className="bg-amber-50/50 rounded-xl p-4 border border-amber-100/50 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<Settings className="w-4 h-4 text-amber-700" />
<div className="bg-red-50/50 rounded-xl p-4 border border-red-100/50 flex items-start gap-3">
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
<Settings className="w-4 h-4 text-re-red-hover" />
</div>
<div>
<h5 className="font-bold text-amber-900 text-sm">Super Admin Notice</h5>
<p className="text-[11px] text-amber-800/80 leading-snug">
<h5 className="font-bold text-red-900 text-sm">Super Admin Notice</h5>
<p className="text-[11px] text-red-800/80 leading-snug">
Updates made here take immediate effect. These values define the default expected amounts for all current and future onboarding payments.
</p>
</div>

View File

@ -96,7 +96,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 space-y-4">
<h3 className="text-sm font-semibold text-slate-800 flex items-center gap-2">
<Settings className="w-4 h-4 text-amber-600" />
<Settings className="w-4 h-4 text-re-red" />
General Settings
</h3>
<div>
@ -149,12 +149,12 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</div>
</div>
<div className="bg-amber-50 p-4 rounded-lg border border-amber-100">
<h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2 mb-3">
<div className="bg-red-50 p-4 rounded-lg border border-red-100">
<h3 className="text-sm font-semibold text-red-900 flex items-center gap-2 mb-3">
<Info className="w-4 h-4" />
Available Placeholders
</h3>
<p className="text-[10px] text-amber-700 mb-4 leading-relaxed">
<p className="text-[10px] text-re-red-hover mb-4 leading-relaxed">
Click a placeholder to insert it at the cursor.
</p>
<div className="flex flex-wrap gap-2">
@ -164,14 +164,14 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
key={p}
type="button"
onClick={() => insertPlaceholder(p)}
className="px-2 py-1 bg-white border border-amber-200 rounded text-[11px] font-mono text-amber-800 hover:bg-amber-600 hover:text-white hover:border-amber-600 transition-all flex items-center gap-1 shadow-sm"
className="px-2 py-1 bg-white border border-red-200 rounded text-[11px] font-mono text-red-800 hover:bg-re-red hover:text-white hover:border-re-red transition-all flex items-center gap-1 shadow-sm"
>
{`{{${p}}}`}
</button>
))
) : (
<div className="w-full py-4 text-center border-2 border-dashed border-amber-200 rounded-lg">
<p className="text-[10px] text-amber-600">No placeholders defined for this trigger</p>
<div className="w-full py-4 text-center border-2 border-dashed border-red-200 rounded-lg">
<p className="text-[10px] text-re-red">No placeholders defined for this trigger</p>
</div>
)}
</div>
@ -182,7 +182,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="lg:col-span-4 space-y-4 flex flex-col h-full min-h-[600px]">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<Edit2 className="w-4 h-4 text-amber-600" />
<Edit2 className="w-4 h-4 text-re-red" />
Template Designer
</h3>
</div>
@ -230,7 +230,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
'{"applicantName": "Rajesh Kumar", "location": "Mumbai South", "applicationId": "APP-2026-X12", "link": "https://example.com/app", "portalLink": "https://example.com/app"}'
)
}
className="text-[10px] text-amber-600 hover:underline"
className="text-[10px] text-re-red hover:underline"
>
Reset to Sample
</button>
@ -302,7 +302,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
Cancel
</Button>
<Button
className="flex-1 bg-amber-600 hover:bg-amber-700"
className="flex-1 bg-re-red hover:bg-re-red-hover"
type="button"
onClick={() => handleSaveTemplate(composeFullBody())}
disabled={!editingTemplate?.id || !editingTemplate?.templateCode?.trim()}

View File

@ -44,7 +44,7 @@ export const UserManagementTable: React.FC<UserManagementTableProps> = ({ userAs
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Shield className="w-3 h-3 text-amber-600" />
<Shield className="w-3 h-3 text-re-red" />
<span className="text-sm font-medium">{user.role}</span>
</div>
</TableCell>

View File

@ -146,7 +146,7 @@ export const ZMDialog: React.FC<ZMDialogProps> = ({
<div className="flex gap-3 pt-6">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save Zonal Manager</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save Zonal Manager</Button>
</div>
</div>
</DialogContent>

View File

@ -33,7 +33,7 @@ export const ZMManagement: React.FC<ZMManagementProps> = ({
<CardTitle>Zonal Managers (DD-ZM)</CardTitle>
<CardDescription>Manage Zonal Managers and their region assignments</CardDescription>
</div>
<Button onClick={onAddZM} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddZM} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add ZM
</Button>
@ -99,7 +99,7 @@ export const ZMManagement: React.FC<ZMManagementProps> = ({
<Button variant="ghost" size="sm" onClick={() => onEditZM(zm)}>
<Edit2 className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => onDeleteZM(zm.id, zm.name)} className="text-red-600 hover:text-red-700 hover:bg-red-50">
<Button variant="ghost" size="sm" onClick={() => onDeleteZM(zm.id, zm.name)} className="text-re-red hover:text-re-red-hover hover:bg-red-50">
<Trash2 className="w-4 h-4" />
</Button>
</div>

View File

@ -27,7 +27,7 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
<CardTitle>Zone Details</CardTitle>
<CardDescription>Geographical coverage and state mappings for each zone</CardDescription>
</div>
<Button onClick={onAddZone} className="bg-amber-600 hover:bg-amber-700">
<Button onClick={onAddZone} className="bg-re-red hover:bg-re-red-hover">
<Plus className="w-4 h-4 mr-2" />
Add Zone
</Button>
@ -40,7 +40,7 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
<div key={zone.id} className="border rounded-lg p-5 space-y-4 bg-gradient-to-br from-white to-slate-50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-amber-500 to-amber-600 rounded-lg flex items-center justify-center shadow-md">
<div className="w-12 h-12 bg-gradient-to-br from-red-500 to-re-red rounded-lg flex items-center justify-center shadow-md">
<Globe className="w-6 h-6 text-white" />
</div>
<div>
@ -86,11 +86,11 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
<Label className="text-xs text-slate-600 mb-2 block">
Zonal Business Head (ZBH)
</Label>
<div className="bg-amber-50 border border-amber-100 rounded-lg p-3 space-y-2">
<div className="bg-red-50 border border-red-100 rounded-lg p-3 space-y-2">
<div className="flex items-center gap-2">
<Shield className="w-4 h-4 text-amber-600" />
<Shield className="w-4 h-4 text-re-red" />
<span className="text-sm font-semibold text-slate-900">{zone.zonalBusinessHead.name}</span>
<Badge className="bg-amber-600 text-white text-[10px] ml-auto">ZBH</Badge>
<Badge className="bg-re-red text-white text-[10px] ml-auto">ZBH</Badge>
</div>
<div className="flex items-center gap-2 ml-6 text-slate-600">

View File

@ -106,7 +106,7 @@ export const ZoneDialog: React.FC<ZoneDialogProps> = ({
</div>
<div className="flex gap-3 pt-4">
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={onSave}>Save Zone</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover" onClick={onSave}>Save Zone</Button>
</div>
</div>
</DialogContent>

View File

@ -19,14 +19,14 @@ export const ZonesOverview: React.FC<ZonesOverviewProps> = ({ selectedZone, onZo
return (
<Card
key={zone.id}
className={`border-2 transition-all cursor-pointer ${selectedZone === zone.id ? 'border-amber-600 shadow-lg' : 'hover:border-amber-400'
className={`border-2 transition-all cursor-pointer ${selectedZone === zone.id ? 'border-re-red shadow-lg' : 'hover:border-red-400'
}`}
onClick={() => onZoneClick(zone.id)}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Globe className="w-5 h-5 text-amber-600" />
<Globe className="w-5 h-5 text-re-red" />
<CardTitle className="text-lg">{zone.name.toUpperCase().endsWith('ZONE') ? zone.name : `${zone.name} Zone`}</CardTitle>
</div>
<Badge variant="outline" className="text-xs">{zone.code}</Badge>

View File

@ -1,33 +1,67 @@
/**
* Allowed email template triggers keep aligned with backend
* Dealer_Onboarding_Backend/src/constants/allowed-email-template-codes.ts and seed-master-emails.
* `backend/src/constants/allowed-email-template-codes.ts` and `seed-master-emails.ts`.
*/
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'APPLICANT_SHORTLISTED',
'APPLICANT_REJECTED',
'ARCHITECTURAL_PLAN_REQUEST',
'CONSTITUTIONAL_CHANGE_SUBMITTED',
'DEALERSHIP_AGREEMENT_SIGNATURE_REQUEST',
'CONSTITUTIONAL_CHANGE_APPROVED',
'CONSTITUTIONAL_CHANGE_UPDATE',
'DEALER_CODE_READY',
'DOCUMENT_RECEIVED_ACKNOWLEDGEMENT',
'DOCUMENT_REJECTED_RESUBMIT',
'DOCUMENT_SUBMISSION_REMINDER',
'EOR_COMPLETED',
'FDD_DOCUMENT_REQUEST',
'FNF_INITIATED',
'FNF_LWD_READY',
'FNF_SUMMARY_PREPARED',
'FNF_SETTLEMENT_APPROVED',
'GENERIC_NOTIFICATION',
'INAUGURATION_COMPLETED',
'INTERVIEW_SCHEDULED',
'INTERVIEW_SCHEDULED_APPLICANT',
'INTERVIEW_SCHEDULED_PANELIST',
'INTERVIEW_RESCHEDULED_APPLICANT',
'INTERVIEW_RESCHEDULED_PANELIST',
'INTERVIEW_CANCELLED_APPLICANT',
'INTERVIEW_CANCELLED_PANELIST',
'LOA_ISSUED',
'LOI_ACKNOWLEDGEMENT_REQUEST',
'LOI_ISSUED',
'NON_OPPORTUNITY',
'ONBOARDING_PAYMENT_VERIFIED',
'ONBOARDING_STATUS_UPDATE',
'OPPORTUNITY',
'PROSPECT_DOCUMENT_REQUEST',
'QUESTIONNAIRE_REMINDER',
'QUESTIONNAIRE_SUBMITTED',
'SECURITY_DEPOSIT_REQUEST',
'RELOCATION_RECEIVED',
'RELOCATION_SUBMITTED',
'RELOCATION_APPROVED',
'RELOCATION_UPDATE',
'RESIGNATION_APPROVED',
'RESIGNATION_RECEIVED',
'RESIGNATION_SUBMITTED',
'RESIGNATION_UPDATE',
'SLA_BREACH_WARNING',
'STATUTORY_DOCUMENT_REQUEST',
'SLA_REMINDER',
'SLA_BREACH',
'SLA_ESCALATION',
'TERMINATION_INITIATED',
'TERMINATION_SCN_ISSUED',
'TERMINATION_LETTER_ISSUED',
'TERMINATION_FINAL_CLOSURE_DEALER',
'TERMINATION_UPDATE',
'USER_ASSIGNED',
'WORKNOTE_NOTIFICATION'
'WORKNOTE_NOTIFICATION',
'WORKFLOW_ACTION_REQUIRED',
'WORKFLOW_STATUS_UPDATE_DEALER'
] as const;
const ALLOWED_SET = new Set<string>(ALLOWED_EMAIL_TEMPLATE_CODES);

View File

@ -4,7 +4,6 @@ import {
Tabs, TabsContent, TabsList, TabsTrigger
} from '@/components/ui/tabs';
import { Globe, Shield, Mail, MapPin, SlidersHorizontal, Settings, FileText, Settings2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner';
// Services & Hooks
@ -438,39 +437,38 @@ export const MasterPage: React.FC = () => {
<h1 className="text-slate-900 mb-2 font-bold text-2xl">Master Configuration</h1>
<p className="text-slate-600">Centralized governance for locations, roles, and operational policies</p>
</div>
<Badge className="bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-1">Admin Control Panel</Badge>
</div>
{loading ? (
<div className="flex flex-col items-center justify-center p-20 space-y-4">
<div className="w-12 h-12 border-4 border-amber-600 border-t-transparent rounded-full animate-spin"></div>
<div className="w-12 h-12 border-4 border-re-red border-t-transparent rounded-full animate-spin"></div>
<p className="text-slate-600 font-medium animate-pulse">Synchronizing Global Settings...</p>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
<TabsList className="grid w-full grid-cols-8 h-auto sticky top-0 z-10 bg-white/80 backdrop-blur-sm border shadow-sm rounded-xl p-1">
<TabsTrigger value="hierarchy" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="hierarchy" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<Globe className="w-4 h-4" /> Organisation
</TabsTrigger>
<TabsTrigger value="roles" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="roles" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<Shield className="w-4 h-4" /> Roles
</TabsTrigger>
<TabsTrigger value="templates" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="templates" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<Mail className="w-4 h-4" /> Emails
</TabsTrigger>
<TabsTrigger value="locations" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white">
<TabsTrigger value="locations" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white">
<MapPin className="w-4 h-4" /> Locations
</TabsTrigger>
<TabsTrigger value="approvals" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="approvals" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<SlidersHorizontal className="w-4 h-4" /> Approvals
</TabsTrigger>
<TabsTrigger value="documents" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="documents" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<FileText className="w-4 h-4" /> Docs Config
</TabsTrigger>
<TabsTrigger value="governance" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="governance" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<Settings2 className="w-4 h-4" /> Governance
</TabsTrigger>
<TabsTrigger value="settings" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-amber-600 data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<TabsTrigger value="settings" className="flex items-center gap-2 py-3 rounded-lg data-[state=active]:bg-re-red data-[state=active]:text-white transition-all transform hover:scale-[1.02]">
<Settings className="w-4 h-4" /> App Settings
</TabsTrigger>
</TabsList>

View File

@ -4,7 +4,9 @@ import { RootState } from '@/store';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Clock, Plus, Pen, Bell, AlertTriangle, CheckCircle, RefreshCw } from 'lucide-react';
import { Clock, Plus, Pen, Bell, AlertTriangle, CheckCircle, RefreshCw, Activity } from 'lucide-react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { SLAMonitorPanel } from '@/features/master/components/SLAMonitorPanel';
import { toast } from 'sonner';
import { masterService } from '@/services/master.service';
import { setMasterData } from '@/store/slices/masterSlice';
@ -15,6 +17,8 @@ export const SLAConfigPage: React.FC = () => {
const { slaConfigs, loading } = useSelector((state: RootState) => state.master);
const [showSLADialog, setShowSLADialog] = useState(false);
const [selectedSLA, setSelectedSLA] = useState<any>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [mainTab, setMainTab] = useState('monitor');
const fetchConfigs = async () => {
try {
@ -22,7 +26,7 @@ export const SLAConfigPage: React.FC = () => {
if (res && res.success) {
dispatch(setMasterData({ slaConfigs: res.data }));
}
} catch (error) {
} catch {
toast.error('Failed to fetch SLA configurations');
}
};
@ -39,7 +43,7 @@ export const SLAConfigPage: React.FC = () => {
toast.success('Default SLAs initialized successfully');
fetchConfigs();
}
} catch (error) {
} catch {
toast.error('Failed to initialize default SLAs');
} finally {
setLoadingMore(false);
@ -56,24 +60,36 @@ export const SLAConfigPage: React.FC = () => {
setShowSLADialog(true);
};
const [loadingMore, setLoadingMore] = useState(false);
return (
<div className="space-y-6 max-w-7xl mx-auto">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Clock className="w-6 h-6 text-amber-600" />
SLA & Escalation Matrix
<Clock className="w-6 h-6 text-re-red" />
SLA & Escalation
</h1>
<p className="text-slate-500">Configure Turn Around Time (TAT) and escalation rules for each process stage</p>
<p className="text-slate-500">Configure TAT rules and monitor live queue, breaches, and schedulers</p>
</div>
<div className="flex items-center gap-3">
<Tabs value={mainTab} onValueChange={setMainTab}>
<TabsList className="bg-slate-100">
<TabsTrigger value="monitor" className="flex items-center gap-1.5">
<Activity className="w-4 h-4" />
Operations monitor
</TabsTrigger>
<TabsTrigger value="config">Configuration matrix</TabsTrigger>
</TabsList>
<TabsContent value="monitor" className="mt-6">
<SLAMonitorPanel />
</TabsContent>
<TabsContent value="config" className="mt-6 space-y-6">
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={handleInitialize} disabled={loading || loadingMore}>
<RefreshCw className={`w-4 h-4 mr-2 ${loadingMore ? 'animate-spin' : ''}`} />
Initialize Defaults
</Button>
<Button onClick={handleAddSLA} disabled={loading}>
<Button onClick={handleAddSLA} disabled={loading} className="bg-re-red hover:bg-re-red-hover text-white">
<Plus className="w-4 h-4 mr-2" />
Add Manual SLA
</Button>
@ -81,7 +97,6 @@ export const SLAConfigPage: React.FC = () => {
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{slaConfigs.map((sla) => (
@ -89,25 +104,37 @@ export const SLAConfigPage: React.FC = () => {
<CardHeader className="pb-3 bg-gradient-to-br from-white to-slate-50/50">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-amber-50 border border-amber-100 flex items-center justify-center">
<Clock className="w-5 h-5 text-amber-600" />
<div className="w-10 h-10 rounded-xl bg-red-50 border border-red-100 flex items-center justify-center">
<Clock className="w-5 h-5 text-re-red" />
</div>
<div>
<CardTitle className="text-lg">{sla.activityName}</CardTitle>
<CardDescription className="flex items-center gap-1.5 mt-0.5">
<span className="font-semibold text-amber-700">Target TAT: {sla.tatHours} {sla.tatUnit}</span>
<span className="font-semibold text-re-red-hover">
Target TAT: {sla.tatHours} {sla.tatUnit}
</span>
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={sla.isActive ? "default" : "secondary"} className={sla.isActive ? "bg-emerald-600" : "bg-slate-400"}>
<Badge
variant={sla.isActive ? 'default' : 'secondary'}
className={sla.isActive ? 'bg-emerald-600' : 'bg-slate-400'}
>
{sla.isActive ? (
<><CheckCircle className="w-3 h-3 mr-1" /> Active</>
<>
<CheckCircle className="w-3 h-3 mr-1" /> Active
</>
) : (
'Disabled'
)}
</Badge>
<Button variant="ghost" size="icon" onClick={() => handleEdit(sla)} className="h-8 w-8 text-slate-400 hover:text-amber-600">
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(sla)}
className="h-8 w-8 text-slate-400 hover:text-re-red"
>
<Pen className="w-4 h-4" />
</Button>
</div>
@ -117,7 +144,9 @@ export const SLAConfigPage: React.FC = () => {
<div className="border-l-2 border-blue-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<Bell className="w-4 h-4 text-blue-600" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">Reminders ({sla.reminders?.length || 0})</span>
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">
Reminders ({sla.reminders?.length || 0})
</span>
</div>
<div className="space-y-1.5">
{(sla.reminders || []).map((reminder: any, idx: number) => (
@ -136,17 +165,21 @@ export const SLAConfigPage: React.FC = () => {
<div className="border-l-2 border-red-400 pl-3">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="w-4 h-4 text-red-600" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">Escalations ({sla.escalationConfigs?.length || 0})</span>
<AlertTriangle className="w-4 h-4 text-re-red" />
<span className="text-xs font-bold text-slate-700 uppercase tracking-wider">
Escalations ({sla.escalationConfigs?.length || 0})
</span>
</div>
<div className="space-y-2">
{(sla.escalationConfigs || []).map((esc: any, idx: number) => (
<div key={idx} className="text-[11px]">
<div className="flex items-center gap-1.5 text-slate-900 font-medium">
<Badge variant="outline" className="text-[9px] h-4 px-1 bg-red-50 border-red-200 text-red-700">
<Badge variant="outline" className="text-[9px] h-4 px-1 bg-red-50 border-red-200 text-re-red-hover">
L{esc.level}
</Badge>
<span>after {esc.timeValue} {esc.timeUnit}</span>
<span>
after {esc.timeValue} {esc.timeUnit}
</span>
</div>
<p className="text-slate-500 ml-8 font-mono truncate">{esc.notifyEmail}</p>
</div>
@ -168,13 +201,10 @@ export const SLAConfigPage: React.FC = () => {
</div>
)}
</div>
</TabsContent>
</Tabs>
<SLADialog
isOpen={showSLADialog}
onClose={() => setShowSLADialog(false)}
sla={selectedSLA}
onSave={fetchConfigs}
/>
<SLADialog isOpen={showSLADialog} onClose={() => setShowSLADialog(false)} sla={selectedSLA} onSave={fetchConfigs} />
</div>
);
};

View File

@ -1,6 +1,6 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { ApplicationProgressBar } from '@/features/onboarding/components/ApplicationProgressBar';
import { Application } from '@/lib/mock-data';
import { MapPin, Phone, Mail, Award, Calendar, Building } from 'lucide-react';
import { formatDateTime } from '@/components/ui/utils';
@ -11,42 +11,20 @@ interface ApplicationCardProps {
}
export function ApplicationCard({ application, onViewDetails }: ApplicationCardProps) {
/**
* Status badge classes see ApplicationsPage for the rationale.
* Three buckets only: positive (black), negative (muted brand red),
* default in-progress (light slate). Keeps the listing brand-aligned.
*/
const getStatusColor = (status: string) => {
const statusColors: Record<string, string> = {
'Submitted': 'bg-slate-500',
'Questionnaire Pending': 'bg-orange-500',
'Questionnaire Completed': 'bg-blue-500',
'Shortlisted': 'bg-cyan-500',
'Level 1 Pending': 'bg-amber-500',
'Level 1 Approved': 'bg-green-500',
'Level 2 Pending': 'bg-purple-500',
'Level 2 Approved': 'bg-green-600',
'Level 2 Recommended': 'bg-teal-500',
'Level 3 Pending': 'bg-indigo-500',
'FDD Verification': 'bg-violet-500',
'Payment Pending': 'bg-yellow-500',
'LOI Issued': 'bg-lime-500',
'Dealer Code Generation': 'bg-fuchsia-500',
'Architecture Team Assigned': 'bg-blue-500',
'Architecture Document Upload': 'bg-blue-500',
'Architecture Team Completion': 'bg-blue-500',
'Statutory GST': 'bg-emerald-500',
'Statutory PAN': 'bg-emerald-500',
'Statutory Nodal': 'bg-emerald-500',
'Statutory Check': 'bg-emerald-500',
'Statutory Partnership': 'bg-emerald-500',
'Statutory Firm Reg': 'bg-emerald-500',
'Statutory Virtual Code': 'bg-emerald-500',
'Statutory Domain': 'bg-emerald-500',
'Statutory MSD': 'bg-emerald-500',
'Statutory LOI Ack': 'bg-emerald-500',
'EOR In Progress': 'bg-sky-500',
'LOA Pending': 'bg-emerald-500',
'Approved': 'bg-green-700',
'Rejected': 'bg-red-500',
'Disqualified': 'bg-red-700'
};
return statusColors[status] || 'bg-slate-500';
const s = String(status || '');
if (s === 'Rejected' || s === 'Disqualified' || s.includes('Rejected')) {
return 'bg-red-50 text-re-red-hover border border-red-200';
}
if (s === 'Approved' || s === 'Onboarded' || s === 'Completed') {
return 'bg-slate-900 text-white border border-transparent';
}
return 'bg-slate-200 text-slate-800 border border-slate-300';
};
return (
@ -124,7 +102,12 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
<span className="text-slate-600">Progress</span>
<span className="text-slate-900" data-testid="onboarding-application-card-progress-text">{application.progress}%</span>
</div>
<Progress value={application.progress} className="h-2" data-testid="onboarding-application-card-progress-bar" />
<ApplicationProgressBar
value={application.progress}
status={application.status}
barClassName="h-2 w-full bg-status-progress-soft"
data-testid="onboarding-application-card-progress-bar"
/>
</div>
{/* Deadline Warning */}
@ -138,7 +121,7 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
<Button
onClick={() => onViewDetails(application.id)}
className="w-full bg-amber-600 hover:bg-amber-700"
className="w-full bg-re-red hover:bg-re-red-hover"
data-testid="onboarding-application-card-view-button"
>
View Details

View File

@ -0,0 +1,44 @@
import { Progress } from '@/components/ui/progress';
import { cn } from '@/components/ui/utils';
import { getStatusProgressBarClass } from '@/lib/statusProgressTheme';
export interface ApplicationProgressBarProps {
value: number;
status?: string | null;
currentStage?: string | null;
/** Track styling — matches Application Details progress tab by default */
barClassName?: string;
showPercent?: boolean;
percentClassName?: string;
'data-testid'?: string;
}
/** Status-colored progress bar (amber / green / re-red) — same strategy as Application Details. */
export function ApplicationProgressBar({
value,
status,
currentStage,
barClassName = 'h-2 w-20 bg-status-progress-soft',
showPercent = false,
percentClassName = 'text-slate-600 shrink-0',
'data-testid': testId,
}: ApplicationProgressBarProps) {
return (
<div
className={cn('flex items-center gap-2 min-w-0', showPercent && 'w-full max-w-[8rem]')}
data-testid={testId ? `${testId}-container` : undefined}
>
<Progress
value={value}
className={barClassName}
indicatorClassName={getStatusProgressBarClass(status, currentStage)}
data-testid={testId}
/>
{showPercent && (
<span className={percentClassName} data-testid={testId ? `${testId}-text` : undefined}>
{value}%
</span>
)}
</div>
);
}

View File

@ -115,7 +115,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
id="isPrimaryModal"
name="isPrimary"
defaultChecked={editingBank?.isPrimary}
className="w-4 h-4 rounded border-slate-300 text-amber-600 focus:ring-amber-500"
className="w-4 h-4 rounded border-slate-300 text-re-red focus:ring-re-red"
data-testid="onboarding-is-primary-checkbox"
/>
<Label htmlFor="isPrimaryModal" className="text-xs font-medium cursor-pointer">Set as primary account</Label>
@ -124,7 +124,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
</div>
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={onClose} data-testid="onboarding-bank-details-cancel">Cancel</Button>
<Button type="submit" disabled={isSubmitting} size="sm" className="bg-amber-600" data-testid="onboarding-bank-details-submit">
<Button type="submit" disabled={isSubmitting} size="sm" className="bg-re-red" data-testid="onboarding-bank-details-submit">
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
{editingBank ? 'Update Account' : 'Save Bank Details'}
</Button>

View File

@ -33,11 +33,11 @@ const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ a
<div className="space-y-6" data-testid="onboarding-questionnaire-view">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<ClipboardList className="w-5 h-5 text-amber-600" />
<ClipboardList className="w-5 h-5 text-re-red" />
<h3 className="text-slate-900">Questionnaire Responses</h3>
</div>
{totalScore !== undefined && (
<Badge className="bg-amber-600" data-testid="onboarding-questionnaire-total-score">Score: {totalScore}/100</Badge>
<Badge className="bg-re-red" data-testid="onboarding-questionnaire-total-score">Score: {totalScore}/100</Badge>
)}
</div>
@ -62,12 +62,12 @@ const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ a
return (
<div
key={resp.id}
className="border border-slate-200 rounded-lg p-5 hover:border-amber-300 transition-colors"
className="border border-slate-200 rounded-lg p-5 hover:border-red-300 transition-colors"
data-testid={`onboarding-questionnaire-item-${index}`}
>
<div className="flex items-start gap-3 mb-3">
<div className="w-8 h-8 rounded-full bg-amber-100 flex items-center justify-center flex-shrink-0">
<span className="text-amber-600">{index + 1}</span>
<div className="w-8 h-8 rounded-full bg-red-50 flex items-center justify-center flex-shrink-0">
<span className="text-re-red">{index + 1}</span>
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">

View File

@ -126,9 +126,9 @@ export function ApplicantInformationCard({
data-testid="onboarding-applicant-info-edit-firm-type"
>
Proposed Firm Type
<Pencil className="w-3 h-3 text-slate-300 group-hover:text-amber-600 transition-colors" />
<Pencil className="w-3 h-3 text-slate-300 group-hover:text-re-red transition-colors" />
</p>
<p className="text-slate-900 font-black text-amber-700 tracking-tight leading-none mt-1" data-testid="onboarding-applicant-info-firm-type">
<p className="text-slate-900 font-black text-re-red-hover tracking-tight leading-none mt-1" data-testid="onboarding-applicant-info-firm-type">
{application.constitutionType || 'Not Provided'}
</p>
</div>
@ -214,14 +214,14 @@ export function ApplicantInformationCard({
<div className="pt-6 border-t mt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-black text-slate-900 uppercase tracking-widest flex items-center gap-2">
<CreditCard className="w-4 h-4 text-amber-600" /> Statutory & Bank Information
<CreditCard className="w-4 h-4 text-re-red" /> Statutory & Bank Information
</h3>
{canEditStatutory && !isEditingStatutory && (
<Button
variant="ghost"
size="sm"
onClick={onEditStatutory}
className="h-8 text-amber-600 hover:text-amber-700 hover:bg-amber-50 gap-1.5"
className="h-8 text-re-red hover:text-re-red-hover hover:bg-red-50 gap-1.5"
data-testid="onboarding-applicant-info-edit-statutory"
>
<Pencil className="w-3.5 h-3.5" />
@ -232,7 +232,7 @@ export function ApplicantInformationCard({
{isEditingStatutory ? (
<div
className="bg-slate-50/50 p-6 rounded-xl border-2 border-amber-100 space-y-4"
className="bg-slate-50/50 p-6 rounded-xl border-2 border-red-100 space-y-4"
data-testid="onboarding-applicant-info-statutory-edit-form"
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@ -324,7 +324,7 @@ export function ApplicantInformationCard({
size="sm"
onClick={onSaveStatutory}
disabled={isSavingStatutory}
className="bg-amber-600 hover:bg-amber-700"
className="bg-re-red hover:bg-re-red-hover"
data-testid="onboarding-applicant-info-statutory-save"
>
{isSavingStatutory ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save Details'}

View File

@ -35,6 +35,8 @@ interface ApplicationDetailsActionModalsProps {
setInterviewIdToCancel: (value: string) => void;
isCancellingInterview: boolean;
handleConfirmCancelInterview: () => void;
interviewToReschedule: any;
setInterviewToReschedule: (value: any) => void;
interviewType: string;
setInterviewType: (value: string) => void;
interviewMode: string;
@ -99,6 +101,8 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
setInterviewIdToCancel,
isCancellingInterview,
handleConfirmCancelInterview,
interviewToReschedule,
setInterviewToReschedule,
interviewType,
setInterviewType,
interviewMode,
@ -252,10 +256,13 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</DialogContent>
</Dialog>
<Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal}>
<Dialog open={showScheduleModal} onOpenChange={(open) => {
setShowScheduleModal(open);
if (!open) setInterviewToReschedule(null);
}}>
<DialogContent data-testid="onboarding-schedule-modal">
<DialogHeader>
<DialogTitle>Schedule Interview</DialogTitle>
<DialogTitle>{interviewToReschedule ? 'Reschedule Interview' : 'Schedule Interview'}</DialogTitle>
<DialogDescription>Set up an interview session with the applicant and relevant team members.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
@ -264,9 +271,9 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<Select value={interviewType} onValueChange={setInterviewType}>
<SelectTrigger className="mt-2" data-testid="onboarding-schedule-type-select"><SelectValue placeholder="Select interview type" /></SelectTrigger>
<SelectContent>
<SelectItem value="level1" disabled={isInterviewCompleted(1) || isInterviewActive(1)}><div className="flex items-center justify-between w-full"><span>Level 1</span>{isInterviewCompleted(1) && <CheckCircle className="w-4 h-4 text-green-500 ml-2 inline" />}{isInterviewActive(1) && <Clock className="w-4 h-4 text-amber-500 ml-2 inline" />}</div></SelectItem>
<SelectItem value="level2" disabled={!isInterviewCompleted(1) || isInterviewCompleted(2) || isInterviewActive(2)}><div className="flex items-center justify-between w-full"><span>Level 2</span>{!isInterviewCompleted(1) && <span className="text-[10px] text-slate-400 ml-2">(Prerequisite: L1)</span>}{isInterviewCompleted(2) && <CheckCircle className="w-4 h-4 text-green-500 ml-2 inline" />}{isInterviewActive(2) && <Clock className="w-4 h-4 text-amber-500 ml-2 inline" />}</div></SelectItem>
<SelectItem value="level3" disabled={!isInterviewCompleted(2) || isInterviewCompleted(3) || isInterviewActive(3)}><div className="flex items-center justify-between w-full"><span>Level 3</span>{!isInterviewCompleted(2) && <span className="text-[10px] text-slate-400 ml-2">(Prerequisite: L2)</span>}{isInterviewCompleted(3) && <CheckCircle className="w-4 h-4 text-green-500 ml-2 inline" />}{isInterviewActive(3) && <Clock className="w-4 h-4 text-amber-500 ml-2 inline" />}</div></SelectItem>
<SelectItem value="level1" disabled={isInterviewCompleted(1) || isInterviewActive(1)}><div className="flex items-center justify-between w-full"><span>Level 1</span>{isInterviewCompleted(1) && <CheckCircle className="w-4 h-4 text-green-500 ml-2 inline" />}{isInterviewActive(1) && <Clock className="w-4 h-4 text-re-red ml-2 inline" />}</div></SelectItem>
<SelectItem value="level2" disabled={!isInterviewCompleted(1) || isInterviewCompleted(2) || isInterviewActive(2)}><div className="flex items-center justify-between w-full"><span>Level 2</span>{!isInterviewCompleted(1) && <span className="text-[10px] text-slate-400 ml-2">(Prerequisite: L1)</span>}{isInterviewCompleted(2) && <CheckCircle className="w-4 h-4 text-green-500 ml-2 inline" />}{isInterviewActive(2) && <Clock className="w-4 h-4 text-re-red ml-2 inline" />}</div></SelectItem>
<SelectItem value="level3" disabled={!isInterviewCompleted(2) || isInterviewCompleted(3) || isInterviewActive(3)}><div className="flex items-center justify-between w-full"><span>Level 3</span>{!isInterviewCompleted(2) && <span className="text-[10px] text-slate-400 ml-2">(Prerequisite: L2)</span>}{isInterviewCompleted(3) && <CheckCircle className="w-4 h-4 text-green-500 ml-2 inline" />}{isInterviewActive(3) && <Clock className="w-4 h-4 text-re-red ml-2 inline" />}</div></SelectItem>
</SelectContent>
</Select>
</div>
@ -305,8 +312,13 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
)}
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowScheduleModal(false)} disabled={isScheduling} data-testid="onboarding-schedule-cancel-button">Cancel</Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={handleScheduleInterview} disabled={isScheduling} data-testid="onboarding-schedule-submit-button">{isScheduling ? 'Scheduling...' : 'Schedule'}</Button>
<Button variant="outline" className="flex-1" onClick={() => {
setShowScheduleModal(false);
setInterviewToReschedule(null);
}} disabled={isScheduling} data-testid="onboarding-schedule-cancel-button">Cancel</Button>
<Button className="flex-1 bg-primary-600 hover:bg-primary-700" onClick={handleScheduleInterview} disabled={isScheduling} data-testid="onboarding-schedule-submit-button">
{isScheduling ? (interviewToReschedule ? 'Rescheduling...' : 'Scheduling...') : (interviewToReschedule ? 'Reschedule' : 'Schedule')}
</Button>
</div>
</div>
</DialogContent>

View File

@ -69,15 +69,12 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
getDocumentsForStage,
setPreviewDoc,
setShowPreviewModal,
flattenedStages,
setSelectedStage,
uploadDocType,
setUploadDocType,
setUploadFile,
isUploading,
handleUpload,
uploadFile,
documentConfigs,
showPreviewModal,
previewDoc,
showFddFinalizeModal,
@ -126,7 +123,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<div className="custom-scrollbar-slim min-h-0 flex-1 overflow-y-auto px-5 py-5">
<div className="space-y-6">
{ktCriteria.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
KT Matrix configuration is not available. Configure it in Master &gt; Interview Configurations.
</div>
)}
@ -157,10 +154,10 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
</div>
))}
<div className="space-y-2 border-t border-border pt-6">
<Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">Notes <span className="font-normal text-muted-foreground">(optional)</span></Label>
<Label htmlFor="kt-matrix-remarks" className="text-sm font-medium">Remarks <span className="text-red-500">*</span></Label>
<Textarea
id="kt-matrix-remarks"
placeholder="Optional remarks…"
placeholder="Enter remarks..."
className="min-h-[96px] resize-y text-sm leading-relaxed"
value={ktMatrixRemarks}
onChange={(e) => setKtMatrixRemarks(e.target.value)}
@ -186,7 +183,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<p className="text-sm text-muted-foreground">Weighted total <span className="font-semibold tabular-nums text-foreground" data-testid="onboarding-kt-matrix-total-score">{calculateKTScore()}</span><span className="text-muted-foreground"> / 100</span></p>
<div className="flex gap-2 sm:shrink-0">
<Button variant="outline" onClick={() => setShowKTMatrixModal(false)} data-testid="onboarding-kt-matrix-cancel">Cancel</Button>
<Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || ktCriteria.length === 0 || Object.keys(ktMatrixSelectedValues).length < ktCriteria.length} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
<Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || ktCriteria.length === 0 || Object.keys(ktMatrixSelectedValues).length < ktCriteria.length || !ktMatrixRemarks?.trim()} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
</div>
</div>
</DialogContent>
@ -208,22 +205,10 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<SelectContent><SelectItem value="10">Outstanding (9-10)</SelectItem><SelectItem value="8">Excellent (7-8)</SelectItem><SelectItem value="6">Good (5-6)</SelectItem><SelectItem value="4">Average (3-4)</SelectItem><SelectItem value="2">Below Average (1-2)</SelectItem></SelectContent>
</Select>
</div>
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level2Recommendation} onValueChange={setLevel2Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level2-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{l2Fields.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
Level 2 feedback configuration is not available. Configure it in Master &gt; Interview Configurations.
</div>
)}
@ -255,6 +240,19 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
)}
</div>
))}
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level2Recommendation} onValueChange={setLevel2Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level2-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowLevel2FeedbackModal(false)} data-testid="onboarding-level2-feedback-cancel">Cancel</Button>
<Button className="flex-1 bg-black hover:bg-zinc-800 text-white" onClick={handleSubmitLevel2Feedback} disabled={isSubmittingLevel2 || l2Fields.length === 0} data-testid="onboarding-level2-feedback-submit">{isSubmittingLevel2 ? 'Submitting...' : 'Submit Feedback'}</Button>
@ -311,22 +309,10 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<SelectContent><SelectItem value="10">Outstanding (9-10)</SelectItem><SelectItem value="8">Excellent (7-8)</SelectItem><SelectItem value="6">Good (5-6)</SelectItem><SelectItem value="4">Average (3-4)</SelectItem><SelectItem value="2">Below Average (1-2)</SelectItem></SelectContent>
</Select>
</div>
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level3Recommendation} onValueChange={setLevel3Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level3-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
{l3Fields.length === 0 && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
Level 3 feedback configuration is not available. Configure it in Master &gt; Interview Configurations.
</div>
)}
@ -358,6 +344,19 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
)}
</div>
))}
<div>
<Label>Recommendation <span className="text-red-500">*</span></Label>
<Select value={level3Recommendation} onValueChange={setLevel3Recommendation}>
<SelectTrigger className="mt-2" data-testid="onboarding-level3-recommendation-select">
<SelectValue placeholder="Select recommendation" />
</SelectTrigger>
<SelectContent>
<SelectItem value="Approve">Approve</SelectItem>
<SelectItem value="Reject">Reject</SelectItem>
<SelectItem value="Hold">Hold</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowLevel3FeedbackModal(false)} data-testid="onboarding-level3-feedback-cancel">Cancel</Button>
<Button className="flex-1 bg-black hover:bg-zinc-800 text-white" onClick={handleSubmitLevel3Feedback} disabled={isSubmittingLevel3 || l3Fields.length === 0} data-testid="onboarding-level3-feedback-submit">{isSubmittingLevel3 ? 'Submitting...' : 'Submit Feedback'}</Button>
@ -369,13 +368,13 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<Dialog open={showDocumentsModal} onOpenChange={(open) => { setShowDocumentsModal(open); if (!open) setShowUploadForm(false); }}>
<DialogContent className="max-w-[95vw] sm:max-w-2xl md:max-w-3xl lg:max-w-4xl max-h-[90vh] overflow-hidden flex flex-col p-4 sm:p-6" data-testid="onboarding-documents-modal">
<DialogHeader className="pb-4">
<DialogTitle className="text-xl font-bold flex items-center gap-2"><FileText className="w-5 h-5 text-amber-600" />Documents - {selectedStage || 'General'}</DialogTitle>
<DialogTitle className="text-xl font-bold flex items-center gap-2"><FileText className="w-5 h-5 text-re-red" />Documents - {selectedStage || 'General'}</DialogTitle>
<DialogDescription className="text-slate-500">View and manage documents uploaded for this stage.</DialogDescription>
</DialogHeader>
{!showUploadForm ? (
<div className="flex-1 flex flex-col min-h-0 space-y-4">
{getDocumentsForStage(selectedStage || '').length > 0 ? (
<div className="flex-1 overflow-auto border rounded-lg border-slate-200" data-testid="onboarding-documents-table-container">
<div className="custom-scrollbar-x-slim flex-1 overflow-auto border rounded-lg border-slate-200" data-testid="onboarding-documents-table-container">
<Table className="w-full table-auto">
<TableHeader className="bg-slate-50/80 sticky top-0 z-10">
<TableRow className="hover:bg-transparent border-b">
@ -396,7 +395,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<TableCell className="text-right py-3">
<div className="flex gap-1 justify-end">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-full" onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} data-testid={`onboarding-document-preview-${index}`}><Eye className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-600 hover:bg-amber-50 rounded-full" onClick={() => { const baseUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:5000'; window.open(`${baseUrl}/${doc.filePath}`, '_blank'); }} data-testid={`onboarding-document-download-${index}`}><Download className="w-4 h-4" /></Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-re-red hover:bg-red-50 rounded-full" onClick={() => { const baseUrl = (import.meta as any).env?.VITE_API_URL || 'http://localhost:5000'; window.open(`${baseUrl}/${doc.filePath}`, '_blank'); }} data-testid={`onboarding-document-download-${index}`}><Download className="w-4 h-4" /></Button>
</div>
</TableCell>
</TableRow>
@ -408,66 +407,46 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<div className="flex-1 flex flex-col items-center justify-center py-12 text-center border rounded-lg bg-slate-50/30" data-testid="onboarding-documents-empty"><div className="w-16 h-16 rounded-full bg-slate-100 flex items-center justify-center mb-4"><FileText className="w-8 h-8 text-slate-300" /></div><h3 className="text-slate-900 font-semibold mb-2">No Documents Found</h3><p className="text-slate-600 text-sm max-w-[250px]">No documents have been uploaded for this stage yet.</p></div>
)}
<div className="flex flex-col sm:flex-row gap-3 pt-2 mt-auto">
<Button className="flex-1 bg-amber-600 hover:bg-amber-700 text-white font-bold py-3 sm:py-5 rounded-xl shadow-lg shadow-amber-600/15 transition-all hover:scale-[1.01] active:scale-[0.99]" onClick={() => setShowUploadForm(true)} data-testid="onboarding-documents-upload-button"><Upload className="w-5 h-5 mr-3" />Upload Document</Button>
<Button className="flex-1 bg-re-red hover:bg-re-red-hover text-white font-bold py-3 sm:py-5 rounded-xl shadow-lg shadow-re-red/15 transition-all hover:scale-[1.01] active:scale-[0.99]" onClick={() => setShowUploadForm(true)} data-testid="onboarding-documents-upload-button"><Upload className="w-5 h-5 mr-3" />Upload Document</Button>
<Button variant="outline" className="flex-1 sm:flex-none py-3 sm:py-5 px-8 rounded-xl border-slate-200 font-semibold text-slate-600 hover:bg-slate-50" onClick={() => setShowDocumentsModal(false)} data-testid="onboarding-documents-close-button">Close</Button>
</div>
</div>
) : (
<div className="space-y-6 py-4" data-testid="onboarding-documents-upload-form">
<div className="grid gap-6 bg-slate-50/50 p-4 sm:p-6 rounded-2xl border border-slate-200">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div className="bg-slate-50/50 p-4 sm:p-6 rounded-2xl border border-slate-200">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-slate-700 font-semibold px-1">Stage context <span className="text-red-500">*</span></Label>
<Select value={selectedStage || 'null'} onValueChange={(val) => setSelectedStage(val === 'null' ? null : val)}>
<SelectTrigger className="bg-white border-slate-200 h-11 rounded-xl focus:ring-amber-500 shadow-sm" data-testid="onboarding-documents-stage-select"><SelectValue placeholder="Select stage" /></SelectTrigger>
<SelectContent>
<SelectItem value="null">General / No Stage</SelectItem>
{flattenedStages.map((s: any, idx: number) => <SelectItem key={`${s.name}-${idx}`} value={s.name}>{s.parentBranch ? `${s.parentBranch}: ${s.name}` : s.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label className="text-slate-700 font-semibold px-1">Document Type <span className="text-red-500">*</span></Label>
<Select value={uploadDocType} onValueChange={setUploadDocType}>
<SelectTrigger className="bg-white border-slate-200 h-11 rounded-xl focus:ring-amber-500 shadow-sm" data-testid="onboarding-documents-type-select"><SelectValue placeholder="Select type" /></SelectTrigger>
<SelectContent>
{(() => {
const baseDocs = ['Other'];
const stageConfigs = documentConfigs.filter((c: any) => {
const cfgStage = c.stageCode?.trim();
const selStage = (selectedStage || 'General').trim();
if (cfgStage === selStage) return true;
if (selStage.startsWith('EOR:') && cfgStage === 'EOR') return true;
if (!selectedStage && cfgStage === 'General') return true;
return false;
});
let filteredDocs: string[] = [];
if (stageConfigs.length > 0) filteredDocs = stageConfigs.map((c: any) => c.documentType);
else if (!selectedStage || selectedStage === 'General') {
filteredDocs = ['PAN Card', 'GST Certificate', 'Aadhaar Card', 'Passport Size Photograph', 'Partnership Deed', 'LLP Agreement', 'Certificate of Incorporation', 'Board Resolution', 'Firm Registration Certificate', 'Cancelled Check', 'Bank Statement', 'Other'];
} else if (selectedStage?.toLowerCase().includes('architecture')) {
filteredDocs = ['Architecture Blueprint', 'Site Plan', 'Proposed Site City Map', 'Site Readiness Report', 'Architecture Completion Certificate', 'Other'];
} else if (selectedStage?.toLowerCase().includes('fdd')) {
filteredDocs = ['FDD Final Audit Report', 'Bank Statement', 'Income Tax Returns (ITR)', 'CIBIL Report', 'Other'];
} else filteredDocs = baseDocs;
if (selectedStage?.startsWith('EOR: ')) {
const eorItem = selectedStage.replace('EOR: ', '');
if (!filteredDocs.includes(eorItem)) filteredDocs = [eorItem, ...filteredDocs];
}
return Array.from(new Set(filteredDocs)).map((doc, idx) => <SelectItem key={`${doc}-${idx}`} value={doc} data-testid={`onboarding-documents-type-option-${idx}`}>{doc}</SelectItem>);
})()}
</SelectContent>
</Select>
</div>
<Label className="text-slate-700 font-semibold px-1">Document Name <span className="text-red-500">*</span></Label>
<Input
type="text"
placeholder="Enter document name"
value={uploadDocType}
onChange={(e) => setUploadDocType(e.target.value)}
className="bg-white border-slate-200 h-12 rounded-xl focus:ring-re-red shadow-sm"
data-testid="onboarding-documents-name-input"
/>
</div>
<div className="space-y-2">
<Label className="text-slate-700 font-semibold px-1">Select File <span className="text-red-500">*</span></Label>
<Input type="file" className="bg-white border-slate-200 h-12 rounded-xl focus:ring-amber-500 shadow-sm file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-amber-50 file:text-amber-700 hover:file:bg-amber-100 cursor-pointer" onChange={(e) => setUploadFile(e.target.files ? e.target.files[0] : null)} data-testid="onboarding-documents-file-input" />
<Input
type="file"
className="bg-white border-slate-200 h-12 rounded-xl focus:ring-re-red shadow-sm file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-red-50 file:text-re-red-hover hover:file:bg-red-50 cursor-pointer"
onChange={(e) => {
const file = e.target.files ? e.target.files[0] : null;
setUploadFile(file);
if (file) {
const baseName = file.name.replace(/\.[^/.]+$/, '');
setUploadDocType(baseName);
}
}}
data-testid="onboarding-documents-file-input"
/>
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-3 pt-4">
<Button className="flex-1 order-2 sm:order-1 py-3 sm:py-5 rounded-xl border-slate-200 font-semibold text-slate-600 hover:bg-slate-50" variant="outline" onClick={() => setShowUploadForm(false)} disabled={isUploading} data-testid="onboarding-documents-upload-cancel">Cancel</Button>
<Button className="flex-1 order-1 sm:order-2 bg-amber-600 hover:bg-amber-700 text-white font-bold py-3 sm:py-5 rounded-xl shadow-lg shadow-amber-600/15 transition-all hover:scale-[1.01] active:scale-[0.99]" onClick={async () => { await handleUpload(); setShowUploadForm(false); }} disabled={!uploadFile || !uploadDocType || isUploading} data-testid="onboarding-documents-upload-submit">
<Button className="flex-1 order-1 sm:order-2 bg-re-red hover:bg-re-red-hover text-white font-bold py-3 sm:py-5 rounded-xl shadow-lg shadow-re-red/15 transition-all hover:scale-[1.01] active:scale-[0.99]" onClick={async () => { await handleUpload(); setShowUploadForm(false); }} disabled={!uploadFile || !uploadDocType || isUploading} data-testid="onboarding-documents-upload-submit">
{isUploading ? <span className="flex items-center gap-2"><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />Uploading...</span> : <span className="flex items-center gap-2"><Upload className="w-5 h-5" />Confirm Upload</span>}
</Button>
</div>
@ -479,11 +458,11 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<Dialog open={showFddFinalizeModal} onOpenChange={setShowFddFinalizeModal}>
<DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl rounded-3xl" data-testid="onboarding-fdd-finalize-modal">
<div className="bg-slate-950 p-8 flex items-center justify-center relative overflow-hidden"><div className="absolute inset-0 bg-gradient-to-br from-amber-600/20 to-transparent" /><div className="w-20 h-20 bg-amber-600/20 rounded-full flex items-center justify-center animate-pulse relative z-10 shadow-[0_0_40px_rgba(245,158,11,0.2)]"><ShieldCheck className="w-10 h-10 text-amber-500" /></div></div>
<div className="bg-slate-950 p-8 flex items-center justify-center relative overflow-hidden"><div className="absolute inset-0 bg-gradient-to-br from-re-red/20 to-transparent" /><div className="w-20 h-20 bg-re-red/20 rounded-full flex items-center justify-center animate-pulse relative z-10 shadow-[0_0_40px_rgba(218,41,28,0.2)]"><ShieldCheck className="w-10 h-10 text-re-red" /></div></div>
<div className="p-8 space-y-6 bg-white">
<DialogHeader>
<DialogTitle className="text-2xl font-black text-slate-900 text-center tracking-tight">Finalize FDD Audit</DialogTitle>
<DialogDescription className="text-slate-500 text-center pt-2 leading-relaxed text-sm font-medium">You are about to submit your final findings. This action will <span className="font-bold text-slate-900 underline decoration-amber-500 decoration-2">lock the audit session</span> and trigger the LOI approval workflow.</DialogDescription>
<DialogDescription className="text-slate-500 text-center pt-2 leading-relaxed text-sm font-medium">You are about to submit your final findings. This action will <span className="font-bold text-slate-900 underline decoration-re-red decoration-2">lock the audit session</span> and trigger the LOI approval workflow.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{(currentUser?.role !== 'FDD' && currentUser?.roleCode !== 'FDD') && (
@ -491,7 +470,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">Auditor Recommendation <span className="text-red-500">*</span></Label>
<div className="flex gap-2">
{['Recommended', 'Qualified with Observations', 'Not Recommended'].map((rec) => (
<Button key={rec} variant={fddAuditRecommendation === rec ? 'default' : 'outline'} className={cn("flex-1 h-10 font-bold text-[9px] uppercase tracking-wider rounded-xl transition-all", fddAuditRecommendation === rec && rec === 'Recommended' && "bg-emerald-600 hover:bg-emerald-700", fddAuditRecommendation === rec && rec === 'Qualified with Observations' && "bg-amber-500 hover:bg-amber-600", fddAuditRecommendation === rec && rec === 'Not Recommended' && "bg-red-600 hover:bg-red-700")} onClick={() => setFddAuditRecommendation(rec)} data-testid={`onboarding-fdd-recommendation-${rec.replace(/\s+/g, '-').toLowerCase()}`}>{rec}</Button>
<Button key={rec} variant={fddAuditRecommendation === rec ? 'default' : 'outline'} className={cn("flex-1 h-10 font-bold text-[9px] uppercase tracking-wider rounded-xl transition-all", fddAuditRecommendation === rec && rec === 'Recommended' && "bg-emerald-600 hover:bg-emerald-700", fddAuditRecommendation === rec && rec === 'Qualified with Observations' && "bg-red-500 hover:bg-re-red", fddAuditRecommendation === rec && rec === 'Not Recommended' && "bg-red-600 hover:bg-red-700")} onClick={() => setFddAuditRecommendation(rec)} data-testid={`onboarding-fdd-recommendation-${rec.replace(/\s+/g, '-').toLowerCase()}`}>{rec}</Button>
))}
</div>
</div>
@ -500,18 +479,18 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">Findings Summary</Label>
<Textarea
placeholder="Summarize key financial findings or discrepancies..."
className="min-h-[100px] rounded-xl border-slate-200 focus:ring-amber-500 text-sm"
className="min-h-[100px] rounded-xl border-slate-200 focus:ring-re-red text-sm"
value={fddAuditFindings}
onChange={(e) => setFddAuditFindings(e.target.value)}
data-testid="onboarding-fdd-findings-textarea"
/>
</div>
</div>
<div className="bg-amber-50 p-4 rounded-2xl flex gap-3 border border-amber-100"><Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" /><p className="text-[11px] text-amber-800 font-medium italic">Ensure the final PDF report is uploaded first. This satisfies the FDD statutory requirement.</p></div>
<div className="bg-red-50 p-4 rounded-2xl flex gap-3 border border-red-100"><Info className="w-5 h-5 text-re-red shrink-0 mt-0.5" /><p className="text-[11px] text-red-800 font-medium italic">Ensure the final PDF report is uploaded first. This satisfies the FDD statutory requirement.</p></div>
<div className="flex flex-col sm:flex-row gap-3 pt-2">
<Button variant="outline" className="w-full sm:flex-1 h-12 rounded-2xl font-bold text-slate-600 hover:bg-slate-50 border-slate-200" onClick={() => setShowFddFinalizeModal(false)} disabled={isFinalizingFdd} data-testid="onboarding-fdd-finalize-cancel">Cancel</Button>
<Button
className="w-full sm:flex-1 h-12 rounded-2xl font-bold bg-slate-950 hover:bg-slate-900 text-white shadow-lg shadow-slate-200 transition-all active:scale-95 border-b-4 border-amber-500"
className="w-full sm:flex-1 h-12 rounded-2xl font-bold bg-slate-950 hover:bg-slate-900 text-white shadow-lg shadow-slate-200 transition-all active:scale-95 border-b-4 border-re-red"
disabled={isFinalizingFdd || !fddAuditFindings}
data-testid="onboarding-fdd-finalize-submit"
onClick={async () => {
@ -587,16 +566,16 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<Dialog open={showFirmTypeModal} onOpenChange={setShowFirmTypeModal}>
<DialogContent className="max-w-md p-0 overflow-hidden rounded-3xl border-none shadow-2xl" data-testid="onboarding-firm-type-modal">
<div className="bg-amber-600 p-8 text-white">
<div className="bg-re-red p-8 text-white">
<div className="w-16 h-16 rounded-2xl bg-white/20 flex items-center justify-center mb-6 backdrop-blur-sm border border-white/30 shadow-inner"><Building2 className="w-8 h-8 text-white" /></div>
<h3 className="text-2xl font-black tracking-tight mb-2">Update Firm Type</h3>
<p className="text-amber-100/80 text-sm font-medium leading-relaxed">Select the proposed legal constitution for this dealership application.</p>
<p className="text-red-100/80 text-sm font-medium leading-relaxed">Select the proposed legal constitution for this dealership application.</p>
</div>
<div className="p-8 space-y-6 bg-white">
<div className="space-y-2">
<Label className="text-[10px] text-slate-400 uppercase tracking-widest font-black">Proposed Legal Constitution <span className="text-red-500">*</span></Label>
<Select value={tempFirmType} onValueChange={setTempFirmType}>
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-amber-500" data-testid="onboarding-firm-type-select"><SelectValue placeholder="Select Firm Type" /></SelectTrigger>
<SelectTrigger className="h-12 rounded-xl border-slate-200 focus:ring-re-red" data-testid="onboarding-firm-type-select"><SelectValue placeholder="Select Firm Type" /></SelectTrigger>
<SelectContent>
<SelectItem value="Proprietorship" data-testid="onboarding-firm-type-proprietorship">Proprietorship</SelectItem>
<SelectItem value="Partnership" data-testid="onboarding-firm-type-partnership">Partnership</SelectItem>
@ -608,7 +587,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
</div>
<div className="flex gap-3 pt-2">
<Button variant="outline" className="flex-1 h-12 rounded-xl font-bold text-slate-600 border-slate-200" onClick={() => setShowFirmTypeModal(false)} disabled={updatingFirmType} data-testid="onboarding-firm-type-cancel">Cancel</Button>
<Button className="flex-1 h-12 rounded-xl font-bold bg-amber-600 hover:bg-amber-700 text-white shadow-lg shadow-amber-200 transition-all active:scale-95" disabled={updatingFirmType || !tempFirmType} onClick={handleUpdateFirmType} data-testid="onboarding-firm-type-submit">{updatingFirmType ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Update Type'}</Button>
<Button className="flex-1 h-12 rounded-xl font-bold bg-re-red hover:bg-re-red-hover text-white shadow-lg shadow-red-200 transition-all active:scale-95" disabled={updatingFirmType || !tempFirmType} onClick={handleUpdateFirmType} data-testid="onboarding-firm-type-submit">{updatingFirmType ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Update Type'}</Button>
</div>
</div>
</DialogContent>

View File

@ -1,4 +1,4 @@
import { AlertCircle, CheckCircle, ClipboardList, Download, Eye, FileText, ShieldAlert, ShieldCheck, Upload } from 'lucide-react';
import { AlertCircle, CheckCircle, ClipboardList, Download, Eye, FileText, ShieldCheck, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { onboardingService } from '@/services/onboarding.service';
import { cn, formatDateTime } from '@/components/ui/utils';
@ -8,13 +8,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Props {
application: any;
currentUser: any;
documents: any[];
fddAgencies: any[];
selectedAgencyId: string;
setSelectedAgencyId: (v: string) => void;
isAssigningAgency: boolean;
handleAssignAgency: () => void;
setPreviewDoc: (d: any) => void;
setShowPreviewModal: (v: boolean) => void;
setIsUploading: (v: boolean) => void;
@ -24,13 +18,7 @@ interface Props {
export function ApplicationDetailsFddAuditContent({
application,
currentUser,
documents,
fddAgencies,
selectedAgencyId,
setSelectedAgencyId,
isAssigningAgency,
handleAssignAgency,
setPreviewDoc,
setShowPreviewModal,
setIsUploading,
@ -82,47 +70,6 @@ export function ApplicationDetailsFddAuditContent({
The Financial Due Diligence process has not been initiated for this application yet.
</p>
</div>
{(currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin') && (
<Card className="border-amber-100 bg-amber-50/30 overflow-hidden rounded-2xl" data-testid="onboarding-fdd-initiate-card">
<CardHeader className="pb-2">
<CardTitle className="text-xs font-black uppercase tracking-widest text-amber-800 flex items-center gap-2">
<ShieldAlert className="w-4 h-4" />
Initiate FDD Audit
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label className="text-[10px] font-black uppercase tracking-widest text-slate-400 mb-1.5 block">Select FDD Agency <span className="text-red-500">*</span></label>
<select
className="w-full h-11 bg-white border border-slate-200 rounded-xl px-4 text-sm font-medium focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500 outline-none transition-all shadow-sm"
value={selectedAgencyId}
onChange={(e) => setSelectedAgencyId(e.target.value)}
data-testid="onboarding-fdd-agency-select"
>
<option value="">Choose partner agency...</option>
{(fddAgencies || []).map((agency: any) => (
<option key={agency.id} value={agency.id} data-testid={`onboarding-fdd-agency-option-${agency.id}`}>
{agency.fullName || agency.name} ({agency.email})
</option>
))}
</select>
</div>
<div className="flex items-end">
<Button
className="bg-slate-900 text-white hover:bg-slate-800 font-black text-[10px] uppercase tracking-widest px-8 h-11 border-none shadow-lg shadow-slate-900/10 transition-all active:scale-[0.98]"
onClick={handleAssignAgency}
disabled={isAssigningAgency || !selectedAgencyId}
data-testid="onboarding-fdd-assign-button"
>
{isAssigningAgency ? 'Assigning...' : 'Assign & Start Audit'}
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
@ -132,8 +79,8 @@ export function ApplicationDetailsFddAuditContent({
{hasAssignment && (
<div className="flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl mb-6" data-testid="onboarding-fdd-assignment-banner">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 rounded-lg">
<ShieldCheck className="w-5 h-5 text-amber-600" />
<div className="p-2 bg-red-50 rounded-lg">
<ShieldCheck className="w-5 h-5 text-re-red" />
</div>
<div>
<h4 className="text-sm font-bold text-slate-900">FDD Assignment Active</h4>
@ -230,7 +177,7 @@ export function ApplicationDetailsFddAuditContent({
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4" data-testid="onboarding-fdd-support-docs-grid">
{(documents || []).filter(isFddSupportDoc).map((doc: any, index: number) => (
<div key={doc.id} className="group bg-white border border-slate-200 rounded-xl p-4 flex items-center justify-between hover:border-amber-400 transition-all hover:shadow-md" data-testid={`onboarding-fdd-support-doc-${index}`}>
<div key={doc.id} className="group bg-white border border-slate-200 rounded-xl p-4 flex items-center justify-between hover:border-red-300 transition-all hover:shadow-md" data-testid={`onboarding-fdd-support-doc-${index}`}>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-50 flex items-center justify-center"><FileText className="w-5 h-5 text-slate-400" /></div>
<div className="overflow-hidden">
@ -239,13 +186,13 @@ export function ApplicationDetailsFddAuditContent({
</div>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-600 hover:bg-amber-50" onClick={() => {
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-re-red hover:bg-red-50" onClick={() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
window.open(`${baseUrl}/${doc.filePath}`, '_blank');
}} data-testid={`onboarding-fdd-support-doc-download-${index}`}>
<Download className="w-4 h-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-600 hover:bg-amber-50" onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} data-testid={`onboarding-fdd-support-doc-preview-${index}`}>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-re-red hover:bg-red-50" onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} data-testid={`onboarding-fdd-support-doc-preview-${index}`}>
<Eye className="w-4 h-4" />
</Button>
</div>

View File

@ -1,9 +1,12 @@
import { ArrowLeft, MessageSquare, ShieldAlert } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Application } from '@/lib/mock-data';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { SlaStatusSnapshot } from '@/services/sla.service';
interface ApplicationDetailsHeaderProps {
application: Application;
slaStatus?: SlaStatusSnapshot | null;
isNonResponsive: boolean;
isAdmin: boolean;
onBack: () => void;
@ -12,6 +15,7 @@ interface ApplicationDetailsHeaderProps {
export function ApplicationDetailsHeader({
application,
slaStatus,
isNonResponsive,
isAdmin,
onBack,
@ -58,12 +62,17 @@ export function ApplicationDetailsHeader({
<div className="truncate">
<h1 className="text-slate-900 truncate leading-tight" data-testid="onboarding-details-application-name">{application.name}</h1>
<p className="text-slate-600 truncate text-sm" data-testid="onboarding-details-registration-number">{application.registrationNumber}</p>
{slaStatus && (
<div className="mt-1">
<SlaBadge status={slaStatus} />
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
variant="outline"
className="relative hover:bg-amber-50 hover:border-amber-300 hover:text-amber-700 transition-all shadow-sm"
className="relative hover:bg-red-50 hover:border-red-300 hover:text-re-red-hover transition-all shadow-sm"
onClick={onOpenWorknotes}
data-testid="onboarding-details-view-work-notes"
>

View File

@ -1,9 +1,12 @@
import { useState } from 'react';
import {
AlertCircle,
Calendar,
CheckCircle,
ChevronDown,
ClipboardCheck,
Clock,
FileText,
GitBranch,
Info,
Lock,
@ -14,7 +17,12 @@ import {
XCircle,
Zap,
} from 'lucide-react';
import { RequestDocumentsModal } from './RequestDocumentsModal';
import { cn, formatDateTime } from '@/components/ui/utils';
import {
getRequestStatusBadgeSolidClass,
getStatusProgressBarClass,
} from '@/lib/statusProgressTheme';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@ -46,6 +54,14 @@ interface ApplicationDetailsSidebarProps {
currentUser: any;
handleGenerateDealerCodes: () => void;
onOpenAssignArchitectureModal: () => void;
onOpenAssignFdd: () => void;
showAssignFddModal: boolean;
setShowAssignFddModal: (value: boolean) => void;
fddAgencies: any[];
selectedAgencyId: string;
setSelectedAgencyId: (value: string) => void;
isAssigningAgency: boolean;
handleAssignAgency: () => void;
activeInterviewForUser: any;
hasSubmittedFeedback: boolean;
setSelectedInterviewForFeedback: (value: any) => void;
@ -62,8 +78,41 @@ interface ApplicationDetailsSidebarProps {
setParticipantType: (value: string) => void;
handleAddParticipant: () => void;
isAssigningParticipant: boolean;
documents?: any[];
documentConfigs?: any[];
}
// Statuses where the admin can request supporting documents from the prospect (post-LOI approval).
const REQUEST_DOCUMENTS_ALLOWED_STATUSES = new Set<string>([
'Security Deposit',
'Security Details',
'Payment Pending',
'LOI Issuance Pending',
'LOI Issued',
'Dealer Code Generation',
'Architecture Team Assigned',
'Architecture Document Upload',
'Architecture Team Completion',
'Statutory GST',
'Statutory PAN',
'Statutory Nodal',
'Statutory Check',
'Statutory Partnership',
'Statutory Firm Reg',
'Statutory Virtual Code',
'Statutory Domain',
'Statutory MSD',
'Statutory LOI Ack',
'LOA Pending',
]);
const REQUEST_DOCUMENTS_ALLOWED_ROLES = new Set<string>([
'DD Admin',
'Super Admin',
'DD Lead',
'DD Head',
]);
export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps) {
const {
application,
@ -80,6 +129,14 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
currentUser,
handleGenerateDealerCodes,
onOpenAssignArchitectureModal,
onOpenAssignFdd,
showAssignFddModal,
setShowAssignFddModal,
fddAgencies,
selectedAgencyId,
setSelectedAgencyId,
isAssigningAgency,
handleAssignAgency,
activeInterviewForUser,
hasSubmittedFeedback,
setSelectedInterviewForFeedback,
@ -96,8 +153,18 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
setParticipantType,
handleAddParticipant,
isAssigningParticipant,
documents = [],
documentConfigs = [],
} = props;
const [showRequestDocsModal, setShowRequestDocsModal] = useState(false);
const userRoleResolved =
currentUser?.roleCode || currentUser?.role || '';
const canRequestDocuments =
REQUEST_DOCUMENTS_ALLOWED_ROLES.has(userRoleResolved) &&
REQUEST_DOCUMENTS_ALLOWED_STATUSES.has(application?.status || '');
return (
<div className="space-y-6">
<Card data-testid="onboarding-details-summary-card">
@ -112,12 +179,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
<div>
<p className="text-slate-600">Current Status</p>
<Badge
className={cn(
"mt-1",
application.status === 'Onboarded' ? "bg-green-600 hover:bg-green-700 text-white" :
application.status === 'Rejected' ? "bg-red-600" :
"bg-amber-600"
)}
className={cn('mt-1', getRequestStatusBadgeSolidClass(application.status))}
data-testid="onboarding-details-summary-status"
>
{application.status}
@ -135,7 +197,12 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
<div>
<p className="text-slate-600">Progress</p>
<div className="flex items-center gap-2 mt-2">
<Progress value={application.progress} className="flex-1" data-testid="onboarding-details-summary-progress-bar" />
<Progress
value={application.progress}
className="flex-1 bg-red-50"
indicatorClassName={getStatusProgressBarClass(application.status)}
data-testid="onboarding-details-summary-progress-bar"
/>
<span className="text-slate-900" data-testid="onboarding-details-summary-progress-text">{application.progress}%</span>
</div>
</div>
@ -155,12 +222,12 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
</CardHeader>
<CardContent className="space-y-3">
{permissions.isLoaLocked && (
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800" data-testid="onboarding-details-loa-locked-alert">
<Lock className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-semibold">LOA approval locked</AlertTitle>
<AlertDescription className="text-amber-800">
<Alert variant="destructive" className="mb-4 bg-red-50 border-red-200 text-red-800" data-testid="onboarding-details-loa-locked-alert">
<Lock className="w-4 h-4 text-re-red" />
<AlertTitle className="text-red-900 font-semibold">LOA approval locked</AlertTitle>
<AlertDescription className="text-red-800">
<span className="font-medium">First Fill</span> (later-stage payment) must be verified by Finance
before LOA approval can proceed. This is separate from the initial security deposit before LOI Issued.
before LOA approval can proceed. This is separate from the initial Security Deposit before LOI Issued.
</AlertDescription>
</Alert>
)}
@ -177,29 +244,29 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
Finance has verified the <span className="font-medium">First Fill</span> payment. The application
status was <span className="font-medium">not</span> changed until you reach{' '}
<span className="font-medium">LOA Pending</span>. When you get there, LOA approval will not be
blocked by payment (same pattern as recording the initial security deposit before the LOI
blocked by payment (same pattern as recording the initial Security Deposit before the LOI
security step).
</AlertDescription>
</Alert>
)}
{permissions.isSecurityDetailsLocked && (
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800" data-testid="onboarding-details-security-locked-alert">
<Lock className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-semibold">Security Details approval locked</AlertTitle>
<AlertDescription className="text-amber-800">
<Alert variant="destructive" className="mb-4 bg-red-50 border-red-200 text-red-800" data-testid="onboarding-details-security-locked-alert">
<Lock className="w-4 h-4 text-re-red" />
<AlertTitle className="text-red-900 font-semibold">Security Deposit approval locked</AlertTitle>
<AlertDescription className="text-red-800">
Finance must verify the <span className="font-medium">Security Deposit</span> before this stage can be approved.
You can still use <span className="font-medium">Reject</span> if needed.
</AlertDescription>
</Alert>
)}
{['Security Details', 'Payment Pending'].includes(application.status) && (
{['Security Deposit', 'Security Details', 'Payment Pending'].includes(application.status) && (
<Alert className="mb-4 border-sky-200 bg-sky-50/90 text-sky-900" data-testid="onboarding-details-security-review-alert">
<Info className="h-4 w-4 text-sky-700" />
<AlertTitle className="text-sky-950 font-semibold">Security Details review</AlertTitle>
<AlertTitle className="text-sky-950 font-semibold">Security Deposit review</AlertTitle>
<AlertDescription className="text-sm text-sky-900/90 leading-relaxed">
Check the initial security deposit on the <span className="font-medium">Payments</span> tab (Finance
Check the initial Security Deposit on the <span className="font-medium">Payments</span> tab (Finance
may have already marked it verified). When satisfied, use <span className="font-medium">Approve</span>{' '}
to move to <span className="font-medium">LOI Issued</span>.
</AlertDescription>
@ -217,10 +284,10 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)}
{isAdmin && (application.status === 'Level 3 Approved' || application.status === 'FDD Verification') && (!application.fddAssignments || application.fddAssignments.length === 0) && (
<Alert className="mb-4 bg-amber-50 border-amber-200 text-amber-800" data-testid="onboarding-details-fdd-assignment-alert">
<AlertCircle className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-bold">FDD Assignment Required</AlertTitle>
<AlertDescription className="text-amber-800 font-medium">
<Alert className="mb-4 bg-red-50 border-red-200 text-red-800" data-testid="onboarding-details-fdd-assignment-alert">
<AlertCircle className="w-4 h-4 text-re-red" />
<AlertTitle className="text-red-900 font-bold">FDD Assignment Required</AlertTitle>
<AlertDescription className="text-red-800 font-medium">
This application is pending financial due diligence. Please assign an FDD Agency to proceed with the audit.
</AlertDescription>
</Alert>
@ -263,11 +330,23 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
</Button>
)}
{canRequestDocuments && (
<Button
variant="outline"
className="w-full border-amber-300 hover:bg-amber-50 text-amber-700"
onClick={() => setShowRequestDocsModal(true)}
data-testid="onboarding-details-request-documents"
>
<FileText className="w-4 h-4 mr-2" />
Request Documents
</Button>
)}
{currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role) &&
['Dealer Code Generation', 'LOA Pending', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion'].includes(application.status) && (
<>
{!application.dealerCode && (
<Button className="w-full bg-blue-600 hover:bg-blue-700" onClick={handleGenerateDealerCodes} data-testid="onboarding-details-generate-dealer-codes">
<Button className="w-full bg-re-red hover:bg-re-red-hover" onClick={handleGenerateDealerCodes} data-testid="onboarding-details-generate-dealer-codes">
<Zap className="w-4 h-4 mr-2" />
Generate Dealer Codes
</Button>
@ -276,7 +355,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
{application.dealerCode && !application.architectureAssignedTo && (
<Button
variant="outline"
className="w-full border-blue-200 hover:bg-blue-50 text-blue-700"
className="w-full border-red-200 hover:bg-red-50 text-re-red"
onClick={onOpenAssignArchitectureModal}
data-testid="onboarding-details-assign-architecture"
>
@ -287,6 +366,55 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
</>
)}
{isAdmin &&
['Level 3 Approved', 'Level 3 Recommended', 'FDD Verification', 'FDD In Progress'].includes(application.status) &&
(!application.fddAssignments || application.fddAssignments.length === 0) && (
<Dialog open={showAssignFddModal} onOpenChange={setShowAssignFddModal}>
<Button
variant="outline"
className="w-full border-purple-200 hover:bg-purple-50 text-purple-700"
onClick={onOpenAssignFdd}
data-testid="onboarding-details-assign-fdd"
>
<ClipboardCheck className="w-4 h-4 mr-2" />
Assign FDD
</Button>
<DialogContent data-testid="onboarding-details-assign-fdd-modal">
<DialogHeader>
<DialogTitle>Assign FDD Agency</DialogTitle>
<DialogDescription>
Select an FDD partner agency to perform the financial due diligence audit for this application.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>FDD Agency</Label>
<Select value={selectedAgencyId} onValueChange={setSelectedAgencyId}>
<SelectTrigger className="mt-2" data-testid="onboarding-details-assign-fdd-select">
<SelectValue placeholder={fddAgencies?.length ? 'Choose partner agency...' : 'No agencies available'} />
</SelectTrigger>
<SelectContent>
{(fddAgencies || []).map((agency: any) => (
<SelectItem key={agency.id} value={agency.id}>
{agency.fullName || agency.name} ({agency.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
className="w-full bg-re-red hover:bg-re-red-hover font-bold h-11"
onClick={handleAssignAgency}
disabled={isAssigningAgency || !selectedAgencyId}
data-testid="onboarding-details-assign-fdd-submit"
>
{isAssigningAgency ? 'Assigning...' : 'Assign Agency'}
</Button>
</div>
</DialogContent>
</Dialog>
)}
{activeInterviewForUser && !hasSubmittedFeedback && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@ -394,7 +522,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
</Select>
</div>
<Button
className="w-full bg-amber-600 hover:bg-amber-700 font-bold h-11"
className="w-full bg-re-red hover:bg-re-red-hover font-bold h-11"
onClick={handleAddParticipant}
disabled={isAssigningParticipant}
data-testid="onboarding-details-assign-user-submit"
@ -408,6 +536,15 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
</CardContent>
</Card>
)}
<RequestDocumentsModal
open={showRequestDocsModal}
onClose={() => setShowRequestDocsModal(false)}
applicationId={application?.id || ''}
applicantName={application?.name || application?.applicantName || 'the prospect'}
documentConfigs={documentConfigs}
uploadedDocuments={documents}
/>
</div>
);
}

View File

@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { toast } from 'sonner';
import {
AlertCircle,
@ -9,6 +10,7 @@ import {
Clock,
ClipboardList,
Download,
Eye,
FileText,
GitBranch,
Lock,
@ -18,6 +20,12 @@ import {
User,
} from 'lucide-react';
import { cn, formatDateTime } from '@/components/ui/utils';
import {
getPercentProgressBadgeSolidClass,
getPercentProgressBarClass,
getStatusProgressBadgeSolidClass,
getStatusProgressBarClass,
} from '@/lib/statusProgressTheme';
import QuestionnaireResponseView from '../QuestionnaireResponseView';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
@ -49,7 +57,7 @@ interface ApplicationDetailsTabsProps {
setShowDocumentsModal: (value: boolean) => void;
setShowUploadForm: (value: boolean) => void;
handleRetriggerEvaluators: () => void;
handleCancelInterview: (interviewId: any) => void;
handleRescheduleInterview: (interview: any) => void;
setSelectedEvaluationForView: (value: any) => void;
setShowFeedbackDetailsModal: (value: boolean) => void;
renderFddAuditContent: () => React.ReactNode;
@ -58,6 +66,11 @@ interface ApplicationDetailsTabsProps {
eorChecklist: any[];
setUploadDocType: (value: string) => void;
isAdmin: boolean;
/**
* Whether the viewer may see finance/process-sensitive tabs (FDD Audit
* and Payments). Restricted to DD-Admin / Super Admin by policy.
*/
canViewFinanceTabs: boolean;
fetchApplication: () => void;
fetchEorData: () => void;
deposits: any[];
@ -84,7 +97,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
setShowDocumentsModal,
setShowUploadForm,
handleRetriggerEvaluators,
handleCancelInterview,
handleRescheduleInterview,
setSelectedEvaluationForView,
setShowFeedbackDetailsModal,
renderFddAuditContent,
@ -93,6 +106,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
eorChecklist,
setUploadDocType,
isAdmin,
canViewFinanceTabs,
fetchApplication,
fetchEorData,
deposits,
@ -105,6 +119,15 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
auditLogActionBadgeClass,
} = props;
// If the viewer loses (or never had) access to FDD/Payments tabs but the
// active tab is one of them — e.g. via direct deep-link or stale state —
// bounce them back to the Progress tab so they don't see an empty pane.
useEffect(() => {
if (!canViewFinanceTabs && (activeTab === 'fdd' || activeTab === 'payments')) {
setActiveTab('progress');
}
}, [canViewFinanceTabs, activeTab, setActiveTab]);
const normalizeRole = (value: unknown): string =>
String(value || '')
.trim()
@ -125,15 +148,19 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Card data-testid="onboarding-details-tabs-container">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<CardHeader className="pb-4 px-4 sm:px-6">
<div className="overflow-x-auto custom-scrollbar-x -mx-4 px-4 sm:-mx-6 sm:px-6">
<div className="overflow-x-auto custom-scrollbar-x-slim -mx-4 px-4 sm:-mx-6 sm:px-6">
<TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1" data-testid="onboarding-tabs-list">
<TabsTrigger value="questionnaire" className="min-w-[120px]" data-testid="onboarding-tab-trigger-questionnaire">Questionnaire</TabsTrigger>
<TabsTrigger value="progress" className="min-w-[80px]" data-testid="onboarding-tab-trigger-progress">Progress</TabsTrigger>
<TabsTrigger value="documents" className="min-w-[100px]" data-testid="onboarding-tab-trigger-documents">Documents</TabsTrigger>
<TabsTrigger value="interviews" className="min-w-[100px]" data-testid="onboarding-tab-trigger-interviews">Interviews</TabsTrigger>
{canViewFinanceTabs && (
<TabsTrigger value="fdd" className="min-w-[120px]" data-testid="onboarding-tab-trigger-fdd">FDD Audit</TabsTrigger>
)}
<TabsTrigger value="eor" className="min-w-[120px]" data-testid="onboarding-tab-trigger-eor">EOR Checklist</TabsTrigger>
{canViewFinanceTabs && (
<TabsTrigger value="payments" className="min-w-[100px]" data-testid="onboarding-tab-trigger-payments">Payments</TabsTrigger>
)}
<TabsTrigger value="audit" className="min-w-[100px]" data-testid="onboarding-tab-trigger-audit">Audit Trail</TabsTrigger>
</TabsList>
</div>
@ -148,12 +175,17 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="text-slate-900">Application Journey</h3>
<Badge className="bg-amber-600" data-testid="onboarding-progress-percentage-badge">{application.progress}% Complete</Badge>
<Badge className={getStatusProgressBadgeSolidClass(application.status)} data-testid="onboarding-progress-percentage-badge">{application.progress}% Complete</Badge>
</div>
<Progress value={application.progress} className="h-3 mb-6" data-testid="onboarding-progress-bar" />
<Progress
value={application.progress}
className="h-3 mb-6 bg-status-progress-soft"
indicatorClassName={getStatusProgressBarClass(application.status)}
data-testid="onboarding-progress-bar"
/>
</div>
<div className="relative" data-testid="onboarding-progress-stages-container">
<div className="relative status-progress-ui" data-testid="onboarding-progress-stages-container">
{(() => {
const interviewRoleMap: Record<number, string[]> = {
1: ['DD-ZM', 'RBM'],
@ -235,7 +267,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-white",
approver.status === 'approved' ? "bg-green-500" : approver.status === 'rejected' ? "bg-red-500" : "bg-amber-400"
approver.status === 'approved' ? "bg-green-500" : approver.status === 'rejected' ? "bg-red-500" : "bg-status-progress"
)} data-testid={`onboarding-stage-approver-status-dot-${stageIndex}-${i}`} />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-slate-900 text-white text-[10px] rounded opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
@ -254,7 +286,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 relative ${stage.status === 'completed'
? 'bg-green-500 border-green-500 text-white shadow-md'
: stage.status === 'active'
? stage.isLocked ? 'bg-slate-400 border-slate-400 text-white' : 'bg-amber-500 border-amber-500 text-white animate-pulse-subtle'
? stage.isLocked ? 'bg-slate-400 border-slate-400 text-white' : 'bg-status-workflow-active border-status-workflow-active text-white animate-pulse-subtle'
: 'bg-white border-slate-300 text-slate-400 shadow-none'
}`} data-testid={`onboarding-progress-stage-icon-${index}`}>
{stage.isParallel ? (
@ -264,7 +296,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Lock className="w-5 h-5 text-white cursor-help" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-1.5 bg-slate-900 text-white text-[10px] rounded shadow-xl opacity-0 group-hover:opacity-100 pointer-events-none transition-all duration-200 whitespace-nowrap z-[100] border border-slate-700">
<div className="flex flex-col gap-1">
<span className="font-bold text-amber-400 flex items-center gap-1">
<span className="font-bold text-status-progress flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> Stage Locked
</span>
<span>{stage.lockMessage}</span>
@ -292,7 +324,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className="flex-1 pt-1">
<p className={cn(
"font-bold transition-colors",
stage.status === 'completed' ? "text-green-700" : stage.status === 'active' ? "text-amber-700" : "text-slate-900"
stage.status === 'completed' ? "text-green-700" : stage.status === 'active' ? "text-status-progress-muted" : "text-slate-900"
)} data-testid={`onboarding-progress-stage-name-${index}`}>{stage.name}</p>
{stage.description && (
<p className="text-slate-600 text-sm mt-0.5 leading-relaxed" data-testid={`onboarding-progress-stage-desc-${index}`}>{stage.description}</p>
@ -301,7 +333,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
{renderApprovers(stage.name as string, index)}
{stage.evaluators && stage.evaluators.length > 0 && !['LOI Approval', 'LOA', '1st Level Interview', '2nd Level Interview', '3rd Level Interview'].includes(stage.name as string) && (
<p className="text-amber-600 text-xs mt-1.5 flex items-center gap-1 bg-amber-50 w-fit px-2 py-0.5 rounded border border-amber-100" data-testid={`onboarding-progress-stage-evaluators-${index}`}>
<p className="text-status-progress text-xs mt-1.5 flex items-center gap-1 bg-status-progress-soft w-fit px-2 py-0.5 rounded border border-status-progress" data-testid={`onboarding-progress-stage-evaluators-${index}`}>
<User className="w-3 h-3" />
Evaluators: {stage.evaluators.join(' + ')}
</p>
@ -314,7 +346,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
5: 2,
6: 2,
8: 2,
12: 2
13: 2
};
const stageId = Number(stage.id);
const expectedCount = expectedMap[stageId];
@ -325,7 +357,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
5: 2,
6: 3,
8: 'LOI_APPROVAL',
12: 'LOA_APPROVAL',
13: 'LOA_APPROVAL',
};
const mappedStageCode = stageCodeById[stageId];
const actualCount = mappedStageCode ? getApproverStatus(mappedStageCode).length : (stage.evaluators?.length || 0);
@ -335,8 +367,8 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
if (expectedCount && actualCount < expectedCount && application.status !== 'Rejected' && isEligibleForWarning) {
return (
<div className="mt-2" data-testid={`onboarding-progress-stage-warning-${index}`}>
<Alert variant="destructive" className="py-2 px-3 border-amber-200 bg-amber-50 text-amber-800">
<AlertCircle className="h-4 w-4 text-amber-600" />
<Alert variant="destructive" className="py-2 px-3 border-red-200 bg-red-50 text-red-800">
<AlertCircle className="h-4 w-4 text-re-red" />
<AlertTitle className="text-xs font-semibold">Missing Evaluators</AlertTitle>
<AlertDescription className="text-xs">
{actualCount === 0
@ -346,7 +378,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button
variant="link"
size="sm"
className="h-auto p-0 ml-1 text-xs text-amber-700 underline"
className="h-auto p-0 ml-1 text-xs text-re-red-hover underline"
onClick={handleRetriggerEvaluators}
data-testid={`onboarding-progress-stage-retrigger-${index}`}
>
@ -367,13 +399,20 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
(!doc.stage && doc.documentType?.toLowerCase().includes(stage.name.toLowerCase().split(' ')[0]))
).length;
// Upload is allowed only on the currently active (and unlocked) stage.
const canUploadHere = stage.status === 'active' && !stage.isLocked;
if (stageDocsCount === 0 && !canUploadHere) {
return null;
}
return (
<div className="flex items-center gap-2 mt-1">
<button
onClick={() => {
setSelectedStage(stage.name);
setShowDocumentsModal(true);
if (stageDocsCount === 0) setShowUploadForm(true);
if (stageDocsCount === 0 && canUploadHere) setShowUploadForm(true);
}}
className="text-xs font-semibold text-blue-600 hover:text-blue-800 flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 border border-blue-100 hover:bg-blue-100 transition-all shadow-sm"
data-testid={`onboarding-progress-stage-docs-${index}`}
@ -452,7 +491,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className={`w-8 h-8 rounded-full flex items-center justify-center border-2 ${isDone
? `${branchColor === 'blue' ? 'bg-blue-500 border-blue-500' : 'bg-green-500 border-green-500'}`
: branchStage.status === 'active'
? 'bg-amber-500 border-amber-500 text-white shadow-sm'
? 'bg-status-workflow-active border-status-workflow-active text-white shadow-sm'
: 'bg-white border-slate-300 text-slate-400'
}`} data-testid={`onboarding-progress-branch-stage-icon-${branchKey}-${bsIdx}`}>
{isDone ? (
@ -470,12 +509,19 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<p className="text-slate-500 text-xs mt-0.5">{branchStage.description}</p>
)}
{(() => {
// Upload is allowed only on the currently active branch stage.
const canUploadHere = branchStage.status === 'active';
if (stageDocs.length === 0 && !canUploadHere) {
return null;
}
return (
<div className="flex items-center gap-2 mt-1">
<button
onClick={() => {
setSelectedStage(branchStage.name);
setShowDocumentsModal(true);
if (stageDocs.length === 0) setShowUploadForm(true);
if (stageDocs.length === 0 && canUploadHere) setShowUploadForm(true);
}}
className={cn(
"text-[10px] font-medium flex items-center gap-1 transition-colors",
@ -487,8 +533,10 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
{stageDocs.length > 0 ? `${stageDocs.length} Docs` : 'Upload'}
</button>
</div>
);
})()}
<p className="text-slate-400 text-[10px] mt-1" data-testid={`onboarding-progress-branch-stage-status-${branchKey}-${bsIdx}`}>
{isDone && branchStage.date ? `Done: ${formatDateTime(branchStage.date)}` : isDone && stageDocs.length > 0 ? `Uploaded: ${formatDateTime(stageDocs[0].updatedAt || stageDocs[0].createdAt)}` : branchStage.status === 'active' ? 'Evaluating' : 'Pending'}
{isDone && branchStage.date ? `Done: ${formatDateTime(branchStage.date)}` : isDone && stageDocs.length > 0 ? `Uploaded: ${formatDateTime(stageDocs[0].updatedAt || stageDocs[0].createdAt)}` : 'Pending'}
</p>
</div>
</>
@ -514,7 +562,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<TabsContent value="documents" className="space-y-4" data-testid="onboarding-tab-content-documents">
<div className="flex items-center justify-between">
<h3 className="text-slate-900">Uploaded Documents</h3>
<Button size="sm" className="bg-amber-600 hover:bg-amber-700" data-testid="onboarding-documents-upload-tab-button" onClick={() => {
<Button size="sm" className="bg-re-red hover:bg-re-red-hover" data-testid="onboarding-documents-upload-tab-button" onClick={() => {
setSelectedStage(null);
setShowDocumentsModal(true);
setShowUploadForm(true);
@ -524,7 +572,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</Button>
</div>
<div className="overflow-x-auto">
<div className="overflow-x-auto custom-scrollbar-x-slim">
<Table data-testid="onboarding-documents-table">
<TableHeader>
<TableRow>
@ -556,11 +604,17 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" data-testid={`onboarding-document-preview-${idx}`} onClick={() => {
setPreviewDoc(doc);
setShowPreviewModal(true);
}}>
<Eye className="w-3 h-3 text-slate-500" />
</Button>
<Button size="sm" variant="outline" data-testid={`onboarding-document-download-${idx}`} onClick={() => {
const baseUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000';
window.open(`${baseUrl}/${doc.filePath}`, '_blank');
}}>
<Download className="w-3 h-3" />
<Download className="w-3 h-3 text-slate-500" />
</Button>
</div>
</TableCell>
@ -574,7 +628,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<TabsContent value="interviews" className="space-y-6" data-testid="onboarding-tab-content-interviews">
<div>
<h3 className="text-slate-900 mb-4">Scheduled Interviews</h3>
<div className="overflow-x-auto">
<div className="overflow-x-auto custom-scrollbar-x-slim">
<Table data-testid="onboarding-interviews-scheduled-table">
<TableHeader>
<TableRow>
@ -620,11 +674,11 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button
variant="ghost"
size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-8 px-2"
data-testid={`onboarding-interview-cancel-${idx}`}
onClick={() => handleCancelInterview(interview.id)}
className="text-primary-600 hover:text-primary-700 hover:bg-primary-50 h-8 px-2"
data-testid={`onboarding-interview-reschedule-${idx}`}
onClick={() => handleRescheduleInterview(interview)}
>
Cancel
Reschedule
</Button>
)}
</TableCell>
@ -736,16 +790,23 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
)}
</TabsContent>
{canViewFinanceTabs && (
<TabsContent value="fdd" className="space-y-6" data-testid="onboarding-tab-content-fdd">
{renderFddAuditContent()}
</TabsContent>
)}
<TabsContent value="eor" className="space-y-4" data-testid="onboarding-tab-content-eor">
<TabsContent value="eor" className="space-y-4 status-progress-ui" data-testid="onboarding-tab-content-eor">
<div className="flex items-center justify-between mb-4">
<h3 className="text-slate-900">Essential Operating Requirements</h3>
<Badge className="bg-amber-600" data-testid="onboarding-eor-progress-badge">{Math.round(eorProgress)}% Complete</Badge>
<Badge className={getPercentProgressBadgeSolidClass(eorProgress)} data-testid="onboarding-eor-progress-badge">{Math.round(eorProgress)}% Complete</Badge>
</div>
<Progress value={eorProgress} className="h-3 mb-6" data-testid="onboarding-eor-progress-bar" />
<Progress
value={eorProgress}
className="h-3 mb-6 bg-status-progress-soft"
indicatorClassName={getPercentProgressBarClass(eorProgress)}
data-testid="onboarding-eor-progress-bar"
/>
<div className="space-y-3" data-testid="onboarding-eor-checklist">
{(eorData?.items || eorChecklist).map((item: any, idx: number) => {
@ -780,7 +841,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
{docType}
</span>
{hasDocument && !item.isCompliant && (
<Badge variant="outline" className="text-[10px] h-4 px-1.5 bg-amber-50 text-amber-600 border-amber-200 uppercase tracking-wider font-bold">
<Badge variant="outline" className="text-[10px] h-4 px-1.5 bg-red-50 text-re-red border-red-200 uppercase tracking-wider font-bold">
Needs Verification
</Badge>
)}
@ -841,7 +902,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
)}
{!hasDocument && (
<div className="p-2 text-slate-300 group-hover:text-amber-500 transition-colors" data-testid={`onboarding-eor-upload-hint-${idx}`}>
<div className="p-2 text-slate-300 group-hover:text-re-red transition-colors" data-testid={`onboarding-eor-upload-hint-${idx}`}>
<Upload className="w-4 h-4" />
</div>
)}
@ -888,6 +949,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
)}
</TabsContent>
{canViewFinanceTabs && (
<TabsContent value="payments" className="space-y-6" data-testid="onboarding-tab-content-payments">
<div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-slate-900">Security Deposits</h3>
@ -906,12 +968,12 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Card className={cn(
"border-l-4",
deposit?.status === 'Verified' ? "border-l-green-500" :
deposit?.status === 'Rejected' ? "border-l-red-500" : "border-l-amber-500"
deposit?.status === 'Rejected' ? "border-l-red-500" : "border-l-re-red"
)} data-testid="onboarding-payment-card-security">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded bg-amber-50 flex items-center justify-center text-amber-600">
<div className="w-8 h-8 rounded bg-red-50 flex items-center justify-center text-re-red">
<ClipboardList className="w-4 h-4" />
</div>
<span className="font-semibold text-slate-700">Security Deposit</span>
@ -919,7 +981,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Badge className={cn(
deposit?.status === 'Verified' ? "bg-green-100 text-green-700 hover:bg-green-100" :
deposit?.status === 'Rejected' ? "bg-red-100 text-red-700 hover:bg-red-100" :
"bg-amber-100 text-amber-700 hover:bg-amber-100"
"bg-red-50 text-re-red-hover hover:bg-red-50"
)} data-testid="onboarding-payment-status-security">
{deposit?.status || 'Awaiting'}
</Badge>
@ -960,7 +1022,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[10px] text-amber-600 hover:text-amber-700 hover:bg-amber-50"
className="h-6 px-2 text-[10px] text-re-red hover:text-re-red-hover hover:bg-red-50"
data-testid={`onboarding-payment-doc-view-security-${idx}`}
onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }}
>
@ -988,7 +1050,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Card className={cn(
"border-l-4",
deposit?.status === 'Verified' ? "border-l-green-500" :
deposit?.status === 'Rejected' ? "border-l-red-500" : "border-l-amber-500"
deposit?.status === 'Rejected' ? "border-l-red-500" : "border-l-re-red"
)} data-testid="onboarding-payment-card-first-fill">
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
@ -1001,7 +1063,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Badge className={cn(
deposit?.status === 'Verified' ? "bg-green-100 text-green-700 hover:bg-green-100" :
deposit?.status === 'Rejected' ? "bg-red-100 text-red-700 hover:bg-red-100" :
"bg-amber-100 text-amber-700 hover:bg-amber-100"
"bg-red-50 text-re-red-hover hover:bg-red-50"
)} data-testid="onboarding-payment-status-first-fill">
{deposit?.status || 'Awaiting'}
</Badge>
@ -1062,13 +1124,14 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
})()}
</div>
</TabsContent>
)}
<TabsContent value="audit" data-testid="onboarding-tab-content-audit">
<ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50">
<div className="space-y-2.5 p-3 pr-4" data-testid="onboarding-audit-logs-container">
{auditLoading ? (
<div className="flex items-center justify-center py-10" data-testid="onboarding-audit-loading">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-amber-600" />
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-re-red" />
<span className="ml-2 text-sm text-slate-500">Loading audit trail</span>
</div>
) : auditLogs.length === 0 ? (

View File

@ -0,0 +1,271 @@
import { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { CheckCircle2, FileQuestion, Loader2, Mail } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { onboardingService } from '@/services/onboarding.service';
/**
* Modal where an admin (DD Admin / Super Admin / DD Lead / DD Head) ticks the documents
* still missing for a prospect and dispatches one email per category. Uploaded docs
* are surfaced as disabled rows for transparency.
*/
interface RequestDocumentsModalProps {
open: boolean;
onClose: () => void;
applicationId: string;
applicantName: string;
documentConfigs: any[];
uploadedDocuments: any[];
}
type CategoryKey = 'LOI' | 'Statutory' | 'Architecture' | 'FDD' | 'Other';
const CATEGORY_LABEL: Record<CategoryKey, string> = {
LOI: 'LOI Documents',
Statutory: 'Statutory & Compliance',
Architecture: 'Architecture Inputs',
FDD: 'FDD / Financial',
Other: 'Other',
};
function categorize(stageCode?: string | null): CategoryKey {
const s = String(stageCode || '').toLowerCase();
if (s.startsWith('loi')) return 'LOI';
if (s.startsWith('statutory')) return 'Statutory';
if (s.startsWith('architecture')) return 'Architecture';
if (s.startsWith('fdd')) return 'FDD';
return 'Other';
}
export function RequestDocumentsModal({
open,
onClose,
applicationId,
applicantName,
documentConfigs,
uploadedDocuments,
}: RequestDocumentsModalProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const [dueDays, setDueDays] = useState<number>(14);
const [customMessage, setCustomMessage] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const uploadedSet = useMemo(
() => new Set((uploadedDocuments || []).map((d: any) => d.documentType)),
[uploadedDocuments]
);
const grouped = useMemo(() => {
const buckets: Record<CategoryKey, any[]> = {
LOI: [], Statutory: [], Architecture: [], FDD: [], Other: [],
};
// De-duplicate by documentType — the master may return multiple rows per type if the
// doc is reused across stages. We keep the first occurrence (typically the canonical one).
const seen = new Set<string>();
for (const cfg of documentConfigs || []) {
if (!cfg?.documentType || seen.has(cfg.documentType)) continue;
seen.add(cfg.documentType);
buckets[categorize(cfg.stageCode)].push(cfg);
}
return buckets;
}, [documentConfigs]);
const toggle = (docType: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(docType)) next.delete(docType);
else next.add(docType);
return next;
});
};
const handleSubmit = async () => {
const documentTypes = Array.from(selected);
if (documentTypes.length === 0) {
toast.warning('Pick at least one document to request');
return;
}
setSubmitting(true);
try {
const result: any = await onboardingService.requestProspectDocuments(applicationId, {
documentTypes,
dueDays,
customMessage: customMessage.trim() || undefined,
});
const emailsSent = (result?.emailsSent || []).filter((e: any) => e.status === 'sent');
const failed = (result?.emailsSent || []).filter((e: any) => e.status === 'failed');
const skipped = result?.skippedAlreadyUploaded || [];
if (emailsSent.length > 0) {
toast.success(
`Sent ${emailsSent.length} email${emailsSent.length === 1 ? '' : 's'} to ${applicantName}` +
(skipped.length ? `${skipped.length} already uploaded, skipped` : '')
);
} else if (skipped.length) {
toast.info(`No email sent — all selected documents were already uploaded.`);
}
if (failed.length) {
toast.error(`Some email categories failed: ${failed.map((f: any) => f.category).join(', ')}`);
}
setSelected(new Set());
setCustomMessage('');
onClose();
} catch (err: any) {
toast.error(err?.message || 'Failed to send document request');
} finally {
setSubmitting(false);
}
};
const totalSelectable = (Object.values(grouped) as any[][]).reduce((sum, list) => sum + list.length, 0);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="w-5 h-5 text-re-red" />
Request Documents from Prospect
</DialogTitle>
<DialogDescription>
Pick the documents you want {applicantName || 'the prospect'} to upload. We'll send one email per
category. Anything already uploaded is shown for context and skipped automatically.
</DialogDescription>
</DialogHeader>
{totalSelectable === 0 ? (
<div className="py-10 text-center text-slate-500 flex flex-col items-center gap-2">
<FileQuestion className="w-8 h-8" />
<p>No document configurations available for this application.</p>
</div>
) : (
<ScrollArea className="max-h-[55vh] pr-3">
<div className="space-y-5">
{(Object.keys(CATEGORY_LABEL) as CategoryKey[]).map((cat) => {
const items = grouped[cat];
if (items.length === 0) return null;
return (
<div key={cat} className="border rounded-lg p-3 bg-slate-50">
<h4 className="font-medium text-slate-700 mb-2 text-sm uppercase tracking-wide">
{CATEGORY_LABEL[cat]}
</h4>
<div className="space-y-2">
{items.map((cfg: any) => {
const isUploaded = uploadedSet.has(cfg.documentType);
return (
<div
key={`${cat}-${cfg.documentType}`}
className={`flex items-start gap-3 p-2 rounded ${
isUploaded ? 'bg-green-50 border border-green-100' : 'bg-white border border-slate-200'
}`}
>
{isUploaded ? (
<CheckCircle2 className="w-5 h-5 text-green-600 mt-0.5 shrink-0" />
) : (
<Checkbox
id={`req-doc-${cfg.documentType}`}
checked={selected.has(cfg.documentType)}
onCheckedChange={() => toggle(cfg.documentType)}
className="mt-0.5"
/>
)}
<Label
htmlFor={`req-doc-${cfg.documentType}`}
className={`flex-1 cursor-pointer text-sm leading-snug ${
isUploaded ? 'text-slate-500 line-through' : 'text-slate-800'
}`}
>
<span className="font-medium">{cfg.documentType}</span>
{cfg.isMandatory && !isUploaded && (
<Badge className="ml-2 bg-red-100 text-red-700 text-[10px]">Mandatory</Badge>
)}
{isUploaded && (
<Badge className="ml-2 bg-green-100 text-green-700 text-[10px]">Uploaded</Badge>
)}
{cfg.description && (
<div className="text-xs text-slate-500 mt-0.5">{cfg.description}</div>
)}
</Label>
</div>
);
})}
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-2 border-t">
<div className="sm:col-span-1">
<Label htmlFor="req-doc-due-days" className="text-xs text-slate-600">
Due in (days)
</Label>
<Input
id="req-doc-due-days"
type="number"
min={1}
max={60}
value={dueDays}
onChange={(e) => setDueDays(Math.max(1, Number(e.target.value) || 14))}
className="mt-1"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="req-doc-message" className="text-xs text-slate-600">
Custom message (optional)
</Label>
<Textarea
id="req-doc-message"
placeholder="Add a short note for the prospect…"
value={customMessage}
onChange={(e) => setCustomMessage(e.target.value)}
rows={2}
className="mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={submitting}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={submitting || selected.size === 0}
className="bg-re-red hover:bg-re-red/90 text-white"
>
{submitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Sending
</>
) : (
<>
<Mail className="w-4 h-4 mr-2" />
Send Request ({selected.size})
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -171,6 +171,6 @@ export function auditLogActionBadgeClass(action: string): string {
if (a === 'CREATED' || a.includes('APPROV') || a.includes('COMPLETE')) return 'border-emerald-200 bg-emerald-50/90 text-emerald-900';
if (a.includes('DOCUMENT') || a.includes('UPLOAD') || a.includes('ATTACHMENT')) return 'border-sky-200 bg-sky-50/80 text-sky-900';
if (a.includes('PAYMENT') || a.includes('SECURITY') || a.includes('DEPOSIT')) return 'border-violet-200 bg-violet-50/80 text-violet-900';
if (a.includes('FDD') || a.includes('QUESTIONNAIRE') || a.includes('INTERVIEW')) return 'border-amber-200 bg-amber-50/80 text-amber-900';
if (a.includes('FDD') || a.includes('QUESTIONNAIRE') || a.includes('INTERVIEW')) return 'border-red-200 bg-red-50/80 text-red-900';
return 'border-slate-200 bg-slate-50 text-slate-700';
}

View File

@ -17,10 +17,15 @@ interface UseApplicationDetailsAdminActionsParams {
participantType: string;
users: any[];
interviewDate: string;
setInterviewDate: Dispatch<SetStateAction<string>>;
interviewType: string;
setInterviewType: Dispatch<SetStateAction<string>>;
interviewMode: string;
setInterviewMode: Dispatch<SetStateAction<string>>;
meetingLink: string;
setMeetingLink: Dispatch<SetStateAction<string>>;
location: string;
setLocation: Dispatch<SetStateAction<string>>;
scheduledInterviewParticipants: any[];
uploadFile: File | null;
uploadDocType: string;
@ -45,6 +50,8 @@ interface UseApplicationDetailsAdminActionsParams {
setShowCancelInterviewModal: Dispatch<SetStateAction<boolean>>;
interviewIdToCancel: string;
setInterviewIdToCancel: Dispatch<SetStateAction<string>>;
interviewToReschedule: any;
setInterviewToReschedule: Dispatch<SetStateAction<any>>;
setIsCancellingInterview: Dispatch<SetStateAction<boolean>>;
setIsUploading: Dispatch<SetStateAction<boolean>>;
setShowUploadForm: Dispatch<SetStateAction<boolean>>;
@ -79,10 +86,15 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
participantType,
users,
interviewDate,
setInterviewDate,
interviewType,
setInterviewType,
interviewMode,
setInterviewMode,
meetingLink,
setMeetingLink,
location,
setLocation,
scheduledInterviewParticipants,
uploadFile,
uploadDocType,
@ -107,6 +119,8 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
setShowCancelInterviewModal,
interviewIdToCancel,
setInterviewIdToCancel,
interviewToReschedule,
setInterviewToReschedule,
setIsCancellingInterview,
setIsUploading,
setShowUploadForm,
@ -176,7 +190,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}, [currentUser, application, setUsers]);
const prefillInterviewParticipants = useCallback(() => {
if (!showScheduleModal || !application) return;
if (!showScheduleModal || !application || interviewToReschedule) return;
const levelNum = parseInt(interviewType.replace('level', '')) || 1;
const requiredRolesByLevel: Record<number, string[]> = {
1: ['DD-ZM', 'RBM'],
@ -233,7 +247,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}
});
setScheduledInterviewParticipants(unique);
}, [showScheduleModal, application, interviewType, setScheduledInterviewParticipants]);
}, [showScheduleModal, application, interviewType, interviewToReschedule, setScheduledInterviewParticipants]);
const handleScheduleInterview = async () => {
if (!interviewDate) {
@ -242,20 +256,32 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
}
try {
setIsScheduling(true);
await onboardingService.scheduleInterview({
const payload = {
applicationId: application?.id,
level: interviewType,
scheduledAt: interviewDate,
type: interviewMode === 'virtual' ? 'Virtual Interview' : 'Physical Interview',
location: interviewMode === 'virtual' ? meetingLink : location,
participants: scheduledInterviewParticipants.map((p) => p.id),
};
if (interviewToReschedule) {
await onboardingService.updateInterview(interviewToReschedule.id, {
...payload,
status: 'Scheduled',
});
toast.success('Interview rescheduled successfully');
} else {
await onboardingService.scheduleInterview(payload);
toast.success('Interview scheduled successfully');
}
setShowScheduleModal(false);
setInterviewToReschedule(null);
await fetchInterviews();
await fetchApplication();
} catch {
toast.error('Failed to schedule interview');
toast.error(interviewToReschedule ? 'Failed to reschedule interview' : 'Failed to schedule interview');
} finally {
setIsScheduling(false);
}
@ -266,6 +292,24 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
setShowCancelInterviewModal(true);
};
const handleRescheduleInterview = async (interview: any) => {
setInterviewToReschedule(interview);
setInterviewType(`level${interview.level}`);
setInterviewMode(interview.interviewType?.toLowerCase().includes('virtual') ? 'virtual' : 'physical');
setInterviewDate(interview.scheduleDate ? (() => {
const d = new Date(interview.scheduleDate);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
})() : '');
if (interview.interviewType?.toLowerCase().includes('virtual')) {
setMeetingLink(interview.linkOrLocation || '');
} else {
setLocation(interview.linkOrLocation || '');
}
const participants = (interview.participants || []).map((p: any) => p.user || p).filter(Boolean);
setScheduledInterviewParticipants(participants);
setShowScheduleModal(true);
};
const handleConfirmCancelInterview = async () => {
if (!interviewIdToCancel) return;
try {
@ -284,14 +328,26 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
const handleUpload = async () => {
if (!uploadFile || !uploadDocType) {
toast.warning('Please select a file and document type');
toast.warning('Please enter a document name and select a file');
return;
}
try {
setIsUploading(true);
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('documentType', uploadDocType);
const originalExt = uploadFile.name.match(/\.[^/.]+$/)?.[0] || '';
const typedName = uploadDocType.trim();
const customFileName = typedName.toLowerCase().endsWith(originalExt.toLowerCase())
? typedName
: `${typedName}${originalExt}`;
formData.append('file', uploadFile, customFileName);
// Document type is owned by the entry point, not a user-facing dropdown.
// For checklist-driven entry points (e.g. EOR items), use the checklist item's name
// so backend auto-linking (EOR compliance, architecture date, etc.) still works.
// Everything else uploads as a generic 'Other' document.
const checklistDocType = selectedStage?.startsWith('EOR: ')
? selectedStage.replace(/^EOR:\s*/, '')
: null;
formData.append('documentType', checklistDocType || 'Other');
if (selectedStage) formData.append('stage', selectedStage);
await onboardingService.uploadDocument(applicationId, formData);
toast.success('Document uploaded successfully');
@ -371,7 +427,8 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
case 'Level 3 Interview Pending': newStatus = 'Level 3 Approved'; break;
case 'Level 3 Approved': newStatus = 'FDD Verification'; break;
case 'FDD Verification': newStatus = 'LOI In Progress'; break;
case 'LOI In Progress': newStatus = 'Security Details'; break;
case 'LOI In Progress': newStatus = 'Security Deposit'; break;
case 'Security Deposit':
case 'Security Details':
case 'Payment Pending': newStatus = 'LOI Issued'; break;
case 'LOI Issued': newStatus = 'Dealer Code Generation'; break;
@ -606,6 +663,7 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
fetchUsers,
maybeFetchUsersForModal,
handleScheduleInterview,
handleRescheduleInterview,
handleCancelInterview,
handleConfirmCancelInterview,
handleUpload,

View File

@ -84,7 +84,9 @@ export function useApplicationDetailsData({ applicationId }: UseApplicationDetai
level3InterviewDate: getStageDate('3rd Level Interview', 'Level 3 Approved'),
fddDate: getStageDate('FDD', 'FDD Verification'),
loiApprovalDate: getStageDate('LOI Approval', 'LOI In Progress'),
securityDetailsDate: getStageDate('Security Details', 'Security Details'),
securityDetailsDate:
getStageDate('Security Deposit', 'Security Deposit') ||
getStageDate('Security Details', 'Security Details'),
loiIssueDate: getStageDate('LOI Issue', 'LOI Issued'),
dealerCodeDate: getStageDate('Dealer Code Generation', 'Dealer Code Generation'),
architectureAssignedDate: getStageDate('Architecture Team Assigned', 'Architecture Team Assigned'),

View File

@ -6,6 +6,106 @@ interface UseApplicationDetailsPermissionsParams {
eorProgress: number;
}
function sameUserId(a: unknown, b: unknown): boolean {
if (a == null || b == null) return false;
return String(a).trim() === String(b).trim();
}
/** Backend / DB may use different casing (e.g. default `scheduled` vs created `Scheduled`). */
function normalizeInterviewStatus(status: unknown): string {
return String(status ?? '')
.trim()
.toLowerCase()
.replace(/\s+/g, ' ');
}
function isActiveInterviewStatus(status: unknown): boolean {
const n = normalizeInterviewStatus(status);
if (!n) return false;
return (
n === 'scheduled' ||
n === 'rescheduled' ||
n === 'pending' ||
n === 'in progress' ||
n === 'inprogress'
);
}
function isCompletedInterviewStatus(status: unknown): boolean {
return normalizeInterviewStatus(status) === 'completed';
}
function userIsInterviewParticipant(interview: any, userId: unknown): boolean {
if (!userId || !interview?.participants?.length) return false;
return interview.participants.some(
(p: any) => sameUserId(p.userId, userId) || sameUserId(p.user?.id, userId),
);
}
/** Which interview level the application is currently in (for feedback UI). */
function inferInterviewLevelFromApplicationStatus(status: unknown): number | undefined {
const s = String(status ?? '').trim();
const map: Record<string, number> = {
'Level 1 Interview Pending': 1,
'Level 1 Recommended': 1,
'Level 2 Interview Pending': 2,
'Level 2 Recommended': 2,
'Level 3 Interview Pending': 3,
'Level 3 Recommended': 3,
};
return map[s];
}
function normalizeRoleToken(value: unknown): string {
return String(value ?? '')
.trim()
.toLowerCase()
.replace(/[_\s-]+/g, '');
}
/**
* Allow feedback when scheduling forgot to attach this user as InterviewParticipant but they are a
* designated evaluator for this level (matches backend interview policy / schedule prefill roles).
*/
function userRoleEligibleForInterviewLevel(user: any, level: number): boolean {
if (!user) return false;
const privilegedRoles = ['Super Admin', 'DD Admin'];
if (
privilegedRoles.includes(String(user.role ?? '')) ||
privilegedRoles.includes(String(user.roleCode ?? ''))
) {
return true;
}
const rolesByLevel: Record<number, string[]> = {
1: ['DD-ZM', 'DD ZM', 'RBM'],
2: ['DD Lead', 'ZBH'],
3: ['NBH', 'DD Head'],
};
const allowedRaw = rolesByLevel[level];
if (!allowedRaw?.length) return false;
const allowed = allowedRaw.map(normalizeRoleToken);
const candidates = [
user.role,
user.roleCode,
user.roleName,
user.role?.roleCode,
user.role?.roleName,
]
.filter(Boolean)
.map(normalizeRoleToken);
for (const c of candidates) {
if (!c) continue;
for (const a of allowed) {
if (!a) continue;
if (c === a || c.includes(a) || a.includes(c)) return true;
}
}
return false;
}
export function useApplicationDetailsPermissions({
application,
interviews,
@ -15,24 +115,46 @@ export function useApplicationDetailsPermissions({
}: UseApplicationDetailsPermissionsParams) {
const interviewsList = Array.isArray(interviews) ? interviews : [];
const activeInterviewForUser = interviewsList.find((i: any) =>
['Scheduled', 'Rescheduled', 'Pending', 'In Progress'].includes(i.status) &&
i.participants?.some((p: any) => p.userId === currentUser?.id)
const stageInterviewLevel = inferInterviewLevelFromApplicationStatus(application?.status);
/** Prefer participant match on the interview row that matches current application stage when possible. */
const participantActiveInterview =
(stageInterviewLevel != null
? interviewsList.find(
(i: any) =>
isActiveInterviewStatus(i.status) &&
userIsInterviewParticipant(i, currentUser?.id) &&
Number(i.level) === stageInterviewLevel,
)
: undefined) ??
interviewsList.find(
(i: any) => isActiveInterviewStatus(i.status) && userIsInterviewParticipant(i, currentUser?.id),
);
const lastInterviewForUser = [...interviewsList].reverse().find((i: any) =>
i.participants?.some((p: any) => p.userId === currentUser?.id)
);
/** Same stage + active interview + evaluator role — covers missing / partial participant rows. */
const roleFallbackActiveInterview =
stageInterviewLevel != null &&
currentUser &&
userRoleEligibleForInterviewLevel(currentUser, stageInterviewLevel)
? interviewsList.find(
(i: any) =>
Number(i.level) === stageInterviewLevel && isActiveInterviewStatus(i.status),
)
: undefined;
const currentUserEvaluation = (activeInterviewForUser || lastInterviewForUser)?.evaluations?.find(
(e: any) => e.evaluatorId === currentUser?.id
);
const activeInterviewForUser = participantActiveInterview ?? roleFallbackActiveInterview;
const lastInterviewForUser = interviewsList.find((i: any) => userIsInterviewParticipant(i, currentUser?.id));
const currentUserEvaluation =
activeInterviewForUser?.evaluations?.find((e: any) => sameUserId(e.evaluatorId, currentUser?.id)) ??
lastInterviewForUser?.evaluations?.find((e: any) => sameUserId(e.evaluatorId, currentUser?.id));
const isInterviewCompleted = (level: number) =>
interviewsList.some((i: any) => Number(i.level) === level && i.status === 'Completed');
interviewsList.some((i: any) => Number(i.level) === level && isCompletedInterviewStatus(i.status));
const isInterviewActive = (level: number) =>
interviewsList.some((i: any) => Number(i.level) === level && i.status === 'Scheduled');
interviewsList.some((i: any) => Number(i.level) === level && isActiveInterviewStatus(i.status));
const hasSubmittedFeedback = !!currentUserEvaluation;
@ -66,7 +188,7 @@ export function useApplicationDetailsPermissions({
const isAdminRole = ['DD Admin', 'Super Admin', 'NBH', 'DD Lead', 'DD Head', 'Finance', 'Finance Admin', 'FDD', 'ZBH', 'RBM'].includes(currentUser.role);
const isAdministrativeStage = [
'Level 3 Approved', 'FDD Verification',
'LOI In Progress', 'Security Details', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack',
'LOI In Progress', 'Security Deposit', 'Security Details', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack',
'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion',
'Statutory GST', 'Statutory PAN', 'Statutory Nodal', 'Statutory Check',
'Statutory Partnership', 'Statutory Firm Reg', 'Statutory Rental',
@ -76,7 +198,7 @@ export function useApplicationDetailsPermissions({
const isLoaLocked = application.status === 'LOA Pending' && getDeposit('FIRST_FILL')?.status !== 'Verified';
const isSecurityDetailsLocked =
['Security Details', 'Payment Pending'].includes(application.status) &&
['Security Deposit', 'Security Details', 'Payment Pending'].includes(application.status) &&
getDeposit('SECURITY_DEPOSIT')?.status !== 'Verified';
const isFinalState = application.status === 'Onboarded' || application.status === 'Rejected';

View File

@ -6,14 +6,16 @@ interface UseApplicationDetailsStageDataParams {
interviews: any[];
eorData: any;
getDeposit: (type: string) => any;
documentConfigs?: any[];
}
export function useApplicationDetailsStageData({
application,
documents,
interviews,
interviews: _interviews,
eorData,
getDeposit,
documentConfigs = [],
}: UseApplicationDetailsStageDataParams) {
const normalizeRole = (value: unknown): string =>
String(value || '')
@ -38,33 +40,33 @@ export function useApplicationDetailsStageData({
return (documents || []).some((d) => d.documentType === docType);
};
const isInterviewScheduled = (level: number | string) => {
return (interviews || []).some((i) => (i.level === level || i.level === level.toString()) && i.status?.toLowerCase() === 'scheduled');
const getSecurityDepositStageStatus = (): ProcessStage['status'] => {
const rows = application.progressTracking || [];
const row =
rows.find((ps: any) => ps.stageName === 'Security Deposit') ||
rows.find((ps: any) => ps.stageName === 'Security Details');
return row?.status ? (row.status as ProcessStage['status']) : 'pending';
};
const getStageStatus = (stageName: string, fallbackLogic: () => ProcessStage['status']): ProcessStage['status'] => {
const getStageStatus = (stageName: string, fallbackStatus: ProcessStage['status'] = 'pending'): ProcessStage['status'] => {
const backendStage = (application.progressTracking || []).find((ps: any) => ps.stageName === stageName);
if (backendStage && (backendStage.status === 'completed' || backendStage.status === 'active')) {
return backendStage.status as any;
}
return fallbackLogic();
return backendStage?.status ? (backendStage.status as any) : fallbackStatus;
};
const processStages: ProcessStage[] = [
{ id: 1, name: 'Submitted', status: 'completed', date: application.submissionDate, description: 'Application submitted', documentsUploaded: 3 },
{
id: 2, name: 'Questionnaire', status: getStageStatus('Questionnaire', () =>
['Questionnaire Completed', 'Shortlisted', 'Level 1 Interview Pending', 'Level 1 Approved', 'Level 2 Interview Pending', 'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Questionnaire Pending' ? 'active' : 'pending'),
id: 2, name: 'Questionnaire', status: getStageStatus('Questionnaire'),
date: application.questionnaireDate, description: 'Questionnaire completed', documentsUploaded: 0
},
{
id: 3, name: 'Shortlist', status: getStageStatus('Shortlist', () => ['Shortlisted', 'Level 1 Interview Pending', 'Level 1 Approved', 'Level 2 Interview Pending', 'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Rejected', 'Onboarded'].includes(application.status) ? 'completed' : 'pending'),
id: 3, name: 'Shortlist', status: getStageStatus('Shortlist'),
date: application.shortlistDate, description: 'Application shortlisted by DD',
evaluators: Array.from(new Set((application.participants || []).filter((p: any) => p.participantType === 'assignee').map((p: any) => `${p.user?.fullName || p.user?.name || 'User'} (${p.user?.roleCode || p.participantType})`))),
documentsUploaded: 2
},
{
id: 4, name: '1st Level Interview', status: getStageStatus('1st Level Interview', () => ['Level 1 Approved', 'Level 2 Interview Pending', 'Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : (application.status === 'Level 1 Interview Pending' && isInterviewScheduled(1)) ? 'active' : 'pending'),
id: 4, name: '1st Level Interview', status: getStageStatus('1st Level Interview'),
date: application.level1InterviewDate, description: 'DD-ZM + RBM evaluation',
evaluators: Array.from(new Set(
(application.participants || [])
@ -80,7 +82,7 @@ export function useApplicationDetailsStageData({
documentsUploaded: 1
},
{
id: 5, name: '2nd Level Interview', status: getStageStatus('2nd Level Interview', () => ['Level 2 Approved', 'Level 2 Recommended', 'Level 3 Interview Pending', 'Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : (application.status === 'Level 2 Interview Pending' && isInterviewScheduled(2)) ? 'active' : 'pending'),
id: 5, name: '2nd Level Interview', status: getStageStatus('2nd Level Interview'),
date: application.level2InterviewDate, description: 'DD Lead + ZBH evaluation',
evaluators: Array.from(new Set(
(application.participants || [])
@ -96,7 +98,7 @@ export function useApplicationDetailsStageData({
documentsUploaded: 1
},
{
id: 6, name: '3rd Level Interview', status: getStageStatus('3rd Level Interview', () => ['Level 3 Approved', 'FDD Verification', 'LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : (application.status === 'Level 3 Interview Pending' && isInterviewScheduled(3)) ? 'active' : 'pending'),
id: 6, name: '3rd Level Interview', status: getStageStatus('3rd Level Interview'),
date: application.level3InterviewDate, description: 'NBH + DD Head evaluation',
evaluators: Array.from(new Set(
(application.participants || [])
@ -111,55 +113,91 @@ export function useApplicationDetailsStageData({
)),
documentsUploaded: 2
},
{ id: 7, name: 'FDD', status: getStageStatus('FDD', () => ['LOI In Progress', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'FDD Verification' ? 'active' : 'pending'), date: application.fddDate, description: 'Financial Due Diligence', documentsUploaded: 5 },
{ id: 7, name: 'FDD', status: getStageStatus('FDD'), date: application.fddDate, description: 'Financial Due Diligence', documentsUploaded: 5 },
{
id: 8, name: 'LOI Approval', status: getStageStatus('LOI Approval', () => ['Security Details', 'Payment Pending', 'LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOI In Progress' ? 'active' : 'pending'),
id: 8, name: 'LOI Approval', status: getStageStatus('LOI Approval'),
date: application.loiApprovalDate, description: 'Letter of Intent approval',
evaluators: Array.from(new Set((application.participants || []).filter((p: any) => p.metadata?.stageCode === 'LOI_APPROVAL' || p.metadata?.allAssignments?.includes('LOI_APPROVAL')).map(participantLabel))),
documentsUploaded: 1
},
{
id: 9, name: 'Security Details', status: getStageStatus('Security Details', () => ['LOI Issued', 'Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Security Details' || application.status === 'Payment Pending' ? 'active' : 'pending'),
date: application.securityDetailsDate, description: 'Security verification', documentsUploaded: 3
id: 9, name: 'Security Deposit', status: getSecurityDepositStageStatus(),
date: application.securityDetailsDate, description: 'Security Deposit verification', documentsUploaded: 3
},
(() => {
const loiDocConfigs = documentConfigs.filter((c: any) => c.stageCode === 'LOI Issue' || c.stageCode === 'LOI Documents');
const loiDocList = loiDocConfigs.length
? loiDocConfigs.map((c: any) => c.documentType)
: ['Letter of Intent', 'Signed LOI'];
const allUploaded = loiDocList.every((dt: string) =>
isDocumentUploaded(dt) || (dt === 'Letter of Intent' && isDocumentUploaded('LOI')) || (dt === 'Signed LOI' && isDocumentUploaded('LOI Signed Copy'))
);
const loiApprovalDone = getStageStatus('LOI Approval') === 'completed';
const status: ProcessStage['status'] = allUploaded ? 'completed' : loiApprovalDone ? 'active' : 'pending';
return {
id: 10, name: 'LOI Documents', status,
description: 'Upload Letter of Intent documents before issuance', isParallel: true,
branches: [
{
name: 'Documents Required', color: 'green', stages:
loiDocConfigs.length
? loiDocConfigs.map((c: any, i: number) => ({
id: `10a-${i}`,
name: c.documentType,
status: isDocumentUploaded(c.documentType) ? 'completed' : 'active',
description: c.isMandatory ? `Upload ${c.documentType} (Mandatory)` : `Upload ${c.documentType}`
}))
: [
{ id: '10a-1', name: 'Letter of Intent', status: isDocumentUploaded('Letter of Intent') || isDocumentUploaded('LOI') ? 'completed' : 'active', description: 'Letter of Intent document' },
{ id: '10a-2', name: 'Signed LOI', status: isDocumentUploaded('Signed LOI') || isDocumentUploaded('LOI Signed Copy') ? 'completed' : 'active', description: 'Signed Letter of Intent' },
]
}
]
} satisfies ProcessStage;
})(),
{
id: 11, name: 'LOI Issue', status: getStageStatus('LOI Issue'),
date: application.loiIssueDate, description: 'Letter of Intent issued'
},
{
id: 10, name: 'LOI Issue', status: getStageStatus('LOI Issue', () => ['Statutory LOI Ack', 'Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOI Issued' ? 'active' : 'pending'),
date: application.loiIssueDate, description: 'Letter of Intent issued', documentsUploaded: 1
},
{
id: 11, name: 'Dealer Code Generation', status: getStageStatus('Dealer Code Generation', () => (application.dealerCode || ['Dealer Code Generation', 'Architecture Work', 'Statutory Work', 'LOA Pending', 'LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status)) ? 'completed' : 'pending'),
id: 12, name: 'Dealer Code Generation', status: getStageStatus('Dealer Code Generation'),
date: application.dealerCodeDate, description: 'Dealer code generated and assigned', isParallel: true,
branches: [
{ name: 'Architectural Work', color: 'green', stages: [
{ id: '11a-1', name: 'Architecture Assignment', status: application.architectureAssignedTo ? 'completed' : application.status === 'Architecture Team Assigned' ? 'active' : 'pending', description: 'Assigned to architecture team' },
{ id: '11a-2', name: 'Site Plan Blueprint', status: isDocumentUploaded('Architecture Blueprint') ? 'completed' : application.architectureAssignedTo ? 'active' : 'pending', description: 'Blueprints and site plans' },
{ id: '11a-3', name: 'Architecture Work', status: application.architectureStatus === 'COMPLETED' ? 'completed' : (application.architectureStatus === 'IN_PROGRESS' || isDocumentUploaded('Architecture Blueprint')) ? 'active' : 'pending', description: 'Final architecture approval' },
]},
{ name: 'Statutory Documents', color: 'green', stages: [
{ id: '11b-1', name: 'GST', status: isDocumentUploaded('GST Certificate') || isDocumentUploaded('GST') ? 'completed' : 'active', description: 'GST certificate' },
{ id: '11b-2', name: 'PAN', status: isDocumentUploaded('PAN Card') || isDocumentUploaded('PAN') ? 'completed' : 'active', description: 'PAN card' },
{ id: '11b-3', name: 'Nodal Agreement', status: isDocumentUploaded('Nodal Agreement') ? 'completed' : 'active', description: 'Nodal agreement document' },
{ id: '11b-4', name: 'Cancelled Check', status: isDocumentUploaded('Cancelled Check') ? 'completed' : 'active', description: 'Cancelled check copy' },
{ id: '11b-5', name: 'Partnership Deed/LLP/MOA/AOA/COI', status: isDocumentUploaded('Partnership Deed/LLP/MOA/AOA/COI') || isDocumentUploaded('Partnership Deed') ? 'completed' : 'active', description: 'Business entity documents' },
{ id: '11b-6', name: 'Firm Registration Certificate', status: isDocumentUploaded('Firm Registration Certificate') || isDocumentUploaded('Firm Registration') ? 'completed' : 'active', description: 'Firm registration certificate' },
{ id: '11b-7', name: 'Rental agreement/ Lease agreement / Own/ Land agreement', status: isDocumentUploaded('Rental agreement/ Lease agreement / Own/ Land agreement') || isDocumentUploaded('Property Document') ? 'completed' : 'active', description: 'Property agreement document' },
{ id: '11b-8', name: 'Virtual Code', status: isDocumentUploaded('Virtual Code') || isDocumentUploaded('Virtual Code Confirmation') ? 'completed' : 'active', description: 'Virtual code availability' },
{ id: '11b-9', name: 'Domain ID', status: isDocumentUploaded('Domain ID') || isDocumentUploaded('Domain ID Setup') ? 'completed' : 'active', description: 'Domain ID setup' },
{ id: '11b-10', name: 'MSD Configuration', status: isDocumentUploaded('MSD Configuration') ? 'completed' : 'active', description: 'Microsoft Dynamics configuration' },
{ id: '11b-11', name: 'LOI Acknowledgement Copy', status: isDocumentUploaded('LOI Acknowledgement Copy') || isDocumentUploaded('LOI Acknowledgement') ? 'completed' : 'active', description: 'LOI acknowledgement copy' },
]},
{
name: 'Architectural Work', color: 'green', stages: [
{ id: '12a-1', name: 'Architecture Assignment', status: application.architectureAssignedTo ? 'completed' : application.status === 'Architecture Team Assigned' ? 'active' : 'pending', description: 'Assigned to architecture team' },
{ id: '12a-2', name: 'Site Plan Blueprint', status: isDocumentUploaded('Architecture Blueprint') ? 'completed' : application.architectureAssignedTo ? 'active' : 'pending', description: 'Blueprints and site plans' },
{ id: '12a-3', name: 'Architecture Work', status: application.architectureStatus === 'COMPLETED' ? 'completed' : (application.architectureStatus === 'IN_PROGRESS' || isDocumentUploaded('Architecture Blueprint')) ? 'active' : 'pending', description: 'Final architecture approval' },
]
},
{
id: 12, name: 'LOA', status: getStageStatus('LOA', () => ['EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'LOA Pending' ? 'active' : 'pending'),
name: 'Statutory Documents', color: 'green', stages: [
{ id: '12b-1', name: 'GST', status: isDocumentUploaded('GST Certificate') || isDocumentUploaded('GST') ? 'completed' : 'active', description: 'GST certificate' },
{ id: '12b-2', name: 'PAN', status: isDocumentUploaded('PAN Card') || isDocumentUploaded('PAN') ? 'completed' : 'active', description: 'PAN card' },
{ id: '12b-3', name: 'Nodal Agreement', status: isDocumentUploaded('Nodal Agreement') ? 'completed' : 'active', description: 'Nodal agreement document' },
{ id: '12b-4', name: 'Cancelled Check', status: isDocumentUploaded('Cancelled Check') ? 'completed' : 'active', description: 'Cancelled check copy' },
{ id: '12b-5', name: 'Partnership Deed/LLP/MOA/AOA/COI', status: isDocumentUploaded('Partnership Deed/LLP/MOA/AOA/COI') || isDocumentUploaded('Partnership Deed') ? 'completed' : 'active', description: 'Business entity documents' },
{ id: '12b-6', name: 'Firm Registration Certificate', status: isDocumentUploaded('Firm Registration Certificate') || isDocumentUploaded('Firm Registration') ? 'completed' : 'active', description: 'Firm registration certificate' },
{ id: '12b-7', name: 'Rental agreement/ Lease agreement / Own/ Land agreement', status: isDocumentUploaded('Rental agreement/ Lease agreement / Own/ Land agreement') || isDocumentUploaded('Property Document') ? 'completed' : 'active', description: 'Property agreement document' },
{ id: '12b-8', name: 'Virtual Code', status: isDocumentUploaded('Virtual Code') || isDocumentUploaded('Virtual Code Confirmation') ? 'completed' : 'active', description: 'Virtual code availability' },
{ id: '12b-9', name: 'Domain ID', status: isDocumentUploaded('Domain ID') || isDocumentUploaded('Domain ID Setup') ? 'completed' : 'active', description: 'Domain ID setup' },
{ id: '12b-10', name: 'MSD Configuration', status: isDocumentUploaded('MSD Configuration') ? 'completed' : 'active', description: 'Microsoft Dynamics configuration' },
{ id: '12b-11', name: 'LOI Acknowledgement Copy', status: isDocumentUploaded('LOI Acknowledgement Copy') || isDocumentUploaded('LOI Acknowledgement') ? 'completed' : 'active', description: 'LOI acknowledgement copy' },
]
},
]
},
{
id: 13, name: 'LOA', status: getStageStatus('LOA'),
isLocked: application.status === 'LOA Pending' && getDeposit('FIRST_FILL')?.status !== 'Verified',
lockMessage: 'First Fill (₹15L) must be verified by Finance before LOA Approval.',
evaluators: Array.from(new Set((application.participants || []).filter((p: any) => p.metadata?.stageCode === 'LOA_APPROVAL' || p.metadata?.allAssignments?.includes('LOA_APPROVAL')).map(participantLabel))),
description: 'Letter of Authorization'
description: 'Letter of Agreement'
},
{ id: 13, name: 'EOR Complete', status: getStageStatus('EOR Complete', () => ['Inauguration', 'Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'EOR Complete' ? 'active' : 'pending'), description: 'Essential Operating Requirements' },
{ id: 14, name: 'Inauguration', status: getStageStatus('Inauguration', () => ['Approved', 'Onboarded'].includes(application.status) ? 'completed' : application.status === 'Inauguration' ? 'active' : 'pending'), description: 'Dealership inauguration' },
{ id: 15, name: 'Dealership Active', status: getStageStatus('Onboarded', () => application.status === 'Onboarded' ? 'completed' : ['Inauguration', 'Approved'].includes(application.status) ? 'active' : 'pending'), description: 'Dealer profile active' },
{ id: 14, name: 'EOR Complete', status: getStageStatus('EOR Complete'), description: 'Essential Operating Requirements' },
{ id: 15, name: 'Inauguration', status: getStageStatus('Inauguration'), description: 'Dealership inauguration' },
{ id: 16, name: 'Dealership Active', status: getStageStatus('Onboarded'), description: 'Dealer profile active' },
];
const eorChecklist = [

View File

@ -19,6 +19,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [showCancelInterviewModal, setShowCancelInterviewModal] = useState(false);
const [interviewIdToCancel, setInterviewIdToCancel] = useState('');
const [interviewToReschedule, setInterviewToReschedule] = useState<any>(null);
const [showKTMatrixModal, setShowKTMatrixModal] = useState(false);
const [showLevel2FeedbackModal, setShowLevel2FeedbackModal] = useState(false);
const [showLevel3FeedbackModal, setShowLevel3FeedbackModal] = useState(false);
@ -61,6 +62,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
const [fddAgencies, setFddAgencies] = useState<any[]>([]);
const [selectedAgencyId, setSelectedAgencyId] = useState('');
const [isAssigningAgency, setIsAssigningAgency] = useState(false);
const [showAssignFddModal, setShowAssignFddModal] = useState(false);
const [isApproving, setIsApproving] = useState(false);
const [isRejecting, setIsRejecting] = useState(false);
const [ktMatrixScores, setKtMatrixScores] = useState<Record<string, number>>({});
@ -98,6 +100,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
showScheduleModal, setShowScheduleModal,
showCancelInterviewModal, setShowCancelInterviewModal,
interviewIdToCancel, setInterviewIdToCancel,
interviewToReschedule, setInterviewToReschedule,
showKTMatrixModal, setShowKTMatrixModal,
showLevel2FeedbackModal, setShowLevel2FeedbackModal,
showLevel3FeedbackModal, setShowLevel3FeedbackModal,
@ -140,6 +143,7 @@ export function useApplicationDetailsUIState({ initialTab = 'questionnaire' }: U
fddAgencies, setFddAgencies,
selectedAgencyId, setSelectedAgencyId,
isAssigningAgency, setIsAssigningAgency,
showAssignFddModal, setShowAssignFddModal,
isApproving, setIsApproving,
isRejecting, setIsRejecting,
ktMatrixScores, setKtMatrixScores,

View File

@ -26,7 +26,6 @@ import {
Download,
Grid3x3,
List,
Mail,
CheckCircle,
AlertCircle,
Loader2
@ -41,7 +40,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { ApplicationProgressBar } from '@/features/onboarding/components/ApplicationProgressBar';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
@ -193,9 +192,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
}
};
const handleBulkReminders = () => {
toast.success(`Reminder emails sent to ${selectedIds.length} applicant(s)`);
};
// For DD's All Applications page, only show initial statuses
const statusOptions: ApplicationStatus[] = [
@ -219,7 +215,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
'In Review': 'bg-slate-100 text-slate-800',
'Level 3 Approved': 'bg-green-100 text-green-800',
'FDD Verification': 'bg-indigo-100 text-indigo-800',
'Payment Pending': 'bg-amber-100 text-amber-800',
'Payment Pending': 'bg-red-50 text-red-800',
'LOI In Progress': 'bg-sky-100 text-sky-800',
'LOI Issued': 'bg-sky-100 text-sky-800',
'Dealer Code Generation': 'bg-purple-100 text-purple-800',
@ -240,15 +236,19 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
'EOR In Progress': 'bg-violet-100 text-violet-800',
'EOR Complete': 'bg-violet-100 text-violet-800',
'LOA Pending': 'bg-pink-100 text-pink-800',
'Inauguration': 'bg-amber-100 text-amber-800',
'Inauguration': 'bg-red-50 text-red-800',
'Approved': 'bg-green-100 text-green-800',
'Rejected': 'bg-red-100 text-red-800',
'Disqualified': 'bg-gray-100 text-gray-800',
'Onboarded': 'bg-emerald-100 text-emerald-800',
'LOI Approved': 'bg-sky-100 text-sky-800',
'Security Details In Progress': 'bg-amber-100 text-amber-800',
'Security Deposit In Progress': 'bg-red-50 text-red-800',
'Security Deposit Approved': 'bg-green-100 text-green-800',
'Security Deposit': 'bg-red-50 text-red-800',
/** Legacy overallStatus until DB migrated */
'Security Details In Progress': 'bg-red-50 text-red-800',
'Security Details Approved': 'bg-green-100 text-green-800',
'Security Details': 'bg-amber-100 text-amber-800',
'Security Details': 'bg-red-50 text-red-800',
'LOA Issued': 'bg-pink-100 text-pink-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
@ -257,12 +257,12 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
return (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4" data-testid="onboarding-all-apps-banner">
<div className="bg-red-50 border border-red-200 rounded-lg p-4" data-testid="onboarding-all-apps-banner">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<AlertCircle className="w-5 h-5 text-re-red flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-amber-900 mb-1">DD Workflow - Initial Application Review</h3>
<p className="text-amber-800">
<h3 className="text-red-900 mb-1">DD Workflow - Initial Application Review</h3>
<p className="text-red-800">
This page shows <strong>only applications that haven't been shortlisted yet</strong>. Review and select promising candidates using the <strong>Shortlist</strong> button.
Once shortlisted, applications will be removed from here and moved to the <strong>Dealership Requests</strong> page for further processing.
</p>
@ -331,7 +331,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
className={viewMode === 'grid' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={viewMode === 'grid' ? 'bg-re-red hover:bg-re-red-hover' : ''}
data-testid="onboarding-all-apps-grid-view-btn"
>
<Grid3x3 className="w-4 h-4 mr-2" />
@ -341,7 +341,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
variant={viewMode === 'table' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('table')}
className={viewMode === 'table' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={viewMode === 'table' ? 'bg-re-red hover:bg-re-red-hover' : ''}
data-testid="onboarding-all-apps-table-view-btn"
>
<List className="w-4 h-4 mr-2" />
@ -356,16 +356,6 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{selectedIds.length > 0 && (
<>
<Button
variant="outline"
size="sm"
onClick={handleBulkReminders}
data-testid="onboarding-all-apps-reminders-btn"
>
<Mail className="w-4 h-4 mr-2" />
Send Reminders ({selectedIds.length})
</Button>
<Button
size="sm"
onClick={handleShortlist}
@ -390,7 +380,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{/* Applications Grid/Table */}
{loading ? (
<div className="flex justify-center items-center h-96 bg-white rounded-lg border border-slate-200">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
) : viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-testid="onboarding-all-apps-grid-container">
@ -475,8 +465,12 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={app.progress} className="w-20" data-testid={`onboarding-all-apps-progress-bar-${idx}`} />
<span className="text-slate-600" data-testid={`onboarding-all-apps-progress-text-${idx}`}>{app.progress}%</span>
<ApplicationProgressBar
value={app.progress}
status={app.status}
showPercent
data-testid={`onboarding-all-apps-progress-bar-${idx}`}
/>
</div>
</TableCell>
<TableCell>

View File

@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { onboardingService } from '@/services/onboarding.service';
import { ApplicationDetailsHeader } from '@/features/onboarding/components/application-details/ApplicationDetailsHeader';
@ -27,6 +28,19 @@ export const ApplicationDetails = () => {
const { user: currentUser } = useSelector((state: RootState) => state.auth);
const applicationId = id || '';
const onBack = () => navigate(-1);
const [slaStatus, setSlaStatus] = useState<SlaStatusSnapshot | null>(null);
useEffect(() => {
if (!applicationId) return;
slaService
.getBatchStatus([{ entityType: 'application', entityId: applicationId }])
.then((res) => {
if (res?.success) {
setSlaStatus(res.data[`application:${applicationId}`] ?? null);
}
})
.catch(() => setSlaStatus(null));
}, [applicationId]);
const {
application,
@ -62,6 +76,7 @@ export const ApplicationDetails = () => {
showScheduleModal, setShowScheduleModal,
showCancelInterviewModal, setShowCancelInterviewModal,
interviewIdToCancel, setInterviewIdToCancel,
interviewToReschedule, setInterviewToReschedule,
showKTMatrixModal, setShowKTMatrixModal,
showLevel2FeedbackModal, setShowLevel2FeedbackModal,
showLevel3FeedbackModal, setShowLevel3FeedbackModal,
@ -104,6 +119,7 @@ export const ApplicationDetails = () => {
fddAgencies, setFddAgencies,
selectedAgencyId, setSelectedAgencyId,
isAssigningAgency, setIsAssigningAgency,
showAssignFddModal, setShowAssignFddModal,
isApproving, setIsApproving,
isRejecting, setIsRejecting,
ktMatrixScores, setKtMatrixScores,
@ -162,6 +178,18 @@ export const ApplicationDetails = () => {
currentUser?.role === 'Super Admin' || currentUser?.role === 'DD Admin' ||
currentUser?.role === 'NBH' || currentUser?.role === 'DD Head' ||
currentUser?.roleCode === 'NBH' || currentUser?.roleCode === 'DD_HEAD';
// FDD Audit and Payments tabs are finance-/process-sensitive. Per ops policy,
// only DD-Admin, Super Admin and Finance roles should see them — the broader
// `isAdmin` bucket (which also includes NBH and DD-Head) is too wide here.
const canViewFinanceTabs =
currentUser?.roleCode === 'Super Admin' ||
currentUser?.roleCode === 'DD Admin' ||
currentUser?.roleCode === 'Finance' ||
currentUser?.roleCode === 'Finance Admin' ||
currentUser?.role === 'Super Admin' ||
currentUser?.role === 'DD Admin' ||
currentUser?.role === 'Finance' ||
currentUser?.role === 'Finance Admin';
useEffect(() => {
const fetchConfigs = async () => {
@ -266,7 +294,7 @@ export const ApplicationDetails = () => {
handleRemoveInterviewer,
maybeFetchUsersForModal,
handleScheduleInterview,
handleCancelInterview,
handleRescheduleInterview,
handleConfirmCancelInterview,
handleUpload,
handleApprove,
@ -291,10 +319,15 @@ export const ApplicationDetails = () => {
participantType,
users,
interviewDate,
setInterviewDate,
interviewType,
setInterviewType,
interviewMode,
setInterviewMode,
meetingLink,
setMeetingLink,
location,
setLocation,
scheduledInterviewParticipants,
uploadFile,
uploadDocType,
@ -319,6 +352,8 @@ export const ApplicationDetails = () => {
setShowCancelInterviewModal,
interviewIdToCancel,
setInterviewIdToCancel,
interviewToReschedule,
setInterviewToReschedule,
setIsCancellingInterview,
setIsUploading,
setShowUploadForm,
@ -344,7 +379,7 @@ export const ApplicationDetails = () => {
if (loading && !application) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<Loader2 className="w-10 h-10 animate-spin text-amber-600" />
<Loader2 className="w-10 h-10 animate-spin text-re-red" />
</div>
);
}
@ -359,6 +394,7 @@ export const ApplicationDetails = () => {
interviews,
eorData,
getDeposit,
documentConfigs,
});
const {
@ -380,13 +416,7 @@ export const ApplicationDetails = () => {
const renderFddAuditContent = () => (
<ApplicationDetailsFddAuditContent
application={application}
currentUser={currentUser}
documents={documents}
fddAgencies={fddAgencies}
selectedAgencyId={selectedAgencyId}
setSelectedAgencyId={setSelectedAgencyId}
isAssigningAgency={isAssigningAgency}
handleAssignAgency={handleAssignAgency}
setPreviewDoc={setPreviewDoc}
setShowPreviewModal={setShowPreviewModal}
setIsUploading={setIsUploading}
@ -399,6 +429,7 @@ export const ApplicationDetails = () => {
<div className="space-y-6">
<ApplicationDetailsHeader
application={application}
slaStatus={slaStatus}
isNonResponsive={isNonResponsive}
isAdmin={isAdmin}
onBack={onBack}
@ -443,7 +474,7 @@ export const ApplicationDetails = () => {
setShowDocumentsModal={setShowDocumentsModal}
setShowUploadForm={setShowUploadForm}
handleRetriggerEvaluators={handleRetriggerEvaluators}
handleCancelInterview={handleCancelInterview}
handleRescheduleInterview={handleRescheduleInterview}
setSelectedEvaluationForView={setSelectedEvaluationForView}
setShowFeedbackDetailsModal={setShowFeedbackDetailsModal}
renderFddAuditContent={renderFddAuditContent}
@ -452,6 +483,7 @@ export const ApplicationDetails = () => {
eorChecklist={eorChecklist}
setUploadDocType={setUploadDocType}
isAdmin={isAdmin}
canViewFinanceTabs={canViewFinanceTabs}
fetchApplication={fetchApplication}
fetchEorData={fetchEorData}
deposits={deposits}
@ -487,6 +519,21 @@ export const ApplicationDetails = () => {
currentUser={currentUser}
handleGenerateDealerCodes={handleGenerateDealerCodes}
onOpenAssignArchitectureModal={() => setShowAssignArchitectureModal(true)}
onOpenAssignFdd={() => {
setSelectedAgencyId('');
fetchFddAgencies();
setShowAssignFddModal(true);
}}
showAssignFddModal={showAssignFddModal}
setShowAssignFddModal={setShowAssignFddModal}
fddAgencies={fddAgencies}
selectedAgencyId={selectedAgencyId}
setSelectedAgencyId={setSelectedAgencyId}
isAssigningAgency={isAssigningAgency}
handleAssignAgency={async () => {
await handleAssignAgency();
setShowAssignFddModal(false);
}}
activeInterviewForUser={activeInterviewForUser}
hasSubmittedFeedback={hasSubmittedFeedback}
setSelectedInterviewForFeedback={setSelectedInterviewForFeedback}
@ -503,6 +550,8 @@ export const ApplicationDetails = () => {
setParticipantType={setParticipantType}
handleAddParticipant={handleAddParticipant}
isAssigningParticipant={isAssigningParticipant}
documents={documents}
documentConfigs={documentConfigs}
/>
<ApplicationDetailsActionModals
@ -532,6 +581,8 @@ export const ApplicationDetails = () => {
setInterviewIdToCancel={setInterviewIdToCancel}
isCancellingInterview={isCancellingInterview}
handleConfirmCancelInterview={handleConfirmCancelInterview}
interviewToReschedule={interviewToReschedule}
setInterviewToReschedule={setInterviewToReschedule}
interviewType={interviewType}
setInterviewType={setInterviewType}
interviewMode={interviewMode}

View File

@ -1,7 +1,10 @@
import { useState, useEffect } from 'react';
import { toast } from 'sonner';
import { ApplicationStatus, Application } from '@/lib/mock-data';
import { formatDateTime } from '@/components/ui/utils';
import { onboardingService } from '@/services/onboarding.service';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@ -14,7 +17,8 @@ import {
import {
Search,
Download,
Mail
Mail,
Loader2
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
@ -26,7 +30,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { ApplicationProgressBar } from '@/features/onboarding/components/ApplicationProgressBar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { useSelector } from 'react-redux';
@ -53,12 +57,14 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const [statusFilter, setStatusFilter] = useState<string>(initialFilter || 'all');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<'date'>('date');
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [sortBy] = useState<'date'>('date');
const [showNewApplicationModal, setShowNewApplicationModal] = useState(false);
const [showMyAssignments, setShowMyAssignments] = useState(false);
// Real Data Integration
const [applications, setApplications] = useState<Application[]>([]);
const [slaByAppId, setSlaByAppId] = useState<Record<string, SlaStatusSnapshot | null>>({});
const [locations, setLocations] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
@ -84,7 +90,6 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
const applicationsData = response.data || [];
setPaginationMeta(response.meta);
// Map backend data to frontend Application interface
const mappedApps = applicationsData.map((app: any) => ({
id: app.id,
registrationNumber: app.applicationId || 'N/A',
@ -120,6 +125,23 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
}));
setApplications(mappedApps);
if (mappedApps.length > 0) {
slaService
.getBatchStatus(mappedApps.map((app: Application) => ({ entityType: 'application', entityId: app.id })))
.then((slaRes) => {
if (slaRes?.success) {
const map: Record<string, SlaStatusSnapshot | null> = {};
mappedApps.forEach((app: Application) => {
map[app.id] = slaRes.data[`application:${app.id}`] ?? null;
});
setSlaByAppId(map);
}
})
.catch(() => setSlaByAppId({}));
} else {
setSlaByAppId({});
}
// Extract unique locations for filtering - could be optimized to fetch once
if (locations.length === 0) {
const uniqueLocations = Array.from(new Set(mappedApps.map((app: Application) => app.preferredLocation))).filter(Boolean) as string[];
@ -153,51 +175,45 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
}
};
const handleBulkReminders = () => {
alert(`Sending reminders to ${selectedIds.length} applicants`);
const handleBulkReminders = async () => {
if (selectedIds.length === 0) return;
try {
setIsSendingReminders(true);
const res = await onboardingService.sendBulkReminders(selectedIds);
if (res.success) {
toast.success(res.message || `Reminder emails sent to ${selectedIds.length} applicant(s)`);
setSelectedIds([]);
}
} catch (error: any) {
console.error('Failed to send reminders:', error);
toast.error(error.message || 'Failed to send reminders');
} finally {
setIsSendingReminders(false);
}
};
const handleExport = () => {
alert('Exporting applications to CSV...');
};
/**
* Status badge classes keep the palette restrained to the brand
* (re-red + black + slate). A rainbow palette across status pills
* fights the rest of the UI, so we collapse to three buckets:
* - positive end-states (Approved / Onboarded / Completed) black
* - terminal negative (Rejected / Disqualified) muted brand red
* - everything else (in-progress / pending / default) light slate
*/
const getStatusColor = (status: string) => {
const statusColors: Record<string, string> = {
'Submitted': 'bg-slate-500',
'Questionnaire Pending': 'bg-orange-500',
'Questionnaire Completed': 'bg-blue-500',
'Shortlisted': 'bg-cyan-500',
'Level 1 Pending': 'bg-amber-500',
'Level 1 Approved': 'bg-green-500',
'Level 2 Pending': 'bg-purple-500',
'Level 2 Approved': 'bg-green-600',
'Level 2 Recommended': 'bg-teal-500',
'Level 3 Pending': 'bg-indigo-500',
'FDD Verification': 'bg-violet-500',
'Payment Pending': 'bg-yellow-500',
'LOI Issued': 'bg-lime-500',
'Dealer Code Generation': 'bg-fuchsia-500',
'Architecture Team Assigned': 'bg-blue-500',
'Architecture Document Upload': 'bg-blue-500',
'Architecture Team Completion': 'bg-blue-500',
'Statutory GST': 'bg-emerald-500',
'Statutory PAN': 'bg-emerald-500',
'Statutory Nodal': 'bg-emerald-500',
'Statutory Check': 'bg-emerald-500',
'Statutory Partnership': 'bg-emerald-500',
'Statutory Firm Reg': 'bg-emerald-500',
'Statutory Virtual Code': 'bg-emerald-500',
'Statutory Domain': 'bg-emerald-500',
'Statutory MSD': 'bg-emerald-500',
'Statutory LOI Ack': 'bg-emerald-500',
'EOR In Progress': 'bg-sky-500',
'LOA Pending': 'bg-emerald-500',
'Approved': 'bg-green-700',
'Rejected': 'bg-red-500',
'Disqualified': 'bg-red-700'
};
return statusColors[status] || 'bg-slate-500';
const s = String(status || '');
if (s === 'Rejected' || s === 'Disqualified' || s.includes('Rejected')) {
return 'bg-red-50 text-re-red-hover border border-red-200';
}
if (s === 'Approved' || s === 'Onboarded' || s === 'Completed') {
return 'bg-slate-900 text-white border border-transparent';
}
return 'bg-slate-200 text-slate-800 border border-slate-300';
};
return (
@ -257,14 +273,6 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
<Label htmlFor="my-assignments" data-testid="onboarding-applications-assignments-label">My Assignments Only</Label>
</div>
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
<SelectTrigger className="w-full lg:w-40" data-testid="onboarding-applications-sort-select">
<SelectValue placeholder="Sort By" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date">Date</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-wrap items-center gap-3 mt-4">
@ -283,9 +291,14 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
variant="outline"
size="sm"
onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-applications-reminders-button"
>
{isSendingReminders ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length})
</Button>
)}
@ -311,6 +324,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
<TableHead>Name</TableHead>
<TableHead>Preferred Location</TableHead>
<TableHead>Status</TableHead>
<TableHead>SLA</TableHead>
<TableHead>Applicant Location</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Applied On</TableHead>
@ -335,14 +349,19 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
{app.status}
</Badge>
</TableCell>
<TableCell>
<SlaBadge status={slaByAppId[app.id]} compact />
</TableCell>
<TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-application-addr-${idx}`}>
{app.residentialAddress}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Progress value={app.progress} className="h-2 w-20" data-testid={`onboarding-application-progress-bar-${idx}`} />
<span className="text-slate-600" data-testid={`onboarding-application-progress-text-${idx}`}>{app.progress}%</span>
</div>
<ApplicationProgressBar
value={app.progress}
status={app.status}
showPercent
data-testid={`onboarding-application-progress-bar-${idx}`}
/>
</TableCell>
<TableCell data-testid={`onboarding-application-date-${idx}`}>
{formatDateTime(app.submissionDate)}
@ -452,7 +471,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
<Button variant="outline" onClick={() => setShowNewApplicationModal(false)} data-testid="onboarding-new-app-cancel">
Cancel
</Button>
<Button className="bg-amber-600 hover:bg-amber-700" data-testid="onboarding-new-app-submit">
<Button className="bg-re-red hover:bg-re-red-hover" data-testid="onboarding-new-app-submit">
Create Application
</Button>
</div>

View File

@ -408,20 +408,20 @@ export function FDDApplicationDetails() {
{/* SECTION 2: MY SUBMISSIONS */}
<div data-testid="onboarding-fdd-details-my-submissions">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500"></div>
<div className="w-1.5 h-1.5 rounded-full bg-red-500"></div>
My Uploaded Reports
</p>
<div className="space-y-2">
{application.uploadedDocuments?.filter((d: any) => d.uploader?.roleCode === 'FDD').map((doc: any, i: number) => (
<div key={i} className="p-3 border border-amber-100 bg-amber-50/30 rounded flex items-center justify-between hover:bg-amber-50 transition-all group" data-testid={`onboarding-fdd-details-my-report-row-${i}`}>
<div key={i} className="p-3 border border-red-100 bg-red-50/30 rounded flex items-center justify-between hover:bg-red-50 transition-all group" data-testid={`onboarding-fdd-details-my-report-row-${i}`}>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-amber-100 flex items-center justify-center text-amber-500">
<div className="w-8 h-8 rounded bg-red-50 flex items-center justify-center text-re-red">
<FileText className="w-4 h-4" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="text-xs font-bold text-slate-900" data-testid={`onboarding-fdd-details-my-report-name-${i}`}>{doc.originalName || doc.fileName}</p>
<span className="text-[8px] bg-amber-500 text-white px-1 py-0.5 rounded uppercase font-bold tracking-tighter">YOUR AUDIT REPORT</span>
<span className="text-[8px] bg-red-500 text-white px-1 py-0.5 rounded uppercase font-bold tracking-tighter">YOUR AUDIT REPORT</span>
</div>
<p className="text-[10px] text-slate-400 font-medium" data-testid={`onboarding-fdd-details-my-report-meta-${i}`}>
{doc.documentType} {formatDateTime(doc.createdAt)}
@ -433,7 +433,7 @@ export function FDDApplicationDetails() {
<button
type="button"
onClick={() => handlePreview(doc)}
className="p-1.5 hover:bg-white rounded text-slate-400 hover:text-amber-600 transition-all"
className="p-1.5 hover:bg-white rounded text-slate-400 hover:text-re-red transition-all"
data-testid={`onboarding-fdd-details-my-report-preview-${i}`}
>
<Eye className="w-4 h-4" />
@ -576,22 +576,22 @@ export function FDDApplicationDetails() {
<Dialog open={showFinalizeModal} onOpenChange={setShowFinalizeModal}>
<DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl" data-testid="onboarding-fdd-details-finalize-modal">
<div className="bg-slate-950 p-6 flex items-center justify-center relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-amber-600/20 to-transparent" />
<div className="w-16 h-16 bg-amber-600/20 rounded-full flex items-center justify-center animate-pulse relative z-10">
<ShieldCheck className="w-8 h-8 text-amber-500" />
<div className="absolute inset-0 bg-gradient-to-br from-re-red/20 to-transparent" />
<div className="w-16 h-16 bg-re-red/20 rounded-full flex items-center justify-center animate-pulse relative z-10">
<ShieldCheck className="w-8 h-8 text-re-red" />
</div>
</div>
<div className="p-8 space-y-4">
<DialogHeader>
<DialogTitle className="text-2xl font-bold text-slate-900 text-center" data-testid="onboarding-fdd-details-finalize-title">Submit Audit Report</DialogTitle>
<DialogDescription className="text-slate-500 text-center pt-2 leading-relaxed text-base" data-testid="onboarding-fdd-details-finalize-desc">
You are about to submit your final findings. This action will <span className="font-bold text-slate-800 underline decoration-amber-500 decoration-2">notify the Admin</span> for review and approval.
You are about to submit your final findings. This action will <span className="font-bold text-slate-800 underline decoration-re-red decoration-2">notify the Admin</span> for review and approval.
</DialogDescription>
</DialogHeader>
<div className="bg-amber-50 p-4 rounded-xl flex gap-3 border border-amber-100 italic" data-testid="onboarding-fdd-details-finalize-info">
<Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 leading-normal">
<div className="bg-red-50 p-4 rounded-xl flex gap-3 border border-red-100 italic" data-testid="onboarding-fdd-details-finalize-info">
<Info className="w-5 h-5 text-re-red shrink-0 mt-0.5" />
<p className="text-xs text-red-800 leading-normal">
Once submitted, you cannot edit the findings. Ensure all documents are uploaded.
</p>
</div>
@ -602,7 +602,7 @@ export function FDDApplicationDetails() {
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">Detailed Audit Findings & Remarks</Label>
<Textarea
placeholder="Enter detailed financial observations..."
className="min-h-[120px] bg-slate-50 border-slate-200 rounded-xl focus:ring-amber-500 text-sm resize-none"
className="min-h-[120px] bg-slate-50 border-slate-200 rounded-xl focus:ring-re-red text-sm resize-none"
value={fddAuditFindings}
onChange={(e) => setFddAuditFindings(e.target.value)}
data-testid="onboarding-fdd-details-finalize-remarks"
@ -621,7 +621,7 @@ export function FDDApplicationDetails() {
Cancel
</Button>
<Button
className="w-full sm:flex-1 h-12 rounded-xl font-bold bg-slate-950 hover:bg-slate-900 text-white shadow-lg shadow-slate-200 transition-all active:scale-95 border-b-2 border-amber-600"
className="w-full sm:flex-1 h-12 rounded-xl font-bold bg-slate-950 hover:bg-slate-900 text-white shadow-lg shadow-slate-200 transition-all active:scale-95 border-b-2 border-re-red"
data-testid="onboarding-fdd-details-finalize-confirm"
onClick={async () => {
try {

View File

@ -137,7 +137,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
if (loading) {
return (
<div className="flex items-center justify-center p-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-re-red"></div>
</div>
);
}
@ -184,7 +184,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<Badge variant="outline" className="bg-slate-50 text-slate-600 border-slate-200 py-1.5 px-3 rounded-full text-[10px] font-black uppercase tracking-widest" data-testid="onboarding-finance-fdd-app-id">
APP ID: {application.applicationId || application.id}
</Badge>
<Badge className="bg-amber-100 text-amber-700 hover:bg-amber-100 py-1.5 px-3 rounded-full text-[10px] font-black uppercase tracking-widest border border-amber-200" data-testid="onboarding-finance-fdd-status">
<Badge className="bg-red-50 text-re-red-hover hover:bg-red-50 py-1.5 px-3 rounded-full text-[10px] font-black uppercase tracking-widest border border-red-200" data-testid="onboarding-finance-fdd-status">
{application.status}
</Badge>
</div>
@ -197,7 +197,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
</TabsTrigger>
<TabsTrigger value="worknotes" className="rounded-lg px-6 font-bold data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2" data-testid="onboarding-finance-fdd-tab-worknotes">
<MessageSquare className="w-4 h-4" /> Work Notes
{workNotes.length > 0 && <Badge className="ml-1 h-5 w-5 p-0 flex items-center justify-center bg-blue-600 rounded-full text-[10px] text-white font-black" data-testid="onboarding-finance-fdd-worknotes-count">{workNotes.length}</Badge>}
{workNotes.length > 0 && <Badge className="ml-1 h-5 w-5 p-0 flex items-center justify-center bg-re-red rounded-full text-[10px] text-white font-black" data-testid="onboarding-finance-fdd-worknotes-count">{workNotes.length}</Badge>}
</TabsTrigger>
<TabsTrigger value="history" className="rounded-lg px-6 font-bold data-[state=active]:bg-white data-[state=active]:shadow-sm flex items-center gap-2" data-testid="onboarding-finance-fdd-tab-history">
<History className="w-4 h-4" /> Audit Trail
@ -271,7 +271,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
</div>
{doc && (
<div className="flex gap-2">
<Button variant="ghost" size="sm" className="h-8 text-blue-600 font-black text-[10px] uppercase tracking-widest"
<Button variant="ghost" size="sm" className="h-8 text-re-red font-black text-[10px] uppercase tracking-widest"
onClick={() => {
setPreviewDoc(doc);
setShowPreviewModal(true);
@ -293,7 +293,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<div className="space-y-4" data-testid="onboarding-finance-fdd-reports-section">
<div className="flex items-center justify-between px-1">
<h3 className="text-lg font-black text-slate-900 flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-amber-600" /> Audit Findings & Reports
<ShieldCheck className="w-5 h-5 text-re-red" /> Audit Findings & Reports
</h3>
<Badge variant="outline" className="bg-white text-slate-500 font-bold border-slate-200" data-testid="onboarding-finance-fdd-reports-count-badge">
{assignments.length} Reports Found
@ -309,12 +309,12 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
) : (
<div className="space-y-6">
{assignments.map((assignment: any, idx: number) => (
<Card key={assignment.id} className="border-slate-200 shadow-sm overflow-hidden rounded-2xl group hover:border-amber-400 transition-all duration-300" data-testid={`onboarding-finance-fdd-assignment-card-${idx}`}>
<Card key={assignment.id} className="border-slate-200 shadow-sm overflow-hidden rounded-2xl group hover:border-red-300 transition-all duration-300" data-testid={`onboarding-finance-fdd-assignment-card-${idx}`}>
<div className="bg-white p-6 border-b border-slate-50">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-amber-50 rounded-2xl flex items-center justify-center border border-amber-100 shadow-inner group-hover:scale-105 transition-transform">
<ShieldCheck className="w-6 h-6 text-amber-600" />
<div className="w-12 h-12 bg-red-50 rounded-2xl flex items-center justify-center border border-red-100 shadow-inner group-hover:scale-105 transition-transform">
<ShieldCheck className="w-6 h-6 text-re-red" />
</div>
<div>
<h4 className="text-slate-900 font-black text-lg leading-none">FDD Audit Assignment</h4>
@ -323,7 +323,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
</div>
<Badge className={cn(
"py-1 px-3 rounded-full text-[10px] font-black uppercase tracking-widest",
assignment.status === 'completed' ? "bg-green-100 text-green-700" : "bg-amber-100 text-amber-700"
assignment.status === 'completed' ? "bg-green-100 text-green-700" : "bg-red-50 text-re-red-hover"
)} data-testid={`onboarding-finance-fdd-assignment-status-${idx}`}>
{assignment.status}
</Badge>
@ -344,12 +344,12 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<div className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-xl border text-xs font-black shadow-sm",
report.recommendation === 'Green' ? "bg-green-50 border-green-200 text-green-700" :
report.recommendation === 'Amber' ? "bg-amber-50 border-amber-200 text-amber-700" :
report.recommendation === 'Amber' ? "bg-red-50 border-red-200 text-re-red-hover" :
"bg-red-50 border-red-200 text-red-700"
)} data-testid={`onboarding-finance-fdd-report-signal-${idx}-${reportIdx}`}>
<div className={cn("w-2.5 h-2.5 rounded-full animate-pulse",
report.recommendation === 'Green' ? "bg-green-500" :
report.recommendation === 'Amber' ? "bg-amber-500" : "bg-red-500"
report.recommendation === 'Amber' ? "bg-red-500" : "bg-red-500"
)} />
{report.recommendation?.toUpperCase()} SIGNAL
</div>
@ -366,7 +366,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<div className="space-y-5">
<Label className="text-[10px] text-slate-400 uppercase tracking-widest font-black mb-2 block">Available Documents</Label>
{report.reportDocument ? (
<div className="bg-white border-2 border-slate-100 rounded-2xl p-4 flex items-center justify-between hover:border-amber-400 transition-all hover:shadow-lg cursor-default" data-testid={`onboarding-finance-fdd-report-doc-${idx}-${reportIdx}`}>
<div className="bg-white border-2 border-slate-100 rounded-2xl p-4 flex items-center justify-between hover:border-red-300 transition-all hover:shadow-lg cursor-default" data-testid={`onboarding-finance-fdd-report-doc-${idx}-${reportIdx}`}>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-red-50 flex items-center justify-center shadow-sm">
<FileText className="w-6 h-6 text-red-500" />
@ -380,7 +380,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-slate-400 hover:text-amber-600 hover:bg-amber-50 rounded-xl"
className="h-10 w-10 text-slate-400 hover:text-re-red hover:bg-red-50 rounded-xl"
onClick={() => window.open(`http://localhost:5000/${report.reportDocument.filePath}`, '_blank')}
data-testid={`onboarding-finance-fdd-report-download-${idx}-${reportIdx}`}
>
@ -389,7 +389,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<Button
variant="ghost"
size="icon"
className="h-10 w-10 text-slate-400 hover:text-amber-600 hover:bg-amber-50 rounded-xl"
className="h-10 w-10 text-slate-400 hover:text-re-red hover:bg-red-50 rounded-xl"
onClick={() => {
setPreviewDoc(report.reportDocument);
setShowPreviewModal(true);
@ -413,9 +413,9 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<span className="text-[10px] font-black text-green-700 uppercase tracking-widest">Audited & Verified</span>
</div>
) : (
<div className="flex items-center gap-2 bg-amber-50 border border-amber-100 px-4 py-2 rounded-full shadow-sm" data-testid={`onboarding-finance-fdd-report-pending-${idx}-${reportIdx}`}>
<Clock className="w-4 h-4 text-amber-600" />
<span className="text-[10px] font-black text-amber-700 uppercase tracking-widest">Pending Verification</span>
<div className="flex items-center gap-2 bg-red-50 border border-red-100 px-4 py-2 rounded-full shadow-sm" data-testid={`onboarding-finance-fdd-report-pending-${idx}-${reportIdx}`}>
<Clock className="w-4 h-4 text-re-red" />
<span className="text-[10px] font-black text-re-red-hover uppercase tracking-widest">Pending Verification</span>
</div>
)}
</div>
@ -437,7 +437,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<Card className="border-slate-200 shadow-sm overflow-hidden rounded-2xl sticky top-6" data-testid="onboarding-finance-fdd-action-sidebar">
<CardHeader className="bg-slate-900 border-b border-slate-800 py-5">
<CardTitle className="text-white text-sm font-black uppercase tracking-widest flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-amber-400" /> Finance Action
<CheckCircle className="w-4 h-4 text-re-red" /> Finance Action
</CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
@ -446,7 +446,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<Textarea
id="remarks"
placeholder="Enter your assessment or audit sign-off remarks here..."
className="min-h-[150px] bg-slate-50 border-slate-200 rounded-xl focus:ring-amber-500 focus:border-amber-500 text-sm font-medium"
className="min-h-[150px] bg-slate-50 border-slate-200 rounded-xl focus:ring-re-red focus:border-re-red text-sm font-medium"
value={approvalRemark}
onChange={(e) => setApprovalRemark(e.target.value)}
disabled={hasMadeDecision || isSubmitting}
@ -458,7 +458,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
!isReadOnly ? (
<div className="space-y-3 pt-2">
<Button
className="w-full h-14 bg-amber-600 hover:bg-amber-700 text-white font-black uppercase tracking-widest rounded-xl shadow-lg shadow-amber-200/50 transition-all active:scale-95"
className="w-full h-14 bg-re-red hover:bg-re-red-hover text-white font-black uppercase tracking-widest rounded-xl shadow-lg shadow-red-200/50 transition-all active:scale-95"
onClick={() => handleDecision('Approved')}
disabled={isSubmitting}
data-testid="onboarding-finance-fdd-approve-btn"
@ -473,7 +473,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
</Button>
<Button
variant="outline"
className="w-full h-12 text-amber-600 border-amber-200 hover:bg-amber-50 font-black uppercase tracking-widest rounded-xl"
className="w-full h-12 text-re-red border-red-200 hover:bg-red-50 font-black uppercase tracking-widest rounded-xl"
onClick={() => toast.info('Clarification request functionality coming soon')}
disabled={isSubmitting}
data-testid="onboarding-finance-fdd-revision-btn"
@ -509,15 +509,15 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<h5 className="text-[10px] text-slate-400 font-black uppercase tracking-widest mb-4">Verification Policy</h5>
<ul className="space-y-3">
<li className="flex items-start gap-3" data-testid="onboarding-finance-fdd-policy-1">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 shrink-0" />
<div className="w-1.5 h-1.5 rounded-full bg-red-500 mt-1.5 shrink-0" />
<p className="text-[10px] text-slate-500 font-bold leading-tight">Must review PDF audit report for financial discrepancies before approval.</p>
</li>
<li className="flex items-start gap-3" data-testid="onboarding-finance-fdd-policy-2">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 shrink-0" />
<div className="w-1.5 h-1.5 rounded-full bg-red-500 mt-1.5 shrink-0" />
<p className="text-[10px] text-slate-500 font-bold leading-tight">Approval triggers the progression to Security Deposit payment state.</p>
</li>
<li className="flex items-start gap-3" data-testid="onboarding-finance-fdd-policy-3">
<div className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 shrink-0" />
<div className="w-1.5 h-1.5 rounded-full bg-red-500 mt-1.5 shrink-0" />
<p className="text-[10px] text-slate-500 font-bold leading-tight">Remarks are mandatory for audit trail and compliance tracking.</p>
</li>
</ul>
@ -527,8 +527,8 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
<Card className="bg-slate-50 border-slate-200 rounded-2xl" data-testid="onboarding-finance-fdd-progress-card">
<CardContent className="p-6 flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-blue-100 flex items-center justify-center shrink-0">
<Clock className="w-5 h-5 text-blue-600" />
<div className="w-10 h-10 rounded-full bg-red-50 flex items-center justify-center shrink-0">
<Clock className="w-5 h-5 text-re-red" />
</div>
<div>
<h5 className="text-[10px] font-black uppercase tracking-widest text-slate-400">Current Progress</h5>
@ -589,7 +589,7 @@ export function FinanceFddDetailPage({ applicationId, onBack }: FinanceFddDetail
data-testid="onboarding-finance-fdd-new-note-input"
/>
<Button
className="shrink-0 bg-blue-600 hover:bg-blue-700 h-auto self-stretch rounded-xl px-6"
className="shrink-0 bg-re-red hover:bg-re-red-hover h-auto self-stretch rounded-xl px-6"
onClick={handlePostNote}
disabled={isNoteSubmitting || !newNote.trim()}
data-testid="onboarding-finance-fdd-send-note-btn"

View File

@ -55,7 +55,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
const paymentRows = applications.flatMap((app: any) => {
const s = app.overallStatus || app.status;
const isPaymentStage = [
'Payment Pending', 'Security Details', 'LOI In Progress', 'LOI Issued',
'Payment Pending', 'Security Deposit', 'Security Details', 'LOI In Progress', 'LOI Issued',
'LOA Pending', 'Dealer Code Generation', 'LOA_APPROVAL'
].includes(s);
const deposits = app.securityDeposits || [];
@ -77,7 +77,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
// Keep virtual pending rows for in-flight cases with no deposit record yet
if (isPaymentStage) {
if (['Payment Pending', 'Security Details', 'LOI In Progress'].includes(s)) {
if (['Payment Pending', 'Security Deposit', 'Security Details', 'LOI In Progress'].includes(s)) {
return [{
id: `virtual-${app.id}-sd`,
applicationId: app.applicationId || app.id,
@ -121,7 +121,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
if (loading) {
return (
<div className="flex items-center justify-center p-20 text-blue-600">
<div className="flex items-center justify-center p-20 text-re-red">
<Clock className="w-8 h-8 animate-spin mr-3" />
<span>Loading Finance Queue...</span>
</div>
@ -134,7 +134,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
<div className="flex items-center justify-between bg-white p-6 rounded-2xl border border-slate-100 shadow-sm">
<div>
<h1 className="text-3xl font-bold text-slate-900 tracking-tight mb-1">Payment Verification</h1>
<p className="text-slate-500">Review and verify dealer security deposits and first fill payments</p>
<p className="text-slate-500">Review and verify Security Deposit and First Fill payments for dealers</p>
</div>
<div className="flex items-center gap-3">
<Button onClick={fetchApplications} variant="outline" size="sm" className="bg-white hover:bg-slate-50" data-testid="onboarding-finance-queue-sync-btn">
@ -148,7 +148,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div className="inline-flex p-1 bg-slate-100 rounded-xl">
<div className="flex items-center px-4 py-2 bg-white rounded-lg text-slate-900 shadow-sm font-medium text-sm" data-testid="onboarding-finance-queue-pending-count">
<IndianRupee className="w-4 h-4 mr-2 text-blue-600" />
<IndianRupee className="w-4 h-4 mr-2 text-re-red" />
Pending Payments ({paymentRows.filter((row: any) => !isVerifiedLikeStatus(row.paymentStatus)).length})
</div>
</div>
@ -203,10 +203,10 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
const statusLabel = row.paymentStatus || 'Awaiting Payment';
const app = row.application || {};
return (
<TableRow key={row.id} className="hover:bg-blue-50/20 group transition-all" data-testid={`onboarding-finance-queue-row-${idx}`}>
<TableRow key={row.id} className="hover:bg-red-50/20 group transition-all" data-testid={`onboarding-finance-queue-row-${idx}`}>
<TableCell className="py-4 pl-6">
<div className="flex flex-col">
<span className="font-mono text-xs font-bold text-blue-600 mb-1" data-testid={`onboarding-finance-queue-app-id-${idx}`}>{app.applicationId || app.id}</span>
<span className="font-mono text-xs font-bold text-re-red mb-1" data-testid={`onboarding-finance-queue-app-id-${idx}`}>{app.applicationId || app.id}</span>
<div className="flex items-center gap-2">
<span className="font-semibold text-slate-900" data-testid={`onboarding-finance-queue-name-${idx}`}>{app.applicantName}</span>
</div>
@ -231,7 +231,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
className={
statusLabel === 'Verified' ? 'bg-emerald-50 text-emerald-700 border-emerald-100 px-3 py-1 rounded-full' :
statusLabel === 'Rejected' ? 'bg-rose-50 text-rose-700 border-rose-100 px-3 py-1 rounded-full' :
'bg-amber-50 text-amber-700 border-amber-100 px-3 py-1 rounded-full'
'bg-red-50 text-re-red-hover border-red-100 px-3 py-1 rounded-full'
}
variant="outline"
data-testid={`onboarding-finance-queue-status-${idx}`}
@ -244,7 +244,7 @@ export function FinanceOnboardingPage({ onViewPaymentDetails }: FinanceOnboardin
size="sm"
variant={isVerifiedLikeStatus(statusLabel) ? 'outline' : 'default'}
className={!isVerifiedLikeStatus(statusLabel)
? 'bg-blue-600 hover:bg-blue-700 shadow-md'
? 'bg-re-red hover:bg-re-red-hover shadow-md'
: 'bg-white text-slate-600 border-slate-200'}
onClick={() => handleAction(row.applicationId || app.id)}
data-testid={`onboarding-finance-queue-action-btn-${idx}`}

View File

@ -11,6 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { cn } from '@/components/ui/utils';
import {
Search,
Download,
@ -182,9 +190,12 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
setApplicationsData(mappedApps);
// Extract unique locations
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean);
setLocations(uniqueLocations);
// Extract unique locations and states from the returned data
const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(prev => Array.from(new Set([...prev, ...newLocations])));
const newStates = Array.from(new Set(mappedApps.map(app => app.state))).filter(Boolean) as string[];
setStates(prev => Array.from(new Set([...prev, ...newStates])));
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load non-opportunity requests');
@ -226,7 +237,7 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</div>
<div className="bg-white rounded-lg border border-slate-200 p-4" data-testid="onboarding-non-opps-stat-exp">
<p className="text-slate-600">With Experience</p>
<p className="text-2xl text-amber-600 mt-1">
<p className="text-2xl text-re-red mt-1">
{paginationMeta?.stats?.withExperience || 0}
</p>
</div>
@ -247,29 +258,55 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</div>
<div className="flex items-center gap-2">
<div className="relative w-full md:w-36">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="pl-10 text-xs"
placeholder="From"
data-testid="onboarding-non-opps-from-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-36 justify-start text-left font-normal h-10 px-3",
!fromDate && "text-muted-foreground"
)}
data-testid="onboarding-non-opps-from-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{fromDate ? format(new Date(fromDate), "PP") : <span className="text-xs text-slate-500">From Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={fromDate ? new Date(fromDate) : undefined}
onSelect={(date) => setFromDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
<span className="text-slate-400">to</span>
<div className="relative w-full md:w-36">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="pl-10 text-xs"
placeholder="To"
data-testid="onboarding-non-opps-to-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-36 justify-start text-left font-normal h-10 px-3",
!toDate && "text-muted-foreground"
)}
data-testid="onboarding-non-opps-to-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{toDate ? format(new Date(toDate), "PP") : <span className="text-xs text-slate-500">To Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={toDate ? new Date(toDate) : undefined}
onSelect={(date) => setToDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
<Select value={locationFilter} onValueChange={setLocationFilter}>
@ -284,6 +321,22 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-slate-700 h-10 px-3"
onClick={() => {
setFromDate('');
setToDate('');
setLocationFilter('all');
setStateFilter('all');
setSearchQuery('');
}}
data-testid="onboarding-non-opps-clear-filters"
>
Clear Filters
</Button>
<Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full lg:w-48" data-testid="onboarding-non-opps-state-select">
<SelectValue placeholder="All States" />
@ -302,7 +355,7 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
{selectedIds.length > 0 && (
<Button
className="bg-amber-600 hover:bg-amber-700 font-bold"
className="bg-re-red hover:bg-re-red-hover font-bold"
onClick={handleBulkConvert}
disabled={isBulkConverting}
>
@ -343,7 +396,7 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
{isGlobalLoading ? (
<TableRow>
<TableCell colSpan={11} className="text-center py-20">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-amber-600 mb-2" />
<Loader2 className="w-8 h-8 mx-auto animate-spin text-re-red mb-2" />
<p className="text-slate-500 text-sm">Loading applications...</p>
</TableCell>
</TableRow>
@ -360,7 +413,7 @@ export function NonOpportunitiesPage({ onViewDetails }: NonOpportunitiesPageProp
<TableRow
key={lead.id}
data-testid={`onboarding-non-opps-row-${idx}`}
className={selectedIds.includes(lead.id) ? 'bg-amber-50/50' : ''}
className={selectedIds.includes(lead.id) ? 'bg-red-50/50' : ''}
>
<TableCell>
<Checkbox

View File

@ -11,6 +11,14 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Calendar as CalendarPicker } from '@/components/ui/calendar';
import { format } from 'date-fns';
import { cn } from '@/components/ui/utils';
import {
Pagination,
PaginationContent,
@ -27,7 +35,6 @@ import {
Mail,
Grid3x3,
List,
AlertCircle,
Loader2,
Calendar,
ArrowUpDown
@ -41,7 +48,7 @@ import {
TableRow,
TableCell
} from '@/components/ui/table';
import { Progress } from '@/components/ui/progress';
import { ApplicationProgressBar } from '@/features/onboarding/components/ApplicationProgressBar';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
@ -67,6 +74,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [showShortlistModal, setShowShortlistModal] = useState(false);
const [shortlistRemark, setShortlistRemark] = useState('');
@ -160,9 +168,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
setApplicationsData(mappedApps);
// Extract unique locations for filtering
const uniqueLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean);
setLocations(uniqueLocations);
// Extract unique locations and states from the returned data
// Note: This appends new ones to the existing list to ensure all found locations are selectable
const newLocations = Array.from(new Set(mappedApps.map(app => app.preferredLocation))).filter(Boolean) as string[];
setLocations(prev => Array.from(new Set([...prev, ...newLocations])));
const newStates = Array.from(new Set(mappedApps.map(app => app.state))).filter(Boolean) as string[];
setStates(prev => Array.from(new Set([...prev, ...newStates])));
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load opportunity requests');
@ -231,12 +243,25 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
}
};
const handleBulkReminders = () => {
const handleBulkReminders = async () => {
if (selectedIds.length === 0) {
toast.error('Please select at least one application');
return;
}
toast.success(`Reminder emails sent to ${selectedIds.length} applicant(s)`);
try {
setIsSendingReminders(true);
const res = await onboardingService.sendBulkReminders(selectedIds);
if (res.success) {
toast.success(res.message || `Reminder emails sent to ${selectedIds.length} applicant(s)`);
setSelectedIds([]);
}
} catch (error: any) {
console.error('Failed to send reminders:', error);
toast.error(error.message || 'Failed to send reminders');
} finally {
setIsSendingReminders(false);
}
};
const handleExport = async () => {
@ -326,7 +351,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
'Level 2 Recommended': 'bg-teal-100 text-teal-800',
'Level 3 Interview Pending': 'bg-orange-100 text-orange-800',
'FDD Verification': 'bg-indigo-100 text-indigo-800',
'Payment Pending': 'bg-amber-100 text-amber-800',
'Payment Pending': 'bg-red-50 text-red-800',
'LOI Issued': 'bg-sky-100 text-sky-800',
'Dealer Code Generation': 'bg-purple-100 text-purple-800',
'Architecture Team Assigned': 'bg-blue-100 text-blue-800',
@ -353,6 +378,9 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
'Level 3 Approved': 'bg-green-100 text-green-800',
'LOI In Progress': 'bg-sky-50 text-sky-700',
'LOI Approved': 'bg-green-100 text-green-800',
'Security Deposit In Progress': 'bg-blue-50 text-blue-700',
'Security Deposit Approved': 'bg-green-100 text-green-800',
'Security Deposit': 'bg-blue-100 text-blue-800',
'Security Details In Progress': 'bg-blue-50 text-blue-700',
'Security Details Approved': 'bg-green-100 text-green-800',
'Security Details': 'bg-blue-100 text-blue-800',
@ -373,27 +401,13 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
if (loading) {
return (
<div className="flex justify-center items-center h-96">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
return (
<div className="space-y-6">
{/* Info Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4" data-testid="onboarding-opp-requests-banner">
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<h3 className="text-amber-900 mb-1">DD Lead Workflow - Opportunity Requests</h3>
<p className="text-amber-800">
This page shows <strong>applications where dealerships are being offered</strong> at the applicant's preferred location.
These have been shortlisted by DD and are waiting for your review. Select and <strong>Shortlist</strong> promising candidates
to move them to the <strong>Dealership Requests</strong> page for further processing.
</p>
</div>
</div>
</div>
{/* Header with Filters */}
<div className="bg-white rounded-lg border border-slate-200 p-6">
@ -424,6 +438,23 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="text-slate-500 hover:text-slate-700 h-9"
onClick={() => {
setFromDate('');
setToDate('');
setStatusFilter('all');
setLocationFilter('all');
setStateFilter('all');
setSearchQuery('');
}}
data-testid="onboarding-opp-requests-clear-filters"
>
Clear Filters
</Button>
<Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full md:w-48" data-testid="onboarding-opp-requests-state-select">
<SelectValue placeholder="Filter by state" />
@ -449,29 +480,55 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
</Select>
<div className="flex items-center gap-2 flex-1 md:flex-none">
<div className="relative w-full md:w-40">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
className="pl-10 text-xs"
placeholder="From"
data-testid="onboarding-opp-requests-from-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-40 justify-start text-left font-normal h-9 px-3",
!fromDate && "text-muted-foreground"
)}
data-testid="onboarding-opp-requests-from-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{fromDate ? format(new Date(fromDate), "PPP") : <span className="text-xs">From Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={fromDate ? new Date(fromDate) : undefined}
onSelect={(date) => setFromDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
<span className="text-slate-400">to</span>
<div className="relative w-full md:w-40">
<Calendar className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400 pointer-events-none" />
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
className="pl-10 text-xs"
placeholder="To"
data-testid="onboarding-opp-requests-to-date"
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"w-full md:w-40 justify-start text-left font-normal h-9 px-3",
!toDate && "text-muted-foreground"
)}
data-testid="onboarding-opp-requests-to-date-trigger"
>
<Calendar className="mr-2 h-4 w-4 text-slate-400" />
{toDate ? format(new Date(toDate), "PPP") : <span className="text-xs">To Date</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<CalendarPicker
mode="single"
selected={toDate ? new Date(toDate) : undefined}
onSelect={(date) => setToDate(date ? date.toISOString().split('T')[0] : '')}
initialFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
<Select value={sortBy} onValueChange={setSortBy}>
@ -497,7 +554,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
className={viewMode === 'grid' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={viewMode === 'grid' ? 'bg-re-red hover:bg-re-red-hover' : ''}
data-testid="onboarding-opp-requests-view-grid-btn"
>
<Grid3x3 className="w-4 h-4 mr-2" />
@ -507,7 +564,7 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
variant={viewMode === 'table' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('table')}
className={viewMode === 'table' ? 'bg-amber-600 hover:bg-amber-700' : ''}
className={viewMode === 'table' ? 'bg-re-red hover:bg-re-red-hover' : ''}
data-testid="onboarding-opp-requests-view-table-btn"
>
<List className="w-4 h-4 mr-2" />
@ -526,9 +583,14 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
variant="outline"
size="sm"
onClick={handleBulkReminders}
disabled={isSendingReminders}
data-testid="onboarding-opp-requests-bulk-reminder-btn"
>
{isSendingReminders ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
Send Reminders ({selectedIds.length})
</Button>
@ -644,10 +706,12 @@ export function OpportunityRequestsPage({ onViewDetails }: OpportunityRequestsPa
<Badge variant="outline" data-testid={`onboarding-opp-requests-shortlisted-badge-${idx}`}>No</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2" data-testid={`onboarding-opp-requests-progress-container-${idx}`}>
<Progress value={app.progress} className="w-20" />
<span className="text-slate-600">{app.progress}%</span>
</div>
<ApplicationProgressBar
value={app.progress}
status={app.status}
showPercent
data-testid={`onboarding-opp-requests-progress-${idx}`}
/>
</TableCell>
<TableCell>
<span className="text-slate-600" data-testid={`onboarding-opp-requests-date-${idx}`}>{formatDateTime(app.submissionDate)}</span>

View File

@ -9,13 +9,16 @@ import {
Building,
Landmark,
CheckCircle2,
Check,
Info,
User,
MapPin
} from 'lucide-react';
import { toast } from 'sonner';
import { API } from '@/api/API';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import * as SelectPrimitive from '@radix-ui/react-select';
import { cn } from '@/components/ui/utils';
import { Select, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select';
interface Props {
id: string;
@ -180,7 +183,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<RefreshCw className="w-8 h-8 animate-spin text-amber-600" />
<RefreshCw className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -189,7 +192,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center">
<p className="text-slate-600 mb-4">Application details not found.</p>
<button onClick={onBack} className="bg-amber-600 text-white px-4 py-2 rounded-md hover:bg-amber-700">Go Back</button>
<button onClick={onBack} className="bg-re-red text-white px-4 py-2 rounded-md hover:bg-re-red-hover">Go Back</button>
</div>
);
}
@ -224,19 +227,13 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<div className="animate-in fade-in duration-500 space-y-6">
{/* Status & Tracking Summary Card */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6" data-testid="onboarding-prospective-details-summary-card">
<div className="flex items-center justify-between mb-4 border-b pb-2">
<div className="mb-4 border-b pb-2">
<h4 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
<Info className="w-5 h-5 text-amber-600" /> Application Summary
<Info className="w-5 h-5 text-re-red" /> Application Summary
</h4>
<div className="text-right">
<p className="text-[10px] text-slate-500 uppercase font-bold tracking-widest">Current Stage</p>
<span className="bg-amber-100 text-amber-700 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide" data-testid="onboarding-prospective-details-current-stage">
{details.currentStage || details.overallStatus}
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="flex items-start gap-3" data-testid="onboarding-prospective-details-applicant-info">
<div className="p-2 bg-blue-50 rounded-lg">
@ -249,8 +246,8 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
</div>
</div>
<div className="flex items-start gap-3" data-testid="onboarding-prospective-details-location-info">
<div className="p-2 bg-amber-50 rounded-lg">
<MapPin className="w-4 h-4 text-amber-600" />
<div className="p-2 bg-red-50 rounded-lg">
<MapPin className="w-4 h-4 text-re-red" />
</div>
<div>
<p className="text-[10px] text-slate-500 uppercase font-bold">Proposed Location</p>
@ -283,21 +280,6 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
</div>
</div>
</div>
<div className="bg-slate-50 rounded-xl p-4 flex flex-col justify-center border border-slate-100" data-testid="onboarding-prospective-details-progress-card">
<div className="flex justify-between items-center mb-2">
<p className="text-xs font-bold text-slate-700 uppercase tracking-tight">Onboarding Progress</p>
<p className="text-xs font-black text-amber-600">{details.progressPercentage || 0}%</p>
</div>
<div className="w-full bg-slate-200 rounded-full h-2 shadow-inner">
<div className="bg-amber-500 h-2 rounded-full transition-all duration-1000 ease-out shadow-sm" style={{ width: `${details.progressPercentage || 0}%` }}></div>
</div>
{details.statusHistory?.[0]?.changeReason && (
<p className="mt-3 text-[11px] text-slate-600 italic leading-relaxed border-l-2 border-amber-300 pl-2">
"{details.statusHistory[0].changeReason}"
</p>
)}
</div>
</div>
</div>
@ -307,12 +289,12 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" data-testid="onboarding-prospective-details-statutory-card">
<div className="p-4 bg-slate-900 text-white flex justify-between items-center">
<h4 className="flex items-center gap-2 text-sm font-bold uppercase tracking-widest">
<CreditCard className="w-4 h-4 text-amber-400" /> Statutory & Bank Details
<CreditCard className="w-4 h-4 text-re-red" /> Statutory & Bank Details
</h4>
<button
onClick={handleSaveDetails}
disabled={isSaving}
className={`text-xs text-white px-3 py-1 rounded font-bold transition-all flex items-center gap-1 disabled:opacity-50 ${isFormDirty ? 'bg-emerald-600 hover:bg-emerald-700 ring-2 ring-emerald-300 animate-pulse' : 'bg-amber-600 hover:bg-amber-700'}`}
className={`text-xs text-white px-3 py-1 rounded font-bold transition-all flex items-center gap-1 disabled:opacity-50 ${isFormDirty ? 'bg-emerald-600 hover:bg-emerald-700 ring-2 ring-emerald-300 animate-pulse' : 'bg-re-red hover:bg-re-red-hover'}`}
data-testid="onboarding-prospective-details-save-statutory-btn"
>
{isSaving ? <RefreshCw className="w-3 h-3 animate-spin" /> : <CheckCircle2 className="w-3 h-3" />}
@ -325,7 +307,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">Registered Business Name</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all"
value={form.accountHolderName}
onChange={(e) => setForm({ ...form, accountHolderName: e.target.value })}
placeholder="As per legal documents"
@ -336,7 +318,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">Permanent Account Number (PAN)</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all uppercase"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all uppercase"
value={form.panNumber}
onChange={(e) => setForm({ ...form, panNumber: e.target.value.toUpperCase() })}
placeholder="ABCDE1234F"
@ -348,7 +330,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">GST Identification Number (GSTIN)</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all uppercase"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all uppercase"
value={form.gstNumber}
onChange={(e) => setForm({ ...form, gstNumber: e.target.value.toUpperCase() })}
placeholder="27ABCDE1234F1Z5"
@ -360,7 +342,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">Registered Office Address</label>
<textarea
rows={1}
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all"
value={form.registeredAddress}
onChange={(e) => setForm({ ...form, registeredAddress: e.target.value })}
placeholder="Full legal address"
@ -378,7 +360,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">Bank Name</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all"
value={form.bankName}
onChange={(e) => setForm({ ...form, bankName: e.target.value })}
placeholder="e.g. HDFC Bank"
@ -389,7 +371,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">Account Number</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all"
value={form.accountNumber}
onChange={(e) => setForm({ ...form, accountNumber: e.target.value })}
placeholder="Bank account number"
@ -400,7 +382,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">IFSC Code</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all uppercase"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all uppercase"
value={form.ifscCode}
onChange={(e) => setForm({ ...form, ifscCode: e.target.value.toUpperCase() })}
placeholder="HDFC0001234"
@ -412,7 +394,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<label className="text-[10px] font-bold text-slate-500 uppercase">Branch Name</label>
<input
type="text"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all"
className="w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm focus:ring-2 focus:ring-re-red outline-none transition-all"
value={form.branchName}
onChange={(e) => setForm({ ...form, branchName: e.target.value })}
placeholder="e.g. South Mumbai"
@ -427,7 +409,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden" data-testid="onboarding-prospective-details-upload-card">
<div className="p-4 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
<h4 className="flex items-center gap-2 text-sm font-bold uppercase tracking-widest text-slate-900">
<Upload className="w-4 h-4 text-blue-600" /> Required Documents
<Upload className="w-4 h-4 text-re-red" /> Required Documents
</h4>
</div>
<div className="p-6 space-y-6">
@ -436,7 +418,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<div className="flex items-center justify-between gap-2">
<label className="text-[10px] font-bold text-slate-500 uppercase">Document Category</label>
{selectedDocType && (
<span className={`rounded-full px-2 py-0.5 text-[10px] font-bold ${selectedDocAlreadyUploaded ? 'bg-blue-100 text-blue-700' : 'bg-amber-100 text-amber-700'}`}>
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold text-re-red">
{selectedDocAlreadyUploaded ? 'Already uploaded' : 'Pending upload'}
</span>
)}
@ -444,24 +426,39 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<div className="relative">
<CheckCircle2 className={`pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 ${selectedDocAlreadyUploaded ? 'text-green-600' : 'text-slate-300'}`} />
<Select value={selectedDocType} onValueChange={setSelectedDocType} disabled={isUploading}>
<SelectTrigger className="h-12 rounded-xl border-slate-200 bg-gradient-to-r from-white to-slate-50 pl-10 pr-3 text-sm font-medium text-slate-700 shadow-sm focus:border-amber-300 focus:ring-2 focus:ring-amber-500" data-testid="onboarding-prospective-details-doc-type-select">
<SelectTrigger className="h-12 rounded-xl border-slate-200 bg-gradient-to-r from-white to-slate-50 pl-10 pr-3 text-sm font-medium text-slate-700 shadow-sm focus:border-re-red/40 focus:ring-2 focus:ring-re-red" data-testid="onboarding-prospective-details-doc-type-select">
<SelectValue placeholder="Choose document type" />
</SelectTrigger>
<SelectContent className="rounded-xl border-slate-200 shadow-lg" data-testid="onboarding-prospective-details-doc-type-content">
{requiredDocumentTypes.map((docType) => {
const isUploaded = uploadedDocumentTypes.has(docType.toLowerCase());
return (
<SelectItem
<SelectPrimitive.Item
key={docType}
value={docType}
className="rounded-lg px-3 py-2 text-sm text-slate-700 focus:bg-amber-50 focus:text-slate-900"
textValue={docType}
className={cn(
'relative flex w-full cursor-default select-none items-center gap-2 rounded-lg py-2 pl-2 pr-8 text-sm text-slate-700 outline-none',
'focus:bg-red-50 focus:text-slate-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
)}
data-testid={`onboarding-prospective-details-doc-type-item-${docType.replace(/\s+/g, '-').toLowerCase()}`}
>
<span className="flex items-center gap-2">
<CheckCircle2 className={`h-4 w-4 ${isUploaded ? 'text-green-600' : 'text-slate-300'}`} />
{docType}
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
</SelectItem>
<div className="flex min-w-0 flex-1 items-center gap-2">
<CheckCircle2
className={cn(
'h-4 w-4 shrink-0',
isUploaded ? 'text-green-600' : 'text-slate-300',
)}
aria-hidden
/>
<SelectPrimitive.ItemText>{docType}</SelectPrimitive.ItemText>
</div>
</SelectPrimitive.Item>
);
})}
</SelectContent>
@ -473,7 +470,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<input
type="file"
id="file-upload"
className="w-full text-xs text-slate-600 file:mr-4 file:py-1 file:px-4 file:rounded-full file:border-0 file:text-xs file:font-semibold file:bg-amber-50 file:text-amber-700 hover:file:bg-amber-100"
className="w-full text-xs text-slate-600 file:mr-4 file:py-1 file:px-4 file:rounded-full file:border-0 file:text-xs file:font-semibold file:bg-red-50 file:text-re-red hover:file:bg-red-100"
onChange={handleFileChange}
disabled={isUploading}
data-testid="onboarding-prospective-details-file-input"
@ -483,7 +480,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<button
onClick={handleUpload}
disabled={!file || !selectedDocType || isUploading}
className="bg-blue-600 text-white px-5 py-2 rounded-md hover:bg-blue-700 disabled:opacity-50 text-xs font-bold transition-all shadow-sm flex items-center gap-2"
className="bg-re-red text-white px-5 py-2 rounded-md hover:bg-re-red-hover disabled:opacity-50 text-xs font-bold transition-all shadow-sm flex items-center gap-2"
data-testid="onboarding-prospective-details-upload-btn"
>
{isUploading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Upload className="w-3.5 h-3.5" />}
@ -499,7 +496,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3" data-testid="onboarding-prospective-details-doc-library">
{documents.length > 0 ? documents.map((doc, idx) => (
<div key={doc.id} className="flex justify-between items-center p-3 border border-slate-100 rounded-xl bg-slate-50 group hover:border-amber-200 transition-all" data-testid={`onboarding-prospective-details-doc-item-${idx}`}>
<div key={doc.id} className="flex justify-between items-center p-3 border border-slate-100 rounded-xl bg-slate-50 group hover:border-red-200 transition-all" data-testid={`onboarding-prospective-details-doc-item-${idx}`}>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-white flex items-center justify-center border border-slate-200 group-hover:bg-blue-50">
<File className="w-4 h-4 text-slate-400 group-hover:text-blue-600" />
@ -509,7 +506,7 @@ export function ProspectiveApplicationDetails({ id, onBack }: Props) {
<p className="text-[10px] text-slate-400 truncate w-32">{doc.fileName}</p>
</div>
</div>
<span className={`text-[9px] px-2 py-0.5 rounded-full font-black uppercase tracking-tighter ${doc.status === 'Approved' ? 'bg-green-100 text-green-700' : 'bg-amber-100 text-amber-700'}`} data-testid={`onboarding-prospective-details-doc-status-${idx}`}>
<span className={`text-[9px] px-2 py-0.5 rounded-full font-black uppercase tracking-tighter ${doc.status === 'Approved' ? 'bg-green-100 text-green-700' : 'bg-red-50 text-re-red'}`} data-testid={`onboarding-prospective-details-doc-status-${idx}`}>
{doc.status || 'Pending'}
</span>
</div>

View File

@ -211,7 +211,7 @@ export function WorkNotesPage(props: Partial<WorkNotesPageProps>) {
'bg-green-600',
'bg-blue-600',
'bg-purple-600',
'bg-amber-600',
'bg-re-red',
'bg-pink-600',
'bg-indigo-600',
'bg-teal-600',

View File

@ -14,18 +14,15 @@ import { toast } from 'sonner';
import { dealerService } from '@/services/dealer.service';
import { masterService } from '@/services/master.service';
import { formatDateTime } from '@/components/ui/utils';
import { getRequestStatusBadgeClass, getStatusProgressBarClass } from '@/lib/statusProgressTheme';
interface DealerRelocationPageProps {
currentUser: UserType | null;
onViewDetails?: (id: string) => void;
}
const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const getStatusColor = (status: string, currentStage?: string) =>
getRequestStatusBadgeClass(status, currentStage);
const getApiErrorMessage = (error: any, fallback: string) =>
error?.response?.data?.message || error?.data?.message || error?.message || fallback;
@ -203,7 +200,7 @@ export function DealerRelocationPage({ onViewDetails }: DealerRelocationPageProp
title: 'Pending',
value: requests.filter(r => r.status !== 'Completed' && r.status !== 'Rejected').length,
icon: Calendar,
color: 'bg-yellow-500',
color: 'bg-re-red',
},
{
title: 'Approved',
@ -218,7 +215,7 @@ export function DealerRelocationPage({ onViewDetails }: DealerRelocationPageProp
{/* Loading Overlay */}
{loading && (
<div className="min-h-[400px] flex items-center justify-center">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
</div>
)}
@ -235,7 +232,7 @@ export function DealerRelocationPage({ onViewDetails }: DealerRelocationPageProp
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-amber-600 hover:bg-amber-700 text-white">
<Button className="bg-re-red hover:bg-re-red-hover text-white">
<Plus className="w-4 h-4 mr-2" />
New Relocation Request
</Button>
@ -404,7 +401,7 @@ export function DealerRelocationPage({ onViewDetails }: DealerRelocationPageProp
</Button>
<Button
type="submit"
className="bg-amber-600 hover:bg-amber-700 text-white"
className="bg-re-red hover:bg-re-red-hover text-white"
disabled={submitting}
>
{submitting ? (
@ -498,7 +495,7 @@ export function DealerRelocationPage({ onViewDetails }: DealerRelocationPageProp
<div className="flex items-center gap-2">
<div className="flex-1 bg-slate-200 rounded-full h-2 min-w-[60px]">
<div
className="bg-amber-500 h-2 rounded-full"
className={`h-2 rounded-full transition-all duration-300 ${getStatusProgressBarClass(request.status, request.currentStage)}`}
style={{ width: `${request.progressPercentage || 0}%` }}
/>
</div>

View File

@ -15,6 +15,16 @@ 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;
@ -29,11 +39,9 @@ const workflowStages = [
{ 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: 'DD Head Approval', key: 'DD_HEAD_APPROVAL', role: 'DD Head' },
{ id: 7, name: 'NBH Approval', key: 'NBH_APPROVAL', role: 'NBH' },
{ id: 8, name: 'Legal Clearance', key: 'LEGAL_CLEARANCE', role: 'Legal Admin' },
{ id: 9, name: 'NBH Clearance with EOR', key: 'NBH_CLEARANCE_EOR', role: 'NBH' },
{ id: 10, name: 'Relocation Complete', key: 'COMPLETED', role: 'System' }
{ 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 */
@ -174,13 +182,12 @@ const requiredDocuments = [
'Water supply documents'
];
const getStatusColor = (status: string) => {
if (status === 'Completed' || status === 'Verified' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const 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;
@ -201,6 +208,13 @@ const getApiErrorMessage = (error: any, fallback: string) => {
export function RelocationRequestDetails({ requestId, onBack, currentUser }: RelocationRequestDetailsProps) {
const navigate = useNavigate();
const [request, setRequest] = useState<any>(null);
// URL slug may be the human-readable code (e.g. `REL-...`); SLA expects UUID.
// Feed the SLA hook the resolved UUID once the request has loaded.
const slaEntityId: string = request?.id || '';
const { get: getSla } = useSlaBatchStatus(
slaEntityId ? [{ entityType: 'relocation', entityId: slaEntityId }] : [],
Boolean(slaEntityId)
);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -213,10 +227,17 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
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();
@ -296,15 +317,6 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
if (response.data.success) {
const req = response.data.request;
setRequest(req);
const currentStage = req.currentStage;
if (
currentStage === 'NBH_CLEARANCE_EOR' ||
currentStage === 'NBH Clearance with EOR' ||
req.status === 'Completed'
) {
fetchEorChecklist(req.id);
}
}
} catch (error) {
console.error('Fetch relocation request details error:', error);
@ -351,7 +363,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
const timelineResolvedOrdinal = relocationTimelineResolvedOrdinal(timelineEntries);
const auditResolvedOrdinal = relocationAuditResolvedOrdinal(auditLogs);
const dbOrdinal = request ? getDbStageOrdinal() : 1;
/** Audit/timeline can reference later steps (e.g. NBH EOR) while the request still sits at NBH Approval — do not use that to drive the active step. */
/** 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 &&
@ -365,11 +377,20 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
request?.status === 'Completed' ||
request?.currentStage === 'Completed' ||
dbOrdinal >= workflowStages.length + 1;
/** Match backend: N/10 while on pipeline (NBH EOR = 9 → 90%); 100% only when completed — avoids stale API 100% at NBH EOR. */
/** 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) =>
@ -422,7 +443,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
request.currentStage &&
request.currentStage !== 'ASM Review' &&
request.currentStage !== 'Rejected';
const canRevoke = showActions && ['ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'Super Admin'].includes(currentUser?.role || '');
const 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));
@ -526,17 +547,31 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
}
};
const handleRejectDocument = async (documentId: string) => {
const submitRejectDocument = async () => {
if (!rejectDocId || !String(rejectDocReason).trim()) {
toast.error('Please enter a rejection reason.');
return;
}
try {
const response = await API.rejectRelocationDocument(requestId, documentId, { remarks: 'Rejected by reviewer' }) as any;
if (response.data.success) {
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);
}
};
@ -554,7 +589,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
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-amber-600 animate-spin" />
<Loader2 className="w-10 h-10 text-re-red animate-spin" />
<p className="text-slate-500 font-medium">Loading request details...</p>
</div>
);
@ -588,12 +623,15 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<p className="text-slate-600">
{request.outlet?.name} ({request.outlet?.code})
</p>
<div className="mt-1">
<SlaBadge status={getSla('relocation', slaEntityId)} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<Button
variant="outline"
className="relative hover:bg-amber-50 hover:border-amber-300 hover:text-amber-700 transition-all shadow-sm"
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',
@ -605,13 +643,13 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
{request?.worknotes?.length > 0 && (
<Badge className="ml-2 bg-amber-600 hover:bg-amber-700 text-white h-5 px-2">
<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={getStatusColor(request.status)}>
<Badge className={requestStatusBadgeClass}>
{request.status}
</Badge>
</div>
@ -641,7 +679,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</div>
</div>
<div className="flex items-center gap-2">
<Navigation className="w-4 h-4 text-amber-600" />
<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>
@ -652,7 +690,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
Type: {request.relocationType}
</Badge>
{request.distance && (
<Badge variant="outline" className="border-amber-200 bg-amber-50 text-amber-700">
<Badge variant="outline" className="border-red-200 bg-red-50 text-re-red-hover">
Distance: {request.distance}
</Badge>
)}
@ -673,9 +711,12 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<p className="text-slate-600 text-sm mb-1">Request Information</p>
<p className="text-slate-900 text-sm">Submitted: {formatDateTime(request.createdAt)}</p>
<p className="text-slate-600 text-sm">By: {request.dealer?.fullName}</p>
<p className="text-slate-900 text-sm mt-2">
Current Stage: {String(request.currentStage || '').replace(/_/g, ' ')}
</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>
@ -692,13 +733,10 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<Card>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<CardHeader className="pb-4">
<div className="overflow-x-auto -mx-6 px-6">
<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>
{(request.currentStage === 'NBH Clearance with EOR' || request.status === 'Completed' || request.currentStage === 'NBH_CLEARANCE_EOR') && (
<TabsTrigger value="eor">EOR Checklist</TabsTrigger>
)}
<TabsTrigger value="history">History & Audit Trail</TabsTrigger>
</TabsList>
</div>
@ -706,23 +744,32 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<CardContent>
{/* Workflow Progress Tab */}
<TabsContent value="workflow" className="mt-0">
<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>
<span className="text-slate-600">{displayProgressPct}%</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 bg-amber-600 transition-all duration-500"
className={`h-full transition-all duration-500 ${statusProgressBarClass}`}
style={{ width: `${displayProgressPct}%` }}
/>
</div>
</div>
{workflowProgressMismatch && (
<div className="mb-6 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{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
@ -739,7 +786,16 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{/* Workflow stages + per-stage timeline (same pattern as ResignationDetails progress tab) */}
<div className="space-y-4">
{workflowStages.map((stage: any, index: number) => {
{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(
@ -760,7 +816,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
isCompleted
? 'bg-green-100 text-green-600'
: isCurrent
? 'bg-amber-100 text-amber-600'
? WORKFLOW_IN_PROGRESS_ACCENT.icon
: 'bg-slate-100 text-slate-400'
}`}
>
@ -781,9 +837,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<div
className={`flex-1 pb-8 ${
isCurrent
? 'bg-amber-50 -ml-4 pl-4 pr-4 py-3 rounded-lg border border-amber-200'
: ''
isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.panel : ''
}`}
>
<div className="flex items-center justify-between mb-1 gap-2">
@ -793,7 +847,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
isCompleted
? 'text-green-700'
: isCurrent
? 'text-amber-900'
? WORKFLOW_IN_PROGRESS_ACCENT.title
: 'text-slate-900'
}
>
@ -801,7 +855,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</h4>
<p
className={`text-sm ${
isCurrent ? 'text-amber-700' : 'text-slate-600'
isCurrent ? WORKFLOW_IN_PROGRESS_ACCENT.subtitle : 'text-slate-600'
}`}
>
Responsible: {stage.role}
@ -818,7 +872,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
isCompleted
? 'bg-green-100 text-green-700 border-green-300'
: isCurrent
? 'bg-amber-100 text-amber-700 border-amber-300'
? WORKFLOW_IN_PROGRESS_ACCENT.stageBadge
: 'bg-slate-100 text-slate-500 border-slate-300'
}
>
@ -864,7 +918,8 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</div>
</div>
);
})}
})
)}
</div>
</TabsContent>
@ -882,9 +937,26 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{/* Upload Button */}
<div className="flex items-center justify-between">
<h4 className="text-slate-900">Required Documents</h4>
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<Dialog
open={isUploadDialogOpen}
onOpenChange={(open) => {
setIsUploadDialogOpen(open);
if (!open) {
setDocTypeLocked(false);
setSelectedFile(null);
}
}}
>
<DialogTrigger asChild>
<Button size="sm" className="bg-amber-600 hover:bg-amber-700">
<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>
@ -893,10 +965,22 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<DialogHeader>
<DialogTitle>Upload Document</DialogTitle>
<DialogDescription>
Select the document type and upload the file
{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
@ -916,6 +1000,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
})}
</select>
</div>
)}
<div>
<Label>Upload File</Label>
<Input
@ -930,7 +1015,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
Cancel
</Button>
<Button
className="bg-amber-600 hover:bg-amber-700"
className="bg-re-red hover:bg-re-red-hover"
onClick={handleUploadDocument}
disabled={isUploading}
>
@ -954,21 +1039,52 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
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 gap-2 p-2 rounded border text-sm ${uploaded ? 'bg-green-50 border-green-200' : 'bg-slate-50 border-slate-200'
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'
}`}
>
{uploaded ? (
<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={uploaded ? 'text-green-900' : 'text-slate-700'}>
<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>
@ -994,7 +1110,10 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</TableHeader>
<TableBody>
{request.documents.map((doc: any) => (
<TableRow key={doc.id}>
<TableRow
key={doc.id}
className={String(doc.status) === 'Rejected' ? 'bg-red-50/80' : undefined}
>
<TableCell className="text-slate-900">
{doc.name}
</TableCell>
@ -1039,7 +1158,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{doc.status === 'Pending Verification' && (() => {
const role = currentUser?.role || currentUser?.roleCode || '';
// SRS — only authorized review roles can verify relocation documents
return ['DD Lead', 'DD Head', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
return ['DD Lead', 'NBH', 'Legal Admin', 'DD Admin', 'Super Admin', 'SUPER_ADMIN', 'DD_ADMIN'].includes(role);
})() && (
<>
<Button
@ -1055,7 +1174,11 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
size="sm"
variant="destructive"
className="h-8 gap-1"
onClick={() => handleRejectDocument(doc.id)}
onClick={() => {
setRejectDocId(doc.id);
setRejectDocReason('');
setRejectDocDialogOpen(true);
}}
title="Reject Document"
>
<AlertCircle className="w-4 h-4" />
@ -1102,7 +1225,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<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-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
<p className="text-slate-500">Fetching checklist...</p>
</div>
) : (
@ -1134,7 +1257,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{(!eorChecklist.items || eorChecklist.items.length === 0) ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-slate-500 py-8 text-sm">
No checklist rows returned. Use &quot;Try Refreshing&quot; above or reload the page; rows are created when the request enters NBH Clearance with EOR.
No checklist rows returned. Use &quot;Try Refreshing&quot; above or reload the page.
</TableCell>
</TableRow>
) : (
@ -1146,7 +1269,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
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-amber-600"
className="w-4 h-4 rounded border-slate-300 text-re-red"
/>
</TableCell>
<TableCell>
@ -1184,7 +1307,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</Button>
</>
) : item.proofDocumentId ? (
<span className="text-xs text-amber-700">Proof linked (refresh if file details are missing)</span>
<span className="text-xs text-re-red-hover">Proof linked (refresh if file details are missing)</span>
) : (
<Button
type="button"
@ -1229,7 +1352,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
)}
{!eorChecklist.items?.every((i: any) => i.isCompliant) && (
<p className="text-right text-xs text-amber-600 italic">
<p className="text-right text-xs text-re-red italic">
All items must be marked as compliant before final submission.
</p>
)}
@ -1252,7 +1375,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
${(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-amber-100 text-amber-700 border-amber-200'
? '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'}
@ -1300,14 +1423,16 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
<CardContent className="space-y-4">
<div>
<p className="text-slate-600 text-sm">Current Stage</p>
<p className="text-slate-900">{request.currentStage}</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 bg-amber-600 transition-all duration-300"
className={`h-full transition-all duration-300 ${statusProgressBarClass}`}
style={{ width: `${displayProgressPct}%` }}
/>
</div>
@ -1342,9 +1467,9 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
Approve Request
</Button>
{!canApprove && (
<p className="text-xs text-amber-700">
<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.
</p>
</div>
)}
<Button
@ -1364,7 +1489,7 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{canSendBack && (
<Button
variant="outline"
className="w-full border-amber-400 text-amber-900 hover:bg-amber-50"
className="w-full border-red-300 text-red-900 hover:bg-red-50"
onClick={() => handleAction('sendBack')}
disabled={isSubmitting}
>
@ -1414,34 +1539,6 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
</CardContent>
</Card>
{/* Assigned Evaluators Card */}
{request.participants && request.participants.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Assigned Evaluators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{request.participants.map((participant: any) => (
<div key={participant.id} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
<div>
<p className="text-slate-900 text-sm font-medium">{participant.user?.fullName || 'Unknown'}</p>
<p className="text-slate-600 text-xs">{participant.user?.roleCode || 'User'}</p>
{participant.metadata?.stage && (
<Badge variant="outline" className="mt-1 text-xs">
{participant.metadata.stage.replace(/_/g, ' ')}
</Badge>
)}
</div>
{participant.metadata?.autoAssigned && (
<Badge className="bg-blue-100 text-blue-700 border-blue-300 text-xs">
Auto-assigned
</Badge>
)}
</div>
))}
</CardContent>
</Card>
)}
</div>
</div>
@ -1505,12 +1602,12 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
actionType === 'approve'
? 'bg-green-600 hover:bg-green-700'
: actionType === 'reject'
? 'bg-red-600 hover:bg-red-700'
? 'bg-re-red hover:bg-re-red-hover'
: actionType === 'sendBack'
? 'bg-amber-600 hover:bg-amber-700'
? 'bg-re-red hover:bg-re-red-hover'
: actionType === 'revoke'
? 'bg-red-700 hover:bg-red-800'
: 'bg-amber-600 hover:bg-amber-700'
? 'bg-re-red hover:bg-re-red-hover'
: 'bg-re-red hover:bg-re-red-hover'
}
disabled={isSubmitting}
>
@ -1536,6 +1633,40 @@ export function RelocationRequestDetails({ requestId, onBack, currentUser }: Rel
{/* 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)}

View File

@ -14,7 +14,13 @@ import { useState, useEffect } from 'react';
import { User } 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 { formatDateTime } from '@/components/ui/utils';
import {
getCurrentStageBadgeClass,
getStatusProgressBarClass,
} from '@/lib/statusProgressTheme';
import {
Pagination,
PaginationContent,
@ -33,13 +39,8 @@ interface RelocationRequestPageProps {
const getApiErrorMessage = (error: any, fallback: string) =>
error?.response?.data?.message || error?.data?.message || error?.message || fallback;
const getStatusColor = (status: string) => {
if (status === 'Completed' || status === 'Closed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending') || status === 'In Progress') return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected') || status.includes('Revoked')) return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Collection') || status.includes('Completion') || status.includes('Infra')) return 'bg-blue-100 text-blue-700 border-blue-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
const getStageBadgeClass = (stage: string, requestStatus?: string) =>
getCurrentStageBadgeClass(stage, requestStatus);
export function RelocationRequestPage({ currentUser, onViewDetails }: RelocationRequestPageProps) {
const [requests, setRequests] = useState<any[]>([]);
@ -74,6 +75,12 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
// Constants
const isSuperAdmin = currentUser?.role === 'Super Admin' || currentUser?.roleCode === 'Super Admin';
const slaItems = requests.map((r: any) => ({
entityType: 'relocation',
entityId: r.id || r.requestId
}));
const { get: getSla } = useSlaBatchStatus(slaItems, requests.length > 0);
const isCompletedRequest = (request: any) =>
request.status === 'Completed' || request.status === 'Closed' || request.currentStage === 'Completed';
@ -243,7 +250,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
title: 'Pending Review',
value: requests.filter((r: any) => isPendingReviewRequest(r)).length,
icon: Calendar,
color: 'bg-yellow-500',
color: 'bg-re-red',
},
{
title: 'Completed',
@ -276,7 +283,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
{isSuperAdmin && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-amber-600 hover:bg-amber-700 text-white">
<Button className="bg-re-red hover:bg-re-red-hover text-white">
<Plus className="w-4 h-4 mr-2" />
New Relocation Request
</Button>
@ -435,7 +442,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</Button>
<Button
type="submit"
className="bg-amber-600 hover:bg-amber-700 text-white"
className="bg-re-red hover:bg-re-red-hover text-white"
disabled={submitting}
>
{submitting ? (
@ -514,7 +521,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
<TableRow>
<TableCell colSpan={8} className="h-32 text-center">
<div className="flex flex-col items-center justify-center space-y-2">
<Loader2 className="w-6 h-6 text-amber-600 animate-spin" />
<Loader2 className="w-6 h-6 text-re-red animate-spin" />
<p className="text-slate-500 text-sm">Loading requests...</p>
</div>
</TableCell>
@ -542,7 +549,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
<span>{request.currentLocation}</span>
</div>
<div className="flex items-center gap-1 text-slate-900 text-sm">
<Navigation className="w-3 h-3 text-amber-600" />
<Navigation className="w-3 h-3 text-re-red" />
<span className="text-slate-500">To:</span>
<span>{request.proposedLocation}</span>
</div>
@ -554,7 +561,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</Badge>
</TableCell>
<TableCell>
<Badge className={`border ${getStatusColor(request.currentStage)}`}>
<SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
<Badge className={getStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
</TableCell>
@ -562,7 +570,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-amber-600 transition-all duration-300"
className={`h-full transition-all duration-300 ${getStatusProgressBarClass(request.status, request.currentStage)}`}
style={{ width: `${request.progressPercentage || 0}%` }}
/>
</div>
@ -607,7 +615,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="h-32 text-center">
<Loader2 className="w-6 h-6 text-amber-600 animate-spin mx-auto" />
<Loader2 className="w-6 h-6 text-re-red animate-spin mx-auto" />
</TableCell>
</TableRow>
) : (
@ -629,7 +637,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</div>
</TableCell>
<TableCell>
<Badge className={`border ${getStatusColor(request.currentStage)}`}>
<SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
<Badge className={getStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
</TableCell>
@ -668,7 +677,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<Loader2 className="w-6 h-6 text-amber-600 animate-spin mx-auto" />
<Loader2 className="w-6 h-6 text-re-red animate-spin mx-auto" />
</TableCell>
</TableRow>
) : (
@ -693,7 +702,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-red-500 transition-all duration-300"
className={`h-full transition-all duration-300 ${getStatusProgressBarClass(request.status, request.currentStage)}`}
style={{ width: `${request.progressPercentage || 0}%` }}
/>
</div>
@ -701,7 +710,8 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
</div>
</TableCell>
<TableCell>
<Badge className={`border ${getStatusColor(request.currentStage)}`}>
<SlaBadge status={getSla('relocation', request.id || request.requestId)} compact />
<Badge className={getStageBadgeClass(request.currentStage, request.status)}>
{request.currentStage}
</Badge>
</TableCell>
@ -739,7 +749,7 @@ export function RelocationRequestPage({ currentUser, onViewDetails }: Relocation
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="h-32 text-center">
<Loader2 className="w-6 h-6 text-amber-600 animate-spin mx-auto" />
<Loader2 className="w-6 h-6 text-re-red animate-spin mx-auto" />
</TableCell>
</TableRow>
) : (

View File

@ -1,17 +1,12 @@
import { ArrowLeft, Calendar, CheckCircle2, Clock, FileText, MapPin, MessageSquare, Upload, User, XCircle } from 'lucide-react';
import { ArrowLeft, CheckCircle2, Clock, MapPin, User, XCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { resignationService } from '@/services/resignation.service';
import { formatDateTime } from '@/components/ui/utils';
import { RESIGNATION_STAGE_OPTIONS, RESIGNATION_DOCUMENT_TYPES } from '@/lib/offboardingDocumentOptions';
import {
AlertDialog,
AlertDialogAction,
@ -28,22 +23,9 @@ interface DealerResignationDetailsPageProps {
onBack: () => void;
}
const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status === 'Rejected' || status === 'Withdrawn') return 'bg-red-100 text-red-700 border-red-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
export function DealerResignationDetailsPage({ resignationId, onBack }: DealerResignationDetailsPageProps) {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [details, setDetails] = useState<any>(null);
const [uploading, setUploading] = useState(false);
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState<string>('');
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [withdrawing, setWithdrawing] = useState(false);
const [isWithdrawDialogOpen, setIsWithdrawDialogOpen] = useState(false);
const [withdrawalReason, setWithdrawalReason] = useState('User requested withdrawal');
@ -52,12 +34,8 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
const fetchDetails = async () => {
try {
setLoading(true);
const [data, audits] = await Promise.all([
resignationService.getResignationById(resignationId),
fetchAuditLogs(resignationId)
]);
const data = await resignationService.getResignationById(resignationId);
setDetails(data);
setAuditLogs(audits);
} catch (error) {
console.error('Failed to fetch resignation details:', error);
toast.error('Unable to load resignation details');
@ -71,53 +49,15 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
}
}, [resignationId]);
const fetchAuditLogs = async (id: string) => {
try {
// Lazy import through existing API helper shape used in other modules.
const { API } = await import('@/api/API');
const response = await API.getAuditLogs('resignation', id) as any;
if (response?.data?.success) return response.data.data || [];
return [];
} catch (error) {
return [];
}
};
const refreshDetails = async () => {
try {
const [data, audits] = await Promise.all([
resignationService.getResignationById(resignationId),
fetchAuditLogs(resignationId)
]);
const data = await resignationService.getResignationById(resignationId);
setDetails(data);
setAuditLogs(audits);
} catch (error) {
toast.error('Unable to refresh resignation details');
}
};
const handleUpload = async () => {
if (!uploadFile) {
toast.error('Please choose a file');
return;
}
try {
setUploading(true);
const formData = new FormData();
formData.append('file', uploadFile);
formData.append('documentType', uploadDocType);
if (uploadStage) formData.append('stage', uploadStage);
await resignationService.uploadDocument(resignationId, formData);
toast.success('Document uploaded successfully');
setUploadFile(null);
await refreshDetails();
} catch (error: any) {
toast.error(error?.response?.data?.message || 'Document upload failed');
} finally {
setUploading(false);
}
};
const handleWithdraw = async () => {
try {
setWithdrawing(true);
@ -135,7 +75,7 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
if (loading) {
return (
<div className="min-h-[320px] flex items-center justify-center">
<Clock className="w-8 h-8 animate-spin text-amber-600" />
<Clock className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -156,9 +96,6 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
);
}
const docs = details.uploadedDocuments || [];
const timeline = Array.isArray(details.timeline) ? details.timeline : [];
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
@ -169,13 +106,13 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
<div className="flex-1">
<h1 className="text-slate-900">Resignation Request Details</h1>
<p className="text-slate-600 text-sm">
Track your request progress and uploaded documents
Review your resignation request details
</p>
</div>
{details.status !== 'Withdrawn' &&
details.status !== 'Completed' &&
details.status !== 'Rejected' &&
!['NBH', 'DD Admin', 'Legal', 'F&F Initiated'].includes(details.currentStage) && (
!['NBH', 'DD Admin', 'Legal', 'Awaiting F&F', 'F&F Initiated'].includes(details.currentStage) && (
<Button
variant="destructive"
className="bg-red-600 hover:bg-red-700"
@ -224,26 +161,16 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="w-5 h-5 text-amber-600" />
<CheckCircle2 className="w-5 h-5 text-re-red" />
Request Summary
</CardTitle>
<CardDescription>Current request status and key metadata</CardDescription>
<CardDescription>Key details about your resignation request</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-xs text-slate-500">Request ID</p>
<p className="text-slate-900">{details.resignationId || details.id}</p>
</div>
<div>
<p className="text-xs text-slate-500">Status</p>
<Badge className={`border ${getStatusColor(details.status || 'Pending')}`}>
{details.status || 'Pending'}
</Badge>
</div>
<div>
<p className="text-xs text-slate-500">Current Stage</p>
<p className="text-slate-900">{details.currentStage || 'Submitted'}</p>
</div>
<div>
<p className="text-xs text-slate-500">Submitted On</p>
<p className="text-slate-900">{formatDateTime(details.submittedOn || details.createdAt)}</p>
@ -252,10 +179,6 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
<p className="text-xs text-slate-500">Resignation Type</p>
<p className="text-slate-900">{details.resignationType || 'N/A'}</p>
</div>
<div>
<p className="text-xs text-slate-500">Progress</p>
<p className="text-slate-900">{details.progressPercentage || 0}%</p>
</div>
</CardContent>
</Card>
@ -296,164 +219,6 @@ export function DealerResignationDetailsPage({ resignationId, onBack }: DealerRe
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-purple-600" />
Uploaded Documents
</CardTitle>
<CardDescription>Dealer can upload resignation-related documents for review</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-4 p-3 border rounded-lg bg-slate-50">
<div>
<Label className="text-xs">Document Type</Label>
<Select value={uploadDocType} onValueChange={setUploadDocType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESIGNATION_DOCUMENT_TYPES.map((docType) => (
<SelectItem key={docType} value={docType}>{docType}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">Stage (Optional)</Label>
<Select value={uploadStage || 'none'} onValueChange={(v) => setUploadStage(v === 'none' ? '' : v)}>
<SelectTrigger>
<SelectValue placeholder="Select stage" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Stage</SelectItem>
{RESIGNATION_STAGE_OPTIONS.map((stage) => (
<SelectItem key={stage} value={stage}>{stage}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs">File</Label>
<input
type="file"
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
/>
</div>
<div className="flex items-end">
<Button className="w-full" onClick={handleUpload} disabled={uploading}>
<Upload className="w-4 h-4 mr-2" />
{uploading ? 'Uploading...' : 'Upload'}
</Button>
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Document Type</TableHead>
<TableHead>File</TableHead>
<TableHead>Uploaded By</TableHead>
<TableHead>Uploaded On</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{docs.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center text-slate-500 py-6">
No documents uploaded yet.
</TableCell>
</TableRow>
) : (
docs.map((doc: any) => (
<TableRow key={doc.id}>
<TableCell>{doc.documentType || '-'}</TableCell>
<TableCell>{doc.fileName || '-'}</TableCell>
<TableCell>{doc.uploader?.fullName || '-'}</TableCell>
<TableCell>{formatDateTime(doc.createdAt)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<MessageSquare className="w-5 h-5 text-blue-600" />
Work Notes Communication
</CardTitle>
<CardDescription>Official channel for internal-dealer clarifications</CardDescription>
</CardHeader>
<CardContent>
<Button
variant="outline"
onClick={() =>
navigate(`/worknotes/resignation/${resignationId}`, {
state: {
applicationName: details?.dealer?.fullName || 'Resignation Request',
registrationNumber: details?.resignationId || resignationId
}
})
}
>
<MessageSquare className="w-4 h-4 mr-2" />
Open Work Notes
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-amber-600" />
Progress Timeline
</CardTitle>
</CardHeader>
<CardContent>
{timeline.length === 0 ? (
<p className="text-sm text-slate-500">No timeline events available yet.</p>
) : (
<div className="space-y-2">
{timeline.slice().reverse().map((entry: any, idx: number) => (
<div key={`${entry.timestamp || entry.createdAt}-${idx}`} className="p-3 border rounded-lg bg-slate-50">
<p className="text-sm text-slate-900">{entry.action || entry.stage || 'Stage Update'}</p>
<p className="text-xs text-slate-500">{formatDateTime(entry.timestamp || entry.createdAt)}</p>
<p className="text-xs text-slate-600">{entry.comments || entry.remarks || 'No remarks'}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Audit Trail</CardTitle>
<CardDescription>Traceability of status/actions on this request</CardDescription>
</CardHeader>
<CardContent>
{auditLogs.length === 0 ? (
<p className="text-sm text-slate-500">No audit records found.</p>
) : (
<div className="space-y-2">
{auditLogs.map((log: any) => (
<div key={log.id} className="p-3 border rounded-lg">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-900">{log.action || 'Action'}</p>
<p className="text-xs text-slate-500">{formatDateTime(log.createdAt || log.timestamp)}</p>
</div>
<p className="text-xs text-slate-600 mt-1">{log.remarks || log.description || 'No remarks'}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -20,13 +20,6 @@ interface DealerResignationPageProps {
onViewDetails?: (id: string) => void;
}
const getStatusColor = (status: string) => {
if (status === 'Completed') return 'bg-green-100 text-green-700 border-green-300';
if (status.includes('Review') || status.includes('Pending')) return 'bg-yellow-100 text-yellow-700 border-yellow-300';
if (status.includes('Rejected')) return 'bg-red-100 text-red-700 border-red-300';
return 'bg-slate-100 text-slate-700 border-slate-300';
};
export function DealerResignationPage({ onViewDetails }: DealerResignationPageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedOutlet, setSelectedOutlet] = useState<any | null>(null);
@ -134,7 +127,7 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
title: 'Pending Resignations',
value: outlets.filter(o => o.status === 'Pending Resignation').length,
icon: Clock,
color: 'bg-amber-500',
color: 'bg-re-red',
},
];
@ -143,7 +136,7 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
{/* Loading Overlay */}
{loading && (
<div className="min-h-[400px] flex items-center justify-center">
<Loader2 className="w-8 h-8 text-amber-600 animate-spin" />
<Loader2 className="w-8 h-8 text-re-red animate-spin" />
</div>
)}
@ -212,7 +205,7 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
outlet.status === 'Active'
? 'bg-green-100 text-green-700 border-green-300'
: outlet.status === 'Pending Resignation'
? 'bg-amber-100 text-amber-700 border-amber-300'
? 'bg-red-50 text-re-red border-red-200'
: 'bg-slate-100 text-slate-700 border-slate-300'
}`}
>
@ -236,8 +229,8 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
</div>
{hasActiveResignation ? (
<div className="bg-amber-50 border border-amber-200 rounded p-3 text-sm">
<p className="text-amber-800">
<div className="bg-red-50 border border-red-200 rounded p-3 text-sm">
<p className="text-slate-800">
Resignation in progress - <span className="underline cursor-pointer" onClick={() => onViewDetails && resignation?.resignationId && onViewDetails(resignation.resignationId)}>View Request</span>
</p>
</div>
@ -362,9 +355,9 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
</div>
{/* Important Info */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-amber-900 mb-2">Important Information</h4>
<ul className="text-amber-800 text-sm space-y-1">
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="text-re-red mb-2 font-semibold">Important Information</h4>
<ul className="text-slate-700 text-sm space-y-1">
<li> F&F settlement process will be initiated after submission</li>
<li> All department clearances must be obtained</li>
<li> Final settlement will be processed after closure</li>
@ -407,7 +400,7 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
<CardHeader>
<CardTitle>My Resignation Requests</CardTitle>
<CardDescription>
Track the progress of your resignation requests
View your submitted resignation requests
</CardDescription>
</CardHeader>
<CardContent>
@ -418,15 +411,13 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
<TableHead>Outlet</TableHead>
<TableHead>Type</TableHead>
<TableHead>Submitted On</TableHead>
<TableHead>Status</TableHead>
<TableHead>Progress</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{resignations.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="text-center py-4 text-slate-500">
<TableCell colSpan={5} className="text-center py-4 text-slate-500">
No resignation requests found
</TableCell>
</TableRow>
@ -437,22 +428,6 @@ export function DealerResignationPage({ onViewDetails }: DealerResignationPagePr
<TableCell>{request.outlet?.name}</TableCell>
<TableCell>{request.resignationType}</TableCell>
<TableCell>{formatDateTime(request.submittedOn)}</TableCell>
<TableCell>
<Badge className={`border ${getStatusColor(request.status)}`}>
{request.status}
</Badge>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<div className="flex-1 bg-slate-200 rounded-full h-2 min-w-[60px]">
<div
className="bg-red-600 h-2 rounded-full"
style={{ width: `${request.progressPercentage}%` }}
/>
</div>
<span className="text-xs text-slate-600">{request.progressPercentage}%</span>
</div>
</TableCell>
<TableCell>
<Button
variant="ghost"

View File

@ -1,4 +1,4 @@
import { ArrowLeft, Check, X, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, Send, AlertCircle, Loader2, Upload, Ban } from 'lucide-react';
import { ArrowLeft, Check, X, RotateCcw, UserPlus, MessageSquare, FileText, Calendar, Send, AlertCircle, Loader2, Upload, Ban, Mail } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@ -9,15 +9,22 @@ import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useState, useEffect } from 'react';
import { Separator } from '@/components/ui/separator';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner';
import { resignationService } from '@/services/resignation.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { API } from '@/api/API';
import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '@/components/ui/utils';
import {
formatOffboardingStatusLabel,
LAST_WORKING_DAY_LABEL
} from '@/lib/offboardingDisplay';
import { RESIGNATION_DOCUMENT_TYPES, RESIGNATION_STAGE_OPTIONS } from '@/lib/offboardingDocumentOptions';
import { WIDE_DIALOG_CLASS } from '@/lib/dialogStyles';
@ -35,6 +42,7 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
'RBM': 'RBM',
'ZBH': 'ZBH',
'DD Lead': 'DD Lead',
'DD Head': 'DD Head',
'NBH': 'NBH',
'DD Admin': 'DD Admin',
'Legal': 'Legal Admin'
@ -44,12 +52,15 @@ const STAGE_TO_ROLE_MAP: Record<string, string> = {
const TERMINAL_STAGE_LABELS = ['REJECTED', 'Rejected', 'REVOKED', 'Revoked', 'WITHDRAWN', 'Withdrawn'];
const RESIGNATION_STAGE_ALIASES: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Submission', 'Submitted'],
'RBM': ['RBM', 'RBM Review', 'Regional Review'],
'Request Submitted': ['Submission', 'Submitted', 'Initiation'],
'ASM': ['ASM', 'ASM Review'],
'RBM': ['RBM', 'RBM Review', 'Regional Review', 'RBM + DD-ZM Review'],
'ZBH': ['ZBH', 'ZBH Review', 'ZM Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'DDL Review'],
'DD Head': ['DD Head', 'DD Head Review', 'DDH Review'],
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
'DD Admin': ['DD Admin', 'DD Admin Review'],
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
'Legal': ['Legal', 'Legal - Resignation Letter'],
'F&F Initiated': ['F&F Initiated', 'FNF_INITIATED', 'FNF Initiated'],
'Completed': ['Completed']
@ -79,7 +90,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
};
const navigate = useNavigate();
const [actionDialog, setActionDialog] = useState<{ open: boolean, type: 'approve' | 'reject' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | null }>({ open: false, type: null });
const [actionDialog, setActionDialog] = useState<{ open: boolean, type: 'approve' | 'reject' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'dispatch' | null }>({ open: false, type: null });
const [remarks, setRemarks] = useState('');
const [assignToUser, setAssignToUser] = useState<string>('');
const [userSearchQuery, setUserSearchQuery] = useState('');
@ -89,6 +100,13 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const [forceTriggerFnF, setForceTriggerFnF] = useState(false);
const [stageDocumentsDialog, setStageDocumentsDialog] = useState<{ open: boolean; stageName: string; documents: any[] }>({ open: false, stageName: '', documents: [] });
const [resignationData, setResignationData] = useState<any>(null); // Real data from API
// URL slug may be the human-readable code (e.g. `RES-...`); SLA expects UUID.
// Feed the SLA hook the resolved UUID once the request has loaded.
const slaEntityId: string = resignationData?.id || '';
const { get: getSla } = useSlaBatchStatus(
slaEntityId ? [{ entityType: 'resignation', entityId: slaEntityId }] : [],
Boolean(slaEntityId)
);
const [isLoading, setIsLoading] = useState(false);
const [auditLogs, setAuditLogs] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@ -97,6 +115,16 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadDocType, setUploadDocType] = useState<string>(RESIGNATION_DOCUMENT_TYPES[0]);
const [uploadStage, setUploadStage] = useState('');
const hasUploadedPPT = useMemo(() => {
const allDocs = [
...(resignationData?.documents || []),
...(resignationData?.uploadedDocuments || [])
];
return allDocs.some(doc =>
(doc.documentType || doc.type) === 'PPT Presentation'
);
}, [resignationData]);
const fetchResignation = async () => {
try {
setIsLoading(true);
@ -127,18 +155,21 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
// Progress stages logic based on live data
const progressStages = [
{ id: 1, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 2, name: 'RBM Review', key: 'RBM', description: 'Regional Business Manager evaluation' },
{ id: 3, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 4, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead final review' },
{ id: 5, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 6, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification' },
{ id: 7, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ id: 8, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
{ id: 9, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
{ id: 1, name: 'Request Submitted', key: 'Request Submitted', description: 'Dealer submitted the resignation request' },
{ id: 2, name: 'ASM Review', key: 'ASM', description: 'Area Sales Manager review' },
{ id: 3, name: 'RBM + DD-ZM Review', key: 'RBM', description: 'Joint approval by Regional Business Manager and DD-ZM' },
{ id: 4, name: 'ZBH Review', key: 'ZBH', description: 'Zonal Business Head approval' },
{ id: 5, name: 'DD Lead Review', key: 'DD Lead', description: 'DD Lead consolidated review' },
{ id: 6, name: 'DD Head Review', key: 'DD Head', description: 'DD Head final dealer development approval' },
{ id: 7, name: 'NBH Approval', key: 'NBH', description: 'National Business Head approval' },
{ id: 8, name: 'Legal - Resignation Letter', key: 'Legal', description: 'Legal team issues resignation approval letter' },
{ id: 9, name: 'DD Admin Review', key: 'DD Admin', description: 'DD Admin verification and final closure' },
{ id: 10, name: 'Awaiting F&F', key: 'Awaiting F&F', description: 'Internal review complete — start Full & Final using Push to F&F when ready' },
{ id: 11, name: 'F&F Settlement', key: 'F&F Initiated', description: 'Full & Final settlement process' },
{ id: 12, name: 'Completed', key: 'Completed', description: 'Resignation process finalized' }
];
const stagesOrdered = ['ASM', 'RBM', 'ZBH', 'DD Lead', 'NBH', 'DD Admin', 'Legal', 'F&F Initiated', 'Completed'];
const stagesOrdered = ['Request Submitted', 'ASM', 'RBM', 'ZBH', 'DD Lead', 'DD Head', 'NBH', 'Legal', 'DD Admin', 'Awaiting F&F', 'F&F Initiated', 'Completed'];
const legalStageApproved = (() => {
if (!resignationData) return false;
@ -157,6 +188,8 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const atLegal = stage === 'legal' || stage === 'legal - resignation letter';
const legalApprovedTransition =
targetStage === 'legal' ||
targetStage === 'dd admin' ||
targetStage === 'awaiting f&f' ||
targetStage === 'f&f initiated' ||
targetStage === 'fnf_initiated' ||
action.includes('approved');
@ -167,14 +200,23 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const getResignationPermissions = () => {
if (!resignationData || !currentUser) {
return { canApprove: false, canWithdraw: false, canSendBack: false, canPushToFnF: false, canAssign: false };
return { canApprove: false, canDispatch: false, dispatchMissed: false, canWithdraw: false, canSendBack: false, canPushToFnF: false, canAssign: false };
}
const currentStage = resignationData.currentStage;
const status = resignationData.status;
const userRole = currentUser.role;
// Final states where no more actions are possible
const isZmRbmStage = currentStage === 'RBM' || currentStage === 'RBM Review' || currentStage === 'RBM + DD-ZM Review';
const userRoleCode = String(currentUser.roleCode || currentUser.role || '').trim().toUpperCase();
// Check if current user already partially approved this request at this stage
const hasAlreadyPartiallyApproved = isZmRbmStage && auditLogs.some(log =>
log.action === 'PARTIAL_APPROVE' &&
(log.actor?.id === currentUser.id || log.actorId === currentUser.id || log.actor?.email === currentUser.email || log.userEmail === currentUser.email) &&
(log.details?.roleCode === userRoleCode || (log.details?.roleCode === 'DD-ZM' && userRoleCode === 'DD ZM'))
);
const isFinalState = ['Completed', 'Rejected', 'Withdrawn', 'Revoked'].includes(status);
// Check if it's already in the settlement phase
@ -184,33 +226,148 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
const nbhIndex = stagesOrdered.indexOf('NBH');
const isPastNBH = stageIndex !== -1 && nbhIndex !== -1 && stageIndex >= nbhIndex;
const isCurrentlyAssigned = userRole === 'Super Admin' || userRole === STAGE_TO_ROLE_MAP[currentStage];
const isCurrentlyAssigned = userRoleCode === 'SUPER_ADMIN' ||
(isZmRbmStage && (userRoleCode === 'RBM' || userRoleCode === 'DD-ZM' || userRoleCode === 'DD ZM')) ||
userRole === STAGE_TO_ROLE_MAP[currentStage];
const isDDLeadStage = currentStage === 'DD Lead' || currentStage === 'DD Lead Review';
const isDDLead = userRoleCode === 'DD_LEAD' || userRoleCode === 'DD LEAD';
const isLwdReached = (() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lwdString = resignationData.lastOperationalDateServices || resignationData.lastOperationalDateSales;
if (!lwdString) return true;
const lwd = new Date(lwdString);
lwd.setHours(0, 0, 0, 0);
return today >= lwd;
})();
const isAwaitingFnfGate = currentStage === 'Awaiting F&F';
const resolvedStageKey = (() => {
const normalized = String(currentStage || '').trim();
const matched = stagesOrdered.find(
(key) => key === normalized || (RESIGNATION_STAGE_ALIASES[key] || []).includes(normalized)
);
return matched || normalized;
})();
const isNbHApprovalStep = resolvedStageKey === 'NBH';
const isAwaitingFnfStep = resolvedStageKey === 'Awaiting F&F';
/** Legacy rows only: acceptance letter at Legal before DD Admin completed Awaiting F&F gate */
const isLegalLegacyFnfStep = resolvedStageKey === 'Legal';
const fnfPushRoles = ['DD Lead', 'DD Head', 'DD Admin', 'Super Admin'];
const fnfPushLegacyRoles = ['DD Lead', 'DD Head', 'DD Admin', 'Super Admin'];
// Dispatch action — Legal uploaded the acceptance letter and DD Admin (or
// Super Admin) must dispatch a formal copy to the dealer. The button stays
// visible from the DD Admin step through Awaiting F&F / F&F Initiated so
// an admin who skipped the step earlier can still send the letter
// retroactively. Once dispatched it disappears (audit log is the source of
// truth) so we don't re-send duplicates.
const isDDAdminStage = currentStage === 'DD Admin' || currentStage === 'DD Admin Review';
const isDDAdmin = userRoleCode === 'DD_ADMIN' || userRole === 'DD Admin';
const isSuperAdmin = userRoleCode === 'SUPER_ADMIN' || userRole === 'Super Admin';
const allResignationDocs: any[] = [
...(resignationData.documents || []),
...(resignationData.uploadedDocuments || [])
];
const hasAcceptanceLetter = allResignationDocs.some((doc: any) => {
const docType = String(doc?.documentType || doc?.type || '').toLowerCase();
const docStage = String(doc?.stage || '').toLowerCase();
return docType.includes('acceptance letter') || docStage === 'legal';
});
const hasBeenDispatched =
auditLogs.some((log: any) => {
const action = String(log?.action || '').toUpperCase();
const desc = String(log?.description || log?.details?.action || '').toLowerCase();
return (
action === 'RESIGNATION_LETTER_DISPATCHED' ||
desc.includes('resignation letter dispatched')
);
}) ||
(resignationData.timeline || []).some((entry: any) =>
String(entry?.action || '').toLowerCase().includes('resignation letter dispatched')
);
const ddAdminIdx = stagesOrdered.indexOf('DD Admin');
const completedIdx = stagesOrdered.indexOf('Completed');
const currentStageIdx = stagesOrdered.indexOf(
stagesOrdered.find(
(key) => key === currentStage || (RESIGNATION_STAGE_ALIASES[key] || []).includes(currentStage)
) || currentStage
);
const isAtOrAfterDDAdmin =
ddAdminIdx !== -1 &&
currentStageIdx !== -1 &&
currentStageIdx >= ddAdminIdx &&
(completedIdx === -1 || currentStageIdx < completedIdx);
const canDispatch =
isAtOrAfterDDAdmin &&
(isDDAdmin || isSuperAdmin) &&
hasAcceptanceLetter &&
!hasBeenDispatched &&
!isFinalState;
const dispatchMissed = canDispatch && !isDDAdminStage;
const canApprove = isCurrentlyAssigned &&
!isFinalState &&
!isSettlementPhase &&
!(currentStage === 'Legal' && legalStageApproved);
!hasAlreadyPartiallyApproved &&
!(currentStage === 'Legal' && legalStageApproved) &&
!(isDDLead && isDDLeadStage && !hasUploadedPPT) &&
!(currentStage === 'DD Admin' && !isLwdReached) &&
// Dispatch replaces Approve at the DD Admin stage so the admin must
// explicitly send the acceptance letter to the dealer.
!isDDAdminStage &&
!isAwaitingFnfGate;
return {
canApprove,
canSendBack: isCurrentlyAssigned && !isFinalState && !isSettlementPhase && stageIndex > 0,
canDispatch,
dispatchMissed,
// SRS §7.3.2: Send Back returns to DD Admin for correction. Legal Admin only drafts/uploads
// the Resignation Acceptance Letter and cannot send the case back to earlier reviewers.
canSendBack:
isCurrentlyAssigned &&
!isFinalState &&
!isSettlementPhase &&
stageIndex > 0 &&
userRole !== 'Legal Admin' &&
userRoleCode !== 'LEGAL_ADMIN',
canWithdraw: userRole === 'Dealer' && !isPastNBH && !isFinalState,
canRevoke: (userRole === 'Super Admin' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
canPushToFnF: ['DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Super Admin'].includes(userRole) &&
!isSettlementPhase && !isFinalState,
canRevoke: (userRoleCode === 'SUPER_ADMIN' || userRole === 'DD Admin') && !isFinalState && !isSettlementPhase,
// Push to F&F: after DD Admin gate only — not during NBH (or any earlier) approval.
// Roles: DD Lead / DD Head / DD Admin / Super Admin (not NBH).
canPushToFnF:
fnfPushRoles.includes(userRole) &&
!isSettlementPhase &&
!isFinalState &&
!isNbHApprovalStep &&
isLwdReached &&
(isAwaitingFnfStep || (isLegalLegacyFnfStep && fnfPushLegacyRoles.includes(userRole))),
canAssign: userRole !== 'Dealer' && !isFinalState
};
};
const permissions = getResignationPermissions();
const isNationalLevel = ['Super Admin', 'DD Lead', 'DD Head', 'NBH', 'DD Admin', 'Legal Admin', 'DD-ZM'].includes(currentUser?.role || '');
const stageAliases: Record<string, string[]> = {
'ASM': ['ASM', 'ASM Review', 'Request Initiated'],
'RBM': ['RBM', 'RBM Review'],
'RBM': ['RBM', 'RBM Review', 'RBM + DD-ZM Review'],
'ZBH': ['ZBH', 'ZBH Review'],
'DD Lead': ['DD Lead', 'DD Lead Review', 'Lead Review'],
'DD Head': ['DD Head', 'DD Head Review', 'Head Review'],
'NBH': ['NBH', 'NBH Approval', 'NBH Review'],
'DD Admin': ['DD Admin', 'DD Admin Review'],
'Awaiting F&F': ['Awaiting F&F', 'Awaiting F&F — manual initiation'],
'Legal': ['Legal', 'Legal - Resignation Letter', 'Legal Review'],
'F&F Initiated': ['F&F Initiated', 'FNF_INITIATED', 'F&F Settlement', 'Settled'],
'Completed': ['Completed', 'Finalized']
@ -260,7 +417,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
return 'pending';
};
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke') => {
const handleAction = (type: 'approve' | 'withdrawal' | 'sendBack' | 'assign' | 'pushfnf' | 'revoke' | 'dispatch') => {
setActionDialog({ open: true, type });
};
@ -279,7 +436,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
};
const handleSubmitAction = async () => {
if (!remarks && !['assign', 'pushfnf'].includes(actionDialog.type || '')) {
if (!remarks && !['assign', 'pushfnf', 'dispatch'].includes(actionDialog.type || '')) {
toast.error('Please provide remarks (min 5 characters)');
return;
}
@ -322,7 +479,9 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
// When Legal approval bumps into LWD gate for F&F initiation, guide user explicitly.
if (response.data?.canForce) {
toast.info('LWD restriction hit. Use "Push to F&F" and enable "Force Initiate F&F Settlement Immediately" if urgent.');
toast.info(
`${LAST_WORKING_DAY_LABEL} restriction: use "Push to F&F" and enable "Force Initiate F&F Settlement Immediately" if urgent.`
);
}
}
} catch (error: any) {
@ -330,7 +489,9 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
toast.error(error.response?.data?.message || 'Failed to submit action');
if (error?.response?.data?.canForce) {
toast.info('LWD restriction hit. Use "Push to F&F" with force option if business-approved.');
toast.info(
`${LAST_WORKING_DAY_LABEL} restriction: use "Push to F&F" with the force option if business-approved.`
);
}
} finally {
setIsSubmitting(false);
@ -409,7 +570,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
if (isLoading && !resignationData) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<Loader2 className="w-8 h-8 animate-spin text-amber-600" />
<Loader2 className="w-8 h-8 animate-spin text-re-red" />
</div>
);
}
@ -433,139 +594,74 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
? 'bg-red-100 text-red-700 border-red-300'
: 'bg-yellow-100 text-yellow-700 border-yellow-300'
}>
{resignationData?.status === 'Settled' ? 'Completed' : (resignationData?.status || 'Pending')}
{resignationData?.status === 'Settled'
? 'Completed'
: formatOffboardingStatusLabel(resignationData?.status || 'Pending')}
</Badge>
<SlaBadge status={getSla('resignation', slaEntityId)} />
</div>
</div>
{/* Action Bar - Professional Layout */}
<Card className="border-slate-200 shadow-sm">
<CardContent className="pt-6">
<div className="flex flex-col gap-4">
{/* Primary Actions Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-slate-600 mr-2">Workflow Actions:</span>
{permissions.canApprove && (
<Button
size="sm"
disabled={isSubmitting}
className="bg-green-600 hover:bg-green-700 transition-all hover:shadow-md"
onClick={() => handleAction('approve')}
>
{isSubmitting && actionDialog.type === 'approve' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
Approve
</Button>
)}
{permissions.canSendBack && (
<Button
size="sm"
variant="outline"
disabled={isSubmitting}
className="hover:bg-slate-50 transition-all font-bold"
onClick={() => handleAction('sendBack')}
>
{isSubmitting && actionDialog.type === 'sendBack' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RotateCcw className="w-4 h-4 mr-2" />}
Send Back
</Button>
)}
{permissions.canWithdraw && (
<Button
size="sm"
variant="outline"
disabled={isSubmitting}
className="text-red-600 border-red-300 hover:bg-red-50 transition-all font-bold"
onClick={() => handleAction('withdrawal')}
>
{isSubmitting && actionDialog.type === 'withdrawal' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <X className="w-4 h-4 mr-2" />}
Withdrawal
</Button>
)}
{permissions.canRevoke && (
<Button
size="sm"
variant="outline"
disabled={isSubmitting}
className="text-orange-600 border-orange-300 hover:bg-orange-50 transition-all font-bold"
onClick={() => handleAction('revoke')}
>
{isSubmitting && actionDialog.type === 'revoke' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Ban className="w-4 h-4 mr-2" />}
Revoke
</Button>
)}
</div>
{/* Secondary Actions */}
<div className="flex items-center gap-2">
{permissions.canPushToFnF && (
<Button
size="sm"
variant="outline"
disabled={isSubmitting}
className="text-amber-600 border-blue-300 hover:bg-blue-50 transition-all"
onClick={() => handleAction('pushfnf')}
>
{isSubmitting && actionDialog.type === 'pushfnf' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
Push to F&F
</Button>
)}
{permissions.canAssign && (
<Button
size="sm"
variant="outline"
disabled={isSubmitting}
className="hover:bg-slate-50 transition-all"
onClick={() => handleAction('assign')}
>
{isSubmitting && actionDialog.type === 'assign' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UserPlus className="w-4 h-4 mr-2" />}
Assign User
</Button>
)}
</div>
</div>
{/* Work Notes Button - Independent Section */}
<div className="flex items-center justify-between pt-4 border-t border-slate-200">
<div className="flex items-center gap-2">
<MessageSquare className="w-4 h-4 text-slate-500" />
<span className="text-sm text-slate-600">Communication & Notes</span>
</div>
<Button
size="sm"
variant="outline"
className="relative hover:bg-amber-50 hover:border-amber-300 hover:text-amber-700 transition-all shadow-sm"
onClick={() => navigate(`/worknotes/resignation/${resignationId}`, {
state: {
applicationName: resignationData?.outlet?.name || 'Resignation',
registrationNumber: resignationData?.resignationId || '',
participants: resignationData?.participants || []
}
})}
>
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
{resignationData?.worknotes?.length > 0 && (
<Badge className="ml-2 bg-amber-600 hover:bg-amber-700 text-white h-5 px-2">
{resignationData.worknotes.length}
</Badge>
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Tabs */}
<Tabs defaultValue="details" className="w-full">
<TabsList className="bg-slate-100 p-1">
<TabsTrigger value="details" className="data-[state=active]:bg-white">Details</TabsTrigger>
<TabsTrigger value="progress" className="data-[state=active]:bg-white">Progress</TabsTrigger>
<TabsTrigger value="documents" className="data-[state=active]:bg-white">Documents</TabsTrigger>
<TabsTrigger value="audit" className="data-[state=active]:bg-white">Audit Trail</TabsTrigger>
{isNationalLevel && (
<TabsTrigger value="approvals" className="data-[state=active]:bg-white">Approval Summary</TabsTrigger>
)}
</TabsList>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
<div className="lg:col-span-2 space-y-6">
{/* Details Tab */}
<TabsContent value="details" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Resignation Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-600">Resignation Type</Label>
<p>{resignationData?.resignationType}</p>
</div>
<div>
<Label className="text-slate-600">Reason</Label>
<p>{resignationData?.reason}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-600">Last Operational Date (Sales)</Label>
<p>{resignationData?.lastOperationalDateSales ? formatDateTime(resignationData.lastOperationalDateSales, 'date') : 'N/A'}</p>
</div>
<div>
<Label className="text-slate-600">Last Operational Date (Services)</Label>
<p>{resignationData?.lastOperationalDateServices ? formatDateTime(resignationData.lastOperationalDateServices, 'date') : 'N/A'}</p>
</div>
</div>
<div>
<Label className="text-slate-600">Additional Info / Dealer Voice</Label>
<p>{resignationData?.additionalInfo || 'No additional info provided'}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-600">Submitted On</Label>
<p>{resignationData?.submittedOn ? formatDateTime(resignationData.submittedOn) : 'N/A'}</p>
</div>
<div>
<Label className="text-slate-600">Current Stage</Label>
<p>{resignationData?.currentStage}</p>
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Request Information</CardTitle>
@ -637,51 +733,6 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Resignation Details</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-600">Resignation Type</Label>
<p>{resignationData?.resignationType}</p>
</div>
<div>
<Label className="text-slate-600">Reason</Label>
<p>{resignationData?.reason}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-600">Last Operational Date (Sales)</Label>
<p>{resignationData?.lastOperationalDateSales ? formatDateTime(resignationData.lastOperationalDateSales, 'date') : 'N/A'}</p>
</div>
<div>
<Label className="text-slate-600">Last Operational Date (Services)</Label>
<p>{resignationData?.lastOperationalDateServices ? formatDateTime(resignationData.lastOperationalDateServices, 'date') : 'N/A'}</p>
</div>
</div>
<div>
<Label className="text-slate-600">Additional Info / Dealer Voice</Label>
<p>{resignationData?.additionalInfo || 'No additional info provided'}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-600">Submitted On</Label>
<p>{resignationData?.submittedOn ? formatDateTime(resignationData.submittedOn) : 'N/A'}</p>
</div>
<div>
<Label className="text-slate-600">Current Stage</Label>
<p>{resignationData?.currentStage}</p>
</div>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* Progress Tab */}
@ -708,7 +759,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="flex flex-col items-center">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
status === 'completed' ? 'bg-green-100 text-green-600' :
status === 'active' ? 'bg-blue-100 text-amber-600' :
status === 'active' ? 'bg-blue-100 text-re-red' :
'bg-slate-100 text-slate-400'
}`}>
{status === 'completed' ? (
@ -728,13 +779,13 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<div className="flex items-center gap-2">
<h3 className={
status === 'completed' ? 'text-green-600' :
status === 'active' ? 'text-amber-600' :
status === 'active' ? 'text-re-red' :
'text-slate-400'
}>{stage.name}</h3>
{stageDocumentCount > 0 && (
<button
onClick={() => handleViewStageDocuments(stage.name, stage.key)}
className="flex items-center gap-1 px-2 py-1 rounded-full bg-amber-100 hover:bg-amber-200 text-amber-700 text-xs transition-colors cursor-pointer"
className="flex items-center gap-1 px-2 py-1 rounded-full bg-red-50 hover:bg-red-100 text-re-red-hover text-xs transition-colors cursor-pointer"
>
<FileText className="w-3 h-3" />
<span>{stageDocumentCount} {stageDocumentCount === 1 ? 'doc' : 'docs'}</span>
@ -750,20 +801,28 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
<p className="text-slate-600 text-sm mb-1">{stage.description}</p>
{timelineEntry && (
<div className="space-y-2">
{stageTimelineEntries.length > 0 && (
<div className="space-y-4 mt-3">
{stageTimelineEntries.map((entry: any, i: number) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="bg-slate-100 text-[10px] font-bold uppercase">
{timelineEntry.user || 'System'}
{entry.user || 'System'}
</Badge>
<span className="text-[10px] text-slate-500 italic">
{timelineEntry.action}
{entry.action}
</span>
<span className="text-[10px] text-slate-400 ml-auto">
{formatDateTime(entry.timestamp || entry.createdAt)}
</span>
</div>
<div className="bg-slate-50 p-3 rounded-lg border border-slate-100 text-sm text-slate-700 shadow-sm">
{timelineEntry.comments || timelineEntry.remarks || 'No remarks provided.'}
{entry.comments || entry.remarks || 'No remarks provided.'}
</div>
</div>
))}
</div>
)}
</div>
</div>
@ -782,7 +841,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<CardTitle>Documents</CardTitle>
<CardDescription>View and manage resignation documents</CardDescription>
</div>
<Button size="sm" onClick={() => setShowUploadDialog(true)} className="bg-amber-600 hover:bg-amber-700">
<Button size="sm" onClick={() => setShowUploadDialog(true)} className="bg-re-red hover:bg-re-red-hover">
<Upload className="w-4 h-4 mr-2" />
Upload Document
</Button>
@ -907,7 +966,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
${(log.description || log.action || log.details?.action || '').toLowerCase().includes('reject') || (log.description || log.action || log.details?.action || '').toLowerCase().includes('revok')
? 'bg-red-100 text-red-700 border-red-200'
: (log.description || log.action || log.details?.action || '').toLowerCase().includes('sent back') || (log.description || log.action || log.details?.action || '').toLowerCase().includes('send back')
? 'bg-amber-100 text-amber-700 border-amber-200'
? 'bg-red-50 text-re-red-hover border-red-200'
: (log.description || log.action || log.details?.action || '').toLowerCase().includes('approv') || (log.description || log.action || log.details?.action || '').toLowerCase().includes('initi')
? 'bg-emerald-100 text-emerald-700 border-emerald-200'
: 'bg-slate-100 text-slate-700 border-slate-200'}
@ -938,6 +997,223 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</CardContent>
</Card>
</TabsContent>
{/* Approval Summary Tab */}
{isNationalLevel && (
<TabsContent value="approvals">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Approval Summary</CardTitle>
<CardDescription>Comprehensive view of all approvals and remarks</CardDescription>
</div>
{permissions.canApprove && (
<Button onClick={() => handleAction('approve')} className="bg-green-600 hover:bg-green-700">
<Check className="w-4 h-4 mr-2" />
Approve Request
</Button>
)}
</CardHeader>
<CardContent>
<Table className="w-full border-collapse">
<TableHeader>
<TableRow className="bg-slate-50/50">
<TableHead className="min-w-[120px]">Stage</TableHead>
<TableHead className="min-w-[120px]">Approver</TableHead>
<TableHead className="min-w-[200px]">Action</TableHead>
<TableHead className="w-full min-w-[300px]">Remarks</TableHead>
<TableHead className="min-w-[180px] text-right">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(resignationData?.timeline || []).length > 0 ? (
resignationData.timeline.map((entry: any, index: number) => (
<TableRow key={index}>
<TableCell className="font-medium">{entry.stage}</TableCell>
<TableCell>
<Badge variant="outline">{entry.user || 'System'}</Badge>
</TableCell>
<TableCell className="whitespace-normal break-words">{entry.action}</TableCell>
<TableCell className="whitespace-normal break-words">
{entry.remarks || entry.comments || '-'}
</TableCell>
<TableCell className="text-slate-500 whitespace-nowrap text-right">
{formatDateTime(entry.timestamp || entry.createdAt)}
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={5} className="text-center py-6 text-slate-500">
No approval records found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{(() => {
const roleNormalized = String(currentUser?.roleCode || currentUser?.role || '').trim().toUpperCase();
const isDDLeadUser = roleNormalized === 'DD LEAD' || roleNormalized === 'DD_LEAD';
const isDDLeadStageCurrent = ['DD Lead', 'DD Lead Review', 'DDL Review'].includes(resignationData?.currentStage);
if (isDDLeadUser && isDDLeadStageCurrent) {
return (
<Button
variant="outline"
className="w-full text-re-red-hover border-red-300 hover:bg-red-50"
onClick={() => {
setUploadDocType('PPT Presentation');
setUploadStage('DD Lead');
setShowUploadDialog(true);
}}
>
<Upload className="w-4 h-4 mr-2" />
Upload PPT
</Button>
);
}
return null;
})()}
{permissions.canApprove && (
<Button
disabled={isSubmitting}
className="w-full bg-green-600 hover:bg-green-700 font-bold"
onClick={() => handleAction('approve')}
>
{isSubmitting && actionDialog.type === 'approve' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Check className="w-4 h-4 mr-2" />}
Approve
</Button>
)}
{permissions.canDispatch && (
<>
{permissions.dispatchMissed && (
<div className="rounded-md border border-amber-300 bg-amber-50 text-amber-800 px-3 py-2 text-xs flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>
Resignation acceptance letter was not dispatched at the DD Admin step.
Please send it to the dealer now.
</span>
</div>
)}
<Button
disabled={isSubmitting}
className={`w-full font-bold ${
permissions.dispatchMissed
? 'bg-amber-600 hover:bg-amber-700'
: 'bg-re-red hover:bg-re-red-hover'
}`}
onClick={() => handleAction('dispatch')}
>
{isSubmitting && actionDialog.type === 'dispatch' ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Mail className="w-4 h-4 mr-2" />
)}
{permissions.dispatchMissed ? 'Dispatch Resignation Letter (Pending)' : 'Dispatch Resignation Letter'}
</Button>
</>
)}
{permissions.canSendBack && (
<Button
variant="outline"
disabled={isSubmitting}
className="w-full font-bold"
onClick={() => handleAction('sendBack')}
>
{isSubmitting && actionDialog.type === 'sendBack' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <RotateCcw className="w-4 h-4 mr-2" />}
Send Back
</Button>
)}
{permissions.canWithdraw && (
<Button
variant="outline"
disabled={isSubmitting}
className="w-full text-red-600 border-red-300 hover:bg-red-50 font-bold"
onClick={() => handleAction('withdrawal')}
>
{isSubmitting && actionDialog.type === 'withdrawal' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <X className="w-4 h-4 mr-2" />}
Withdrawal
</Button>
)}
{permissions.canRevoke && (
<Button
variant="outline"
disabled={isSubmitting}
className="w-full text-orange-600 border-orange-300 hover:bg-orange-50 font-bold"
onClick={() => handleAction('revoke')}
>
{isSubmitting && actionDialog.type === 'revoke' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Ban className="w-4 h-4 mr-2" />}
Revoke
</Button>
)}
{permissions.canPushToFnF && (
<Button
variant="outline"
disabled={isSubmitting}
className="w-full text-re-red-hover border-red-300 hover:bg-red-50"
onClick={() => handleAction('pushfnf')}
>
{isSubmitting && actionDialog.type === 'pushfnf' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Send className="w-4 h-4 mr-2" />}
Push to F&F
</Button>
)}
{permissions.canAssign && (
<Button
variant="outline"
disabled={isSubmitting}
className="w-full"
onClick={() => handleAction('assign')}
>
{isSubmitting && actionDialog.type === 'assign' ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UserPlus className="w-4 h-4 mr-2" />}
Assign User
</Button>
)}
<Separator />
<Button
variant="outline"
className="w-full"
onClick={() => navigate(`/worknotes/resignation/${resignationId}`, {
state: {
applicationName: resignationData?.outlet?.name || 'Resignation',
registrationNumber: resignationData?.resignationId || '',
participants: resignationData?.participants || []
}
})}
>
<MessageSquare className="w-4 h-4 mr-2" />
View Work Notes
{resignationData?.worknotes?.length > 0 && (
<Badge className="ml-auto bg-re-red hover:bg-re-red-hover text-white h-5 px-2">
{resignationData.worknotes.length}
</Badge>
)}
</Button>
</CardContent>
</Card>
</div>
</div>
</Tabs>
{/* Action Dialogs */}
@ -951,12 +1227,15 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{actionDialog.type === 'revoke' && 'Revoke Resignation Request'}
{actionDialog.type === 'assign' && 'Assign to User'}
{actionDialog.type === 'pushfnf' && 'Push to Full & Final Settlement'}
{actionDialog.type === 'dispatch' && 'Dispatch Resignation Letter'}
</DialogTitle>
<DialogDescription>
{actionDialog.type === 'assign'
? 'Select a user to assign this request to'
: actionDialog.type === 'pushfnf'
? 'This will move the resignation request to F&F for dues clearance'
: actionDialog.type === 'dispatch'
? 'The Legal-issued acceptance letter will be emailed to the dealer and the request will advance to Awaiting F&F. Remarks are optional.'
: 'Please provide remarks for this action'
}
</DialogDescription>
@ -1030,11 +1309,14 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
</div>
) : actionDialog.type === 'pushfnf' ? (
<div className="space-y-4">
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-600 mt-0.5" />
<div className="text-sm text-amber-800">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-re-red mt-0.5" />
<div className="text-sm text-red-800">
<p className="font-bold">Manual Trigger Notice</p>
<p>Normally F&F is triggered after LWD. Use manual trigger only if urgent clearance is required.</p>
<p>
Normally F&F is triggered after the {LAST_WORKING_DAY_LABEL.toLowerCase()}. Use manual
trigger only if urgent clearance is required.
</p>
</div>
</div>
<div className="flex items-center gap-2">
@ -1082,13 +1364,13 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
className={
actionDialog.type === 'approve' ? 'bg-green-600 hover:bg-green-700' :
actionDialog.type === 'withdrawal' ? 'bg-red-600 hover:bg-red-700' :
'bg-amber-600 hover:bg-amber-700'
'bg-re-red hover:bg-re-red-hover'
}
>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Processing...
{actionDialog.type === 'dispatch' ? 'Dispatching...' : 'Processing...'}
</>
) : (
<>
@ -1098,6 +1380,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
{actionDialog.type === 'revoke' && 'Revoke'}
{actionDialog.type === 'assign' && 'Assign'}
{actionDialog.type === 'pushfnf' && 'Push to F&F'}
{actionDialog.type === 'dispatch' && 'Send to Dealer'}
</>
)}
</Button>
@ -1110,7 +1393,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<DialogContent className={WIDE_DIALOG_CLASS}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FileText className="w-5 h-5 text-amber-600" />
<FileText className="w-5 h-5 text-re-red" />
Documents - {stageDocumentsDialog.stageName}
</DialogTitle>
<DialogDescription>
@ -1143,7 +1426,7 @@ export function ResignationDetails({ resignationId, onBack, currentUser }: Resig
<Button
size="sm"
variant="outline"
className="text-amber-600 hover:text-blue-700"
className="text-re-red hover:text-blue-700"
onClick={() => {
if (!doc.filePath) return;
const fullPath = doc.filePath.startsWith('/uploads/') && !doc.filePath.startsWith('/uploads/documents/')

View File

@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useState, useEffect } from 'react';
import { API } from '@/api/API';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { useSlaBatchStatus } from '@/hooks/useSlaBatchStatus';
import { toast } from 'sonner';
import { User as UserType } from '@/lib/mock-data';
import { formatDateTime } from '@/components/ui/utils';
@ -71,6 +73,9 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
const openRequests = statusTab === 'open' ? resignations : [];
const completedRequests = statusTab === 'completed' ? resignations : [];
const slaItems = resignations.map((r: any) => ({ entityType: 'resignation', entityId: r.id }));
const { get: getSla } = useSlaBatchStatus(slaItems, resignations.length > 0);
return (
<div className="space-y-6">
{/* Header Stats */}
@ -141,8 +146,8 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<CardContent className="pt-6">
<div className="flex items-start justify-between">
<div className="flex items-start gap-4 flex-1">
<div className="p-3 bg-amber-100 rounded-lg">
<FileText className="w-6 h-6 text-amber-600" />
<div className="p-3 bg-red-50 rounded-lg">
<FileText className="w-6 h-6 text-re-red" />
</div>
<div className="flex-1 text-left">
<div className="flex items-center gap-3 mb-2">
@ -150,6 +155,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<Badge className={getStatusColor(request.status)}>
{request.status}
</Badge>
<SlaBadge status={getSla('resignation', request.id)} compact />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
@ -276,6 +282,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<Badge className={getStatusColor(request.status)}>
{request.status}
</Badge>
<SlaBadge status={getSla('resignation', request.id)} compact />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
@ -337,6 +344,7 @@ export function ResignationPage({ onViewDetails }: ResignationPageProps) {
<Badge className={getStatusColor(request.status)}>
{request.status}
</Badge>
<SlaBadge status={getSla('resignation', request.id)} compact />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>

File diff suppressed because it is too large Load Diff

View File

@ -8,9 +8,10 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useState, useEffect } from 'react';
import { API } from '@/api/API';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
import { SlaBadge } from '@/components/sla/SlaBadge';
import { formatDateTime } from '@/components/ui/utils';
import {
Pagination,
@ -23,6 +24,11 @@ import {
} from "@/components/ui/pagination";
import { User } from '@/lib/mock-data';
import { toast } from 'sonner';
import {
formatTerminationStatusLabel,
LAST_WORKING_DAY_LABEL,
PROPOSED_LAST_WORKING_DAY_LABEL
} from '@/lib/terminationDisplay';
interface TerminationPageProps {
currentUser: User | null;
@ -51,6 +57,8 @@ const getStatusColor = (status: string) => {
return 'bg-blue-100 text-blue-700 border-blue-300';
};
const formatStatus = formatTerminationStatusLabel;
export function TerminationPage({ currentUser, onViewDetails }: TerminationPageProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [dealers, setDealers] = useState<any[]>([]);
@ -59,6 +67,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
const [dealerCode, setDealerCode] = useState('');
const [autoFilledData, setAutoFilledData] = useState<any>(null);
const [terminations, setTerminations] = useState<any[]>([]);
const [slaById, setSlaById] = useState<Record<string, SlaStatusSnapshot | null>>({});
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
@ -69,7 +78,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
reason: '',
proposedLwd: '',
comments: '',
document: null as File | null
documents: [] as File[]
});
const fetchTerminations = async () => {
@ -84,6 +93,23 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
if (data?.success) {
setTerminations(data.terminations);
setPaginationMeta(data.meta);
const rows = data.terminations || [];
if (rows.length) {
slaService
.getBatchStatus(rows.map((t: any) => ({ entityType: 'termination', entityId: t.id })))
.then((slaRes) => {
if (slaRes?.success) {
const map: Record<string, SlaStatusSnapshot | null> = {};
rows.forEach((t: any) => {
map[t.id] = slaRes.data[`termination:${t.id}`] ?? null;
});
setSlaById(map);
}
})
.catch(() => setSlaById({}));
} else {
setSlaById({});
}
}
} catch (error) {
console.error('Error fetching terminations:', error);
@ -103,7 +129,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
};
useEffect(() => {
if (!isDialogOpen || !isDDLead) return;
if (!isDialogOpen || !canCreateTermination) return;
let cancelled = false;
(async () => {
@ -188,6 +214,41 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
setAutoFilledData(mapDealerToFormData(matchedDealer));
};
const isSuperAdmin = currentUser?.role === 'Super Admin';
const isPresentationMandatory = !isSuperAdmin;
const isPptFile = (file: File) => {
const name = file.name.toLowerCase();
return name.endsWith('.ppt') || name.endsWith('.pptx');
};
const handleFilesPicked = (files: FileList | null, inputEl?: HTMLInputElement | null) => {
if (!files || files.length === 0) return;
setFormData((prev) => {
const existing = prev.documents;
const seen = new Set(existing.map((f) => `${f.name}::${f.size}`));
const additions: File[] = [];
Array.from(files).forEach((file) => {
const key = `${file.name}::${file.size}`;
if (!seen.has(key)) {
seen.add(key);
additions.push(file);
}
});
return { ...prev, documents: [...existing, ...additions] };
});
if (inputEl) inputEl.value = '';
};
const removeDocumentAt = (index: number) => {
setFormData({
...formData,
documents: formData.documents.filter((_, i) => i !== index)
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!autoFilledData) {
@ -195,43 +256,50 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
return;
}
if (isPresentationMandatory) {
if (formData.documents.length === 0) {
toast.error('Please upload at least one Presentation (.ppt or .pptx)');
return;
}
if (!formData.documents.some(isPptFile)) {
toast.error('At least one PowerPoint file (.ppt or .pptx) is required');
return;
}
}
try {
const payload = {
dealerId: autoFilledData.dealerId || autoFilledData.id,
const dealerId = autoFilledData.dealerId || autoFilledData.id;
if (!dealerId) {
toast.error('Dealer record not found for the selected dealer');
return;
}
let requestBody: any;
if (formData.documents.length > 0) {
const fd = new FormData();
fd.append('dealerId', String(dealerId));
fd.append('category', formData.terminationCategory);
fd.append('reason', formData.reason);
fd.append('proposedLwd', formData.proposedLwd);
fd.append('comments', formData.comments);
formData.documents.forEach((file) => fd.append('files', file));
requestBody = fd;
} else {
requestBody = {
dealerId,
category: formData.terminationCategory,
reason: formData.reason,
proposedLwd: formData.proposedLwd,
comments: formData.comments
};
if (!payload.dealerId) {
toast.error('Dealer record not found for the selected dealer');
return;
}
const response = await API.createTermination(payload);
const response = await API.createTermination(requestBody);
const data = response.data as any;
if (data?.success) {
// Use termination.id which is the UUID
const newId = data.termination?.id;
// Upload document if selected
if (newId && formData.document) {
const docFormData = new FormData();
docFormData.append('file', formData.document);
docFormData.append('documentType', 'Termination Recommendation');
docFormData.append('stage', 'Submitted');
try {
await API.uploadTerminationDocument(newId, docFormData);
toast.success('Termination request and supporting document submitted');
} catch (docErr) {
console.error('Error uploading supporting document:', docErr);
toast.warning('Termination created, but document upload failed. You can upload it from the details page.');
}
} else {
toast.success('Termination request submitted successfully');
}
toast.success(formData.documents.length > 0
? 'Termination request and documents submitted'
: 'Termination request submitted successfully');
setIsDialogOpen(false);
fetchTerminations();
@ -245,7 +313,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
reason: '',
proposedLwd: '',
comments: '',
document: null
documents: []
});
}
} catch (error: any) {
@ -254,7 +322,8 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
}
};
const isDDLead = currentUser?.role === 'DD Lead';
const allowedRoles = ['DD Lead', 'ASM', 'DD Admin', 'DD AM', 'Super Admin'];
const canCreateTermination = currentUser?.role && allowedRoles.includes(currentUser.role);
// Map terminations to tab-specific views (already filtered by backend, but need variables for render)
const openRequests = activeTab === 'open' || activeTab === 'all' ? terminations : [];
@ -263,13 +332,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
return (
<div className="space-y-6">
{/* Warning Alert */}
<Alert className="border-red-200 bg-red-50">
<AlertTriangle className="h-4 w-4 text-red-600" />
<AlertTitle className="text-red-900">Restricted Access</AlertTitle>
<AlertDescription className="text-red-700">
This section contains sensitive information. All termination actions are logged and require proper authorization.
</AlertDescription>
</Alert>
{/* Header Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@ -312,14 +375,9 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<CardTitle>Termination Requests</CardTitle>
<CardDescription>
Manage dealer termination proceedings and legal compliance
{!isDDLead && (
<span className="block mt-1 text-red-600">
Note: Only DD Lead can create termination requests. Current role: {currentUser?.role || 'Not logged in'}
</span>
)}
</CardDescription>
</div>
{isDDLead && (
{canCreateTermination && (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button className="bg-red-600 hover:bg-red-700">
@ -408,14 +466,14 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<SelectContent>
<SelectItem value="Working Capital">Working Capital</SelectItem>
<SelectItem value="Performance Issues">Performance Issues</SelectItem>
<SelectItem value="Unethical Practical">Unethical Practical</SelectItem>
<SelectItem value="Unethical Practice">Unethical Practice</SelectItem>
<SelectItem value="Unforeseen Circumstances">Unforeseen Circumstances</SelectItem>
<SelectItem value="Others">Others</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Proposed LWD *</Label>
<Label>{PROPOSED_LAST_WORKING_DAY_LABEL} *</Label>
<Input
type="date"
value={formData.proposedLwd}
@ -449,12 +507,45 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div className="space-y-2">
<Label htmlFor="document">Upload Supporting Document</Label>
<Label htmlFor="documents">
{isPresentationMandatory ? 'Upload Documents *' : 'Upload Supporting Documents'}
</Label>
<Input
id="document"
id="documents"
type="file"
onChange={(e) => setFormData({...formData, document: e.target.files?.[0] || null})}
multiple
accept=".ppt,.pptx,.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png"
onChange={(e) => handleFilesPicked(e.target.files, e.currentTarget)}
required={isPresentationMandatory && formData.documents.length === 0}
/>
{isPresentationMandatory && (
<p className="text-xs text-slate-500">
At least one PowerPoint (.ppt / .pptx) is mandatory. You can also attach MOM, dealer commitments, and other supporting files (PDF / DOC / XLS / image).
</p>
)}
{formData.documents.length > 0 && (
<div className="border rounded-md divide-y bg-slate-50">
{formData.documents.map((file, idx) => (
<div key={`${file.name}-${idx}`} className="flex items-center justify-between px-3 py-2 text-sm">
<div className="flex items-center gap-2 min-w-0">
<span className="truncate">{file.name}</span>
{isPptFile(file) && (
<Badge className="bg-blue-100 text-blue-700 border-blue-300">Presentation</Badge>
)}
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDocumentAt(idx)}
className="text-red-600 hover:text-red-700"
>
Remove
</Button>
</div>
))}
</div>
)}
</div>
<DialogFooter>
@ -495,13 +586,14 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getSeverityColor(request.severity || 'Medium')}>
{request.severity || 'Normal'}
</Badge>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
<SlaBadge status={slaById[request.id]} compact />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
@ -518,10 +610,10 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div>
<p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p>
<p>{formatStatus(request.currentStage)}</p>
</div>
<div>
<p className="text-slate-600">Proposed LWD</p>
<p className="text-slate-600">{PROPOSED_LAST_WORKING_DAY_LABEL}</p>
<div className="flex items-center gap-1">
<Calendar className="w-4 h-4 text-slate-500" />
<p>{request.proposedLwd}</p>
@ -570,10 +662,11 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
<SlaBadge status={slaById[request.id]} compact />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
@ -586,7 +679,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
</div>
<div>
<p className="text-slate-600">Current Stage</p>
<p>{request.currentStage}</p>
<p>{formatStatus(request.currentStage)}</p>
</div>
<div>
<p className="text-slate-600">Submitted On</p>
@ -632,10 +725,11 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-bold">{request.requestId || (request.dealer?.dealerCode?.code || 'N/A')}</h3>
<span className="text-slate-400 text-xs">#{request.id.substring(0, 8)}</span>
<Badge className={getStatusColor(request.status)}>
{request.status}
{formatStatus(request.status)}
</Badge>
<SlaBadge status={slaById[request.id]} compact />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
@ -651,7 +745,7 @@ export function TerminationPage({ currentUser, onViewDetails }: TerminationPageP
<p>{request.category}</p>
</div>
<div>
<p className="text-slate-600">LWD</p>
<p className="text-slate-600">{LAST_WORKING_DAY_LABEL}</p>
<p>{request.proposedLwd}</p>
</div>
</div>

View File

@ -0,0 +1,27 @@
import { useEffect, useState } from 'react';
import { slaService, SlaStatusSnapshot } from '@/services/sla.service';
export function useSlaBatchStatus(
items: Array<{ entityType: string; entityId: string }>,
enabled = true
) {
const [byKey, setByKey] = useState<Record<string, SlaStatusSnapshot | null>>({});
useEffect(() => {
if (!enabled || items.length === 0) {
setByKey({});
return;
}
slaService
.getBatchStatus(items)
.then((res) => {
if (res?.success) setByKey(res.data);
})
.catch(() => setByKey({}));
}, [enabled, JSON.stringify(items.map((i) => `${i.entityType}:${i.entityId}`).sort())]);
const get = (entityType: string, entityId: string) =>
byKey[`${entityType}:${entityId}`] ?? null;
return { byKey, get };
}

Some files were not shown because too many files have changed in this diff Show More