folder structure refactored unit test cases added revoke sreject send back tested on costitutional and termination modules all modules almost completed major points covered in this commit

This commit is contained in:
laxmanhalaki 2026-04-19 23:23:00 +05:30
parent b56a78d621
commit 0e8d6bf49f
140 changed files with 13553 additions and 1808 deletions

22
babel.config.cjs Normal file
View File

@ -0,0 +1,22 @@
module.exports = {
presets: [
["@babel/preset-env", { targets: { node: "current" } }],
["@babel/preset-react", { runtime: "automatic" }],
"@babel/preset-typescript",
],
plugins: [
function () {
return {
visitor: {
MetaProperty(path) {
if (path.node.meta.name === "import" && path.node.property.name === "meta") {
path.replaceWithSourceString(
"(typeof global.importMeta !== 'undefined' ? global.importMeta : { env: {} })"
);
}
},
},
};
},
],
}

29
jest.config.cjs Normal file
View File

@ -0,0 +1,29 @@
/** @type {import("jest").Config} */
module.exports = {
testEnvironment: "jsdom",
roots: ["<rootDir>/src"],
testPathIgnorePatterns: [
"<rootDir>/node_modules/",
"<rootDir>/src/features/onboarding/__tests__/e2e/",
"<rootDir>/src/__tests__/e2e/",
],
testMatch: [
"**/__tests__/**/*.(test|spec).[jt]s?(x)",
"**/*.(test|spec).[jt]s?(x)",
],
transform: {
"^.+\\.(ts|tsx)$": "babel-jest",
},
moduleNameMapper: {
"^@/(.*)$": "<rootDir>/src/$1",
"\\.(css|less|scss|sass)$": "identity-obj-proxy",
},
setupFilesAfterEnv: ["<rootDir>/src/setupTests.ts"],
collectCoverageFrom: [
"src/**/*.{ts,tsx}",
"!src/**/*.d.ts",
"!src/main.tsx",
"!src/setupTests.ts",
"!src/**/__tests__/**",
],
}

6635
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,13 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-accordion": "^1.1.2", "@radix-ui/react-accordion": "^1.1.2",
@ -48,10 +54,12 @@
"input-otp": "^1.2.0", "input-otp": "^1.2.0",
"lucide-react": "^0.344.0", "lucide-react": "^0.344.0",
"next-themes": "^0.2.1", "next-themes": "^0.2.1",
"quill": "^2.0.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-day-picker": "^8.10.0", "react-day-picker": "^8.10.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.51.0", "react-hook-form": "^7.51.0",
"react-quill": "^2.0.0",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable-panels": "^2.0.12", "react-resizable-panels": "^2.0.12",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
@ -64,16 +72,29 @@
"zod": "^3.22.4" "zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.29.0",
"@babel/preset-env": "^7.29.2",
"@babel/preset-react": "^7.28.5",
"@babel/preset-typescript": "^7.28.5",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^30.0.0",
"@types/node": "^20.11.24", "@types/node": "^20.11.24",
"@types/react": "^18.2.61", "@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"babel-jest": "^30.3.0",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26", "eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0", "globals": "^17.0.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.3.0",
"jest-environment-jsdom": "^30.3.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"typescript": "^5.2.2", "typescript": "^5.2.2",

34
playwright.config.ts Normal file
View File

@ -0,0 +1,34 @@
import { defineConfig, devices } from "@playwright/test";
/**
* Playwright discovers specs in any `__tests__/e2e/` folder under `src/` (onboarding, lifecycle, etc.).
*
* Starts Vite automatically unless CI sets PLAYWRIGHT_SKIP_WEB_SERVER=1
* (useful when the dev server is already running).
*/
export default defineConfig({
testDir: "./src",
testMatch: "**/__tests__/e2e/**/*.e2e.spec.ts",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "list",
timeout: 60_000,
expect: { timeout: 15_000 },
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? "http://127.0.0.1:5173",
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: process.env.PLAYWRIGHT_SKIP_WEB_SERVER
? undefined
: {
command: "npm run dev -- --host 127.0.0.1 --port 5173",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@ -1,56 +1,56 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { RootState } from './store'; import { RootState } from '@/store';
import { setCredentials, logout as logoutAction, initializeAuth } from './store/slices/authSlice'; import { setCredentials, logout as logoutAction, initializeAuth } from '@/store/slices/authSlice';
import { RoleGuard } from './components/auth/RoleGuard'; import { RoleGuard } from '@/features/auth/components/RoleGuard';
import { Routes, Route, Navigate, useLocation, useNavigate, Outlet } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate, Outlet } from 'react-router-dom';
import { ApplicationFormPage } from './components/public/ApplicationFormPage'; import { ApplicationFormPage } from '@/components/public/ApplicationFormPage';
import PublicQuestionnairePage from './pages/public/PublicQuestionnairePage'; import PublicQuestionnairePage from '@/pages/public/PublicQuestionnairePage';
import { LoginPage } from './components/auth/LoginPage'; import { LoginPage } from '@/features/auth/pages/LoginPage';
import { ProspectiveLoginPage } from './components/auth/ProspectiveLoginPage'; import { ProspectiveLoginPage } from '@/features/auth/pages/ProspectiveLoginPage';
import { Sidebar } from './components/layout/Sidebar'; import { Sidebar } from '@/components/layout/Sidebar';
import { Header } from './components/layout/Header'; import { Header } from '@/components/layout/Header';
import { Dashboard } from './components/dashboard/Dashboard'; import { Dashboard } from '@/features/dashboard/pages/Dashboard';
import { FinanceDashboard } from './components/dashboard/FinanceDashboard'; import { FinanceDashboard } from '@/features/dashboard/pages/FinanceDashboard';
import { DealerDashboard } from './components/dashboard/DealerDashboard'; import { DealerDashboard } from '@/features/dashboard/pages/DealerDashboard';
import { ProspectiveDashboardPage } from './components/dashboard/ProspectiveDashboardPage'; import { ProspectiveDashboardPage } from '@/features/dashboard/pages/ProspectiveDashboardPage';
import { FDDDashboardPage } from './components/dashboard/FDDDashboardPage'; import { FDDDashboardPage } from '@/features/dashboard/pages/FDDDashboardPage';
import { FDDApplicationDetails } from './components/applications/FDDApplicationDetails'; import { FDDApplicationDetails } from '@/features/onboarding/pages/FDDApplicationDetails';
import { ApplicationsPage } from './components/applications/ApplicationsPage'; import { ApplicationsPage } from '@/features/onboarding/pages/ApplicationsPage';
import { AllApplicationsPage } from './components/applications/AllApplicationsPage'; import { AllApplicationsPage } from '@/features/onboarding/pages/AllApplicationsPage';
import { OpportunityRequestsPage } from './components/applications/OpportunityRequestsPage'; import { OpportunityRequestsPage } from '@/features/onboarding/pages/OpportunityRequestsPage';
import { NonOpportunitiesPage } from './components/applications/NonOpportunitiesPage'; import { NonOpportunitiesPage } from '@/features/onboarding/pages/NonOpportunitiesPage';
import { ApplicationDetails } from './components/applications/ApplicationDetails'; import { ApplicationDetails } from '@/features/onboarding/pages/ApplicationDetails';
import { ResignationPage } from './components/applications/ResignationPage'; import { ResignationPage } from '@/features/resignation/pages/ResignationPage';
import { TerminationPage } from './components/applications/TerminationPage'; import { TerminationPage } from '@/features/termination/pages/TerminationPage';
import { FnFPage } from './components/applications/FnFPage'; import { FnFPage } from '@/features/fnf/pages/FnFPage';
import { ResignationDetails } from './components/applications/ResignationDetails'; import { ResignationDetails } from '@/features/resignation/pages/ResignationDetails';
import { TerminationDetails } from './components/applications/TerminationDetails'; import { TerminationDetails } from '@/features/termination/pages/TerminationDetails';
import { FnFDetails } from './components/applications/FnFDetails'; import { FnFDetails } from '@/features/fnf/pages/FnFDetails';
import { FinanceOnboardingPage } from './components/applications/FinanceOnboardingPage'; import { FinanceOnboardingPage } from '@/features/onboarding/pages/FinanceOnboardingPage';
import { FinanceFnFPage } from './components/applications/FinanceFnFPage'; import { FinanceFnFPage } from '@/features/fnf/pages/FinanceFnFPage';
import { FinancePaymentDetailsPage } from './components/applications/FinancePaymentDetailsPage'; import { FinancePaymentDetailsPage } from '@/features/fnf/pages/FinancePaymentDetailsPage';
import { FinanceFnFDetailsPage } from './components/applications/FinanceFnFDetailsPage'; import { FinanceFnFDetailsPage } from '@/features/fnf/pages/FinanceFnFDetailsPage';
import { MasterPage } from './components/applications/MasterPage'; import { MasterPage } from '@/features/master/pages/MasterPage';
import { UserManagementPage } from './components/admin/UserManagementPage'; import { UserManagementPage } from '@/components/admin/UserManagementPage';
import { ApprovalPoliciesPage } from './components/admin/ApprovalPoliciesPage'; import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage';
import { ConstitutionalChangePage } from './components/applications/ConstitutionalChangePage'; import { ConstitutionalChangePage } from '@/features/constitutional/pages/ConstitutionalChangePage';
import { ConstitutionalChangeDetails } from './components/applications/ConstitutionalChangeDetails'; import { ConstitutionalChangeDetails } from '@/features/constitutional/pages/ConstitutionalChangeDetails';
import { RelocationRequestPage } from './components/applications/RelocationRequestPage'; import { RelocationRequestPage } from '@/features/relocation/pages/RelocationRequestPage';
import { RelocationRequestDetails } from './components/applications/RelocationRequestDetails'; import { RelocationRequestDetails } from '@/features/relocation/pages/RelocationRequestDetails';
import { DealerResignationPage } from './components/dealer/DealerResignationPage'; import { DealerResignationPage } from '@/features/resignation/pages/DealerResignationPage';
import { DealerResignationDetailsPage } from './components/dealer/DealerResignationDetailsPage'; import { DealerResignationDetailsPage } from '@/features/resignation/pages/DealerResignationDetailsPage';
import { DealerConstitutionalChangePage } from './components/dealer/DealerConstitutionalChangePage'; import { DealerConstitutionalChangePage } from '@/features/constitutional/pages/DealerConstitutionalChangePage';
import { DealerRelocationPage } from './components/dealer/DealerRelocationPage'; import { DealerRelocationPage } from '@/features/relocation/pages/DealerRelocationPage';
import QuestionnaireBuilder from './components/admin/QuestionnaireBuilder'; import QuestionnaireBuilder from '@/components/admin/QuestionnaireBuilder';
import QuestionnaireList from './components/admin/QuestionnaireList'; import QuestionnaireList from '@/components/admin/QuestionnaireList';
import { WorkNotesPage } from './components/applications/WorkNotesPage'; import { WorkNotesPage } from '@/features/onboarding/pages/WorkNotesPage';
import { Toaster } from './components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { User } from './lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from './api/API'; import { API } from '@/api/API';
import { SocketProvider } from './context/SocketContext'; import { SocketProvider } from '@/context/SocketContext';
// Layout Component // Layout Component
const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => { const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => {
@ -214,14 +214,14 @@ export default function App() {
{/* Dashboards */} {/* Dashboards */}
<Route path="/dashboard" element={ <Route path="/dashboard" element={
hasRole(financeRoles) ? hasRole(financeRoles) ?
<FinanceDashboard currentUser={currentUser} onNavigate={(path) => navigate(`/${path}`)} onViewPaymentDetails={(id) => navigate(`/finance-onboarding/${id}`)} onViewAuditDetails={(id) => navigate(`/finance-audit/${id}`)} onViewFnFDetails={(id) => navigate(`/finance-fnf/${id}`)} /> : <FinanceDashboard currentUser={currentUser} onNavigate={(path: string) => navigate(`/${path}`)} onViewPaymentDetails={(id: string) => navigate(`/finance-onboarding/${id}`)} onViewAuditDetails={(id: string) => navigate(`/finance-audit/${id}`)} onViewFnFDetails={(id: string) => navigate(`/finance-fnf/${id}`)} /> :
hasRole(['Dealer']) ? hasRole(['Dealer']) ?
<DealerDashboard currentUser={currentUser} onNavigate={(path) => navigate(`/${path}`)} /> : <DealerDashboard currentUser={currentUser} onNavigate={(path: string) => navigate(`/${path}`)} /> :
<Dashboard onNavigate={(path) => navigate(`/${path}`)} /> <Dashboard onNavigate={(path: string) => navigate(`/${path}`)} />
} /> } />
{/* Applications */} {/* Applications */}
<Route path="/applications" element={<ApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" />} /> <Route path="/applications" element={<ApplicationsPage onViewDetails={(id: string) => navigate(`/applications/${id}`)} initialFilter="all" />} />
<Route path="/applications/:id" element={<ApplicationDetails />} /> <Route path="/applications/:id" element={<ApplicationDetails />} />
{/* Centralized Work Notes Route */} {/* Centralized Work Notes Route */}
@ -231,8 +231,9 @@ export default function App() {
/> />
} /> } />
{/* All Applications */}
<Route path="/all-applications" element={ <Route path="/all-applications" element={
hasRole(['DD']) ? <AllApplicationsPage onViewDetails={(id) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" /> hasRole(['DD']) ? <AllApplicationsPage onViewDetails={(id: string) => navigate(`/applications/${id}`)} initialFilter="all" /> : <Navigate to="/dashboard" />
} /> } />
{/* FDD Routes - Integrated into Layout */} {/* FDD Routes - Integrated into Layout */}
@ -240,8 +241,8 @@ export default function App() {
<Route path="/fdd-details/:id" element={<FDDApplicationDetails />} /> <Route path="/fdd-details/:id" element={<FDDApplicationDetails />} />
{/* Admin/Lead Routes */} {/* Admin/Lead Routes */}
<Route path="/opportunity-requests" element={<OpportunityRequestsPage onViewDetails={(id) => navigate(`/applications/${id}`)} />} /> <Route path="/opportunity-requests" element={<OpportunityRequestsPage onViewDetails={(id: string) => navigate(`/applications/${id}`)} />} />
<Route path="/non-opportunities" element={<NonOpportunitiesPage onViewDetails={(id) => navigate(`/applications/${id}`)} />} /> <Route path="/non-opportunities" element={<NonOpportunitiesPage onViewDetails={(id: string) => navigate(`/applications/${id}`)} />} />
{/* Other Modules */} {/* Other Modules */}
<Route path="/users" element={<UserManagementPage />} /> <Route path="/users" element={<UserManagementPage />} />
@ -259,7 +260,7 @@ export default function App() {
{/* HR/Finance Modules (Simplified for brevity, following pattern) */} {/* HR/Finance Modules (Simplified for brevity, following pattern) */}
<Route path="/resignation" element={ <Route path="/resignation" element={
hasRole(resignationRoles) hasRole(resignationRoles)
? <ResignationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/resignation/${id}`)} /> ? <ResignationPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/resignation/${id}`)} />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/resignation/:id" element={ <Route path="/resignation/:id" element={
@ -270,7 +271,7 @@ export default function App() {
<Route path="/termination" element={ <Route path="/termination" element={
hasRole(terminationRoles) hasRole(terminationRoles)
? <TerminationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/termination/${id}`)} /> ? <TerminationPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/termination/${id}`)} />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/termination/:id" element={ <Route path="/termination/:id" element={
@ -281,7 +282,7 @@ export default function App() {
<Route path="/fnf" element={ <Route path="/fnf" element={
hasRole(fnfRoles) hasRole(fnfRoles)
? <FnFPage currentUser={currentUser} onViewDetails={(id) => navigate(`/fnf/${id}`)} /> ? <FnFPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/fnf/${id}`)} />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/fnf/:id" element={ <Route path="/fnf/:id" element={
@ -292,7 +293,7 @@ export default function App() {
<Route path="/finance-onboarding" element={ <Route path="/finance-onboarding" element={
hasRole(financeRoles) hasRole(financeRoles)
? <FinanceOnboardingPage onViewPaymentDetails={(id) => navigate(`/finance-onboarding/${id}`)} onViewAuditDetails={(id) => navigate(`/finance-audit/${id}`)} /> ? <FinanceOnboardingPage onViewPaymentDetails={(id: string) => navigate(`/finance-onboarding/${id}`)} onViewAuditDetails={(id: string) => navigate(`/finance-audit/${id}`)} />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/finance-onboarding/:id" element={ <Route path="/finance-onboarding/:id" element={
@ -304,7 +305,7 @@ export default function App() {
<Route path="/finance-fnf" element={ <Route path="/finance-fnf" element={
hasRole(financeRoles) hasRole(financeRoles)
? <FinanceFnFPage onViewFnFDetails={(id) => navigate(`/finance-fnf/${id}`)} /> ? <FinanceFnFPage onViewFnFDetails={(id: string) => navigate(`/finance-fnf/${id}`)} />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/finance-fnf/:id" element={ <Route path="/finance-fnf/:id" element={
@ -313,21 +314,21 @@ export default function App() {
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/constitutional-change" element={<ConstitutionalChangePage currentUser={currentUser} onViewDetails={(id) => navigate(`/constitutional-change/${id}`)} />} /> <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('/constitutional-change')} currentUser={currentUser} />} />
<Route path="/relocation-requests" element={<RelocationRequestPage currentUser={currentUser} onViewDetails={(id) => navigate(`/relocation-requests/${id}`)} />} /> <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('/relocation-requests')} currentUser={currentUser} />} />
{/* Dealer Routes */} {/* Dealer Routes */}
<Route path="/dealer-resignation" element={<DealerResignationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/dealer-resignation/${id}`)} />} /> <Route path="/dealer-resignation" element={<DealerResignationPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/dealer-resignation/${id}`)} />} />
<Route path="/dealer-resignation/:id" element={ <Route path="/dealer-resignation/:id" element={
hasRole(['Dealer']) hasRole(['Dealer'])
? <DealerResignationDetailsPage resignationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/dealer-resignation')} /> ? <DealerResignationDetailsPage resignationId={window.location.pathname.split('/').pop() || ''} onBack={() => navigate('/dealer-resignation')} />
: <Navigate to="/dashboard" /> : <Navigate to="/dashboard" />
} /> } />
<Route path="/dealer-constitutional" element={<DealerConstitutionalChangePage currentUser={currentUser} onViewDetails={(id) => navigate(`/constitutional-change/${id}`)} />} /> <Route path="/dealer-constitutional" element={<DealerConstitutionalChangePage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/constitutional-change/${id}`)} />} />
<Route path="/dealer-relocation" element={<DealerRelocationPage currentUser={currentUser} onViewDetails={(id) => navigate(`/relocation-requests/${id}`)} />} /> <Route path="/dealer-relocation" element={<DealerRelocationPage currentUser={currentUser} onViewDetails={(id: string) => navigate(`/relocation-requests/${id}`)} />} />
{/* Placeholder Routes */} {/* Placeholder Routes */}
<Route path="/tasks" element={ <Route path="/tasks" element={

View File

@ -0,0 +1,387 @@
import type { Page } from "@playwright/test";
/** Matches LoginPage DD Admin quick-login row */
export const E2E_DD_ADMIN = {
id: "18",
fullName: "Lince",
email: "lince@royalenfield.com",
password: "Admin@123",
role: "DD Admin",
} as const;
/** Shared list/detail payloads for Termination → Resignation → Relocation → Constitutional flows */
export const E2E_IDS = {
termination: "e2e-term-uuid-01",
resignation: "e2e-res-uuid-01",
relocation: "e2e-rel-uuid-01",
constitutionalPublicId: "CCR-E2E-001",
constitutionalInternalId: "e2e-cc-internal-01",
} as const;
const terminationListRow = {
id: E2E_IDS.termination,
requestId: "TERM-E2E-001",
severity: "High",
status: "Awaiting SCN Review",
currentStage: "Show Cause Notice",
category: "Performance Issues",
reason: "E2E termination scenario",
proposedLwd: "2026-12-31",
createdAt: new Date().toISOString(),
dealer: {
businessName: "E2E Termination Dealer",
legalName: "E2E Termination Dealer Pvt Ltd",
registeredAddress: "Chennai, TN",
dealerCode: { dealerCode: "E2E-T01", code: "E2E-T01" },
},
};
const terminationDetail = {
...terminationListRow,
documents: [],
uploadedDocuments: [],
timeline: [{ stage: "Show Cause Notice", action: "Moved to SCN", remarks: "E2E stub", timestamp: new Date().toISOString() }],
};
const resignationListRow = {
id: E2E_IDS.resignation,
resignationId: "RES-E2E-001",
status: "Pending DD Admin Clearance",
currentStage: "DD Admin",
resignationType: "Voluntary",
reason: "E2E resignation flow",
submittedOn: new Date().toISOString(),
dealer: {
dealerProfile: {
businessName: "E2E Resignation Dealer",
dealerCode: { dealerCode: "E2E-R01" },
registeredAddress: "Bangalore, KA",
},
},
outlet: { name: "Main Outlet", code: "OUT-E2E-1", city: "Bangalore", state: "KA" },
};
const resignationDetail = {
...resignationListRow,
dealer: {
fullName: "E2E Resignation Dealer",
email: "resign.e2e@example.com",
dealerProfile: resignationListRow.dealer.dealerProfile,
},
documents: [],
uploadedDocuments: [],
timeline: [],
};
const relocationListRow = {
id: E2E_IDS.relocation,
requestId: "REL-E2E-001",
status: "Pending ASM Review",
currentStage: "ASM Review",
currentLocation: "Old Industrial Area",
proposedLocation: "New Highway Plot",
distance: "25 km",
progressPercentage: 25,
createdAt: new Date().toISOString(),
outlet: { code: "OUT-REL-E2E", name: "E2E Relocation Outlet" },
dealerName: "E2E Relocation Dealer",
dealer: { fullName: "E2E Relocation Dealer" },
};
const relocationDetail = {
...relocationListRow,
timeline: [],
documents: [],
dealer: { fullName: "E2E Relocation Dealer" },
};
const constitutionalListRow = {
id: E2E_IDS.constitutionalInternalId,
requestId: E2E_IDS.constitutionalPublicId,
status: "Pending ASM Review",
currentStage: "ASM Review",
changeType: "Partnership",
description: "E2E constitutional change",
createdAt: new Date().toISOString(),
outlet: { name: "CCR Outlet", code: "CCR-OUT", city: "Coimbatore", state: "TN", type: "Proprietorship", address: "Road 1" },
dealer: {
fullName: "E2E Constitutional Dealer",
dealerProfile: {
businessName: "E2E Constitutional Dealer",
dealerCode: { dealerCode: "E2E-C01", salesCode: "S", serviceCode: "V", gmaCode: "G", gearCode: "GR" },
registeredAddress: "Industrial Estate",
application: { loiRequests: [], loaRequests: [] },
},
},
progressPercentage: 10,
};
const constitutionalDetail = {
...constitutionalListRow,
timeline: [{ action: "SUBMITTED", stage: "Submitted", remarks: "E2E stub", timestamp: new Date().toISOString(), user: "Dealer" }],
documents: [],
};
/**
* Stubs REST APIs so lifecycle E2E runs headless against Vite without a backend.
*/
export async function installLifecycleModuleApiStubs(page: Page): Promise<void> {
await page.route("**/api/auth/login", async (route) => {
if (route.request().method() !== "POST") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
token: "e2e-playwright-token",
user: {
id: E2E_DD_ADMIN.id,
fullName: E2E_DD_ADMIN.fullName,
email: E2E_DD_ADMIN.email,
role: E2E_DD_ADMIN.role,
},
}),
});
});
await page.route("**/api/auth/me", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user: {
id: E2E_DD_ADMIN.id,
fullName: E2E_DD_ADMIN.fullName,
email: E2E_DD_ADMIN.email,
role: E2E_DD_ADMIN.role,
roleCode: E2E_DD_ADMIN.role,
},
}),
});
});
await page.route("**/api/communication/notifications**", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, data: [] }),
});
});
await page.route("**/api/termination", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
terminations: [terminationListRow],
}),
});
});
await page.route(`**/api/termination/${E2E_IDS.termination}**`, async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
termination: terminationDetail,
}),
});
});
await page.route("**/api/resignation", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
resignations: { rows: [resignationListRow] },
}),
});
});
await page.route(`**/api/resignation/${E2E_IDS.resignation}**`, async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
resignation: resignationDetail,
}),
});
});
await page.route("**/api/relocation", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
requests: [relocationListRow],
}),
});
});
await page.route(`**/api/relocation/${E2E_IDS.relocation}**`, async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
request: relocationDetail,
}),
});
});
await page.route("**/api/constitutional-change**", async (route) => {
const method = route.request().method();
if (method !== "GET") {
await route.continue();
return;
}
const path = new URL(route.request().url()).pathname.replace(/\/$/, "");
const pub = `/api/constitutional-change/${E2E_IDS.constitutionalPublicId}`;
if (path.endsWith("/constitutional-change/meta")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
structureTargets: [
{ value: "Partnership", label: "Partnership" },
{ value: "LLP", label: "LLP" },
],
}),
});
return;
}
if (path === pub) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
request: constitutionalDetail,
}),
});
return;
}
if (path === "/api/constitutional-change") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
requests: [constitutionalListRow],
}),
});
return;
}
await route.continue();
});
await page.route("**/api/dealer*", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
const path = new URL(route.request().url()).pathname;
if (!path.startsWith("/api/dealer") || path.includes("/bank-details")) {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: [
{
id: "dealer-e2e-1",
legalName: "Stub Dealer Legal",
businessName: "Stub Dealer",
gstNumber: "27AAAAA0000A1Z5",
registeredAddress: "Test",
status: "active",
constitutionType: "Proprietorship",
dealerCode: { dealerCode: "STUB-01" },
user: {
id: "user-dealer-e2e",
email: "dealer@e2e.test",
mobileNumber: "9000000000",
status: "active",
isActive: true,
},
application: { preferredLocation: "Test", city: "Chennai", state: "TN" },
},
],
}),
});
});
await page.route("**/api/master/outlets**", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
outlets: [{ id: "outlet-e2e-1", code: "OUT-1", name: "Outlet One", dealerId: "user-dealer-e2e" }],
}),
});
});
await page.route("**/api/audit/logs**", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ success: true, data: [] }),
});
});
}

View File

@ -0,0 +1,9 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { E2E_DD_ADMIN } from "./lifecycle-api-stubs";
export async function loginAsDdAdmin(page: Page): Promise<void> {
await page.goto("/admin-login");
await page.locator("div.cursor-pointer").filter({ hasText: E2E_DD_ADMIN.email }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}

View File

@ -0,0 +1,53 @@
import { test, expect } from "@playwright/test";
import { installLifecycleModuleApiStubs, E2E_IDS } from "./fixtures/lifecycle-api-stubs";
import { loginAsDdAdmin } from "./fixtures/login";
test.describe("Lifecycle modules (stubbed API)", () => {
test.beforeEach(async ({ page }) => {
await installLifecycleModuleApiStubs(page);
});
test("Termination — list and detail", async ({ page }) => {
await loginAsDdAdmin(page);
await page.goto("/termination");
await expect(page.getByRole("heading", { name: "Termination Requests" })).toBeVisible();
await expect(page.getByText("E2E Termination Dealer").first()).toBeVisible();
await page.getByRole("button", { name: "View Details" }).first().click();
await expect(page).toHaveURL(new RegExp(`/termination/${E2E_IDS.termination}$`));
await expect(page.getByText("E2E Termination Dealer").first()).toBeVisible();
});
test("Resignation — list and detail", async ({ page }) => {
await loginAsDdAdmin(page);
await page.goto("/resignation");
await expect(page.getByRole("heading", { name: "Resignation Requests" })).toBeVisible();
await expect(page.getByText("RES-E2E-001").first()).toBeVisible();
await page.getByRole("button", { name: "View Details" }).first().click();
await expect(page).toHaveURL(new RegExp(`/resignation/${E2E_IDS.resignation}$`));
await expect(page.getByText("E2E Resignation Dealer")).toBeVisible();
});
test("Relocation — list and detail", async ({ page }) => {
await loginAsDdAdmin(page);
await page.goto("/relocation-requests");
await expect(page.getByRole("heading", { name: "Relocation Request Management" })).toBeVisible();
await expect(page.getByText("REL-E2E-001")).toBeVisible();
await page.getByRole("button", { name: "View", exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`/relocation-requests/${E2E_IDS.relocation}$`));
await expect(page.getByText("E2E Relocation Dealer").first()).toBeVisible();
});
test("Constitutional change — list and detail", async ({ page }) => {
await loginAsDdAdmin(page);
await page.goto("/constitutional-change");
await expect(page.getByRole("heading", { name: "Constitutional Change Management" })).toBeVisible();
await expect(page.getByText("CCR-E2E-001")).toBeVisible();
await page.getByRole("button", { name: "View", exact: true }).first().click();
await expect(page).toHaveURL(new RegExp(`/constitutional-change/${E2E_IDS.constitutionalPublicId}$`));
await expect(page.getByText("E2E Constitutional Dealer", { exact: true }).first()).toBeVisible();
});
});

View File

@ -1,5 +1,6 @@
import client from './client'; import client from './client';
import axios from 'axios'; import axios from 'axios';
import type { ConstitutionalChangeAction } from '@/lib/offboarding-actions';
export const API = { export const API = {
// Auth routes // Auth routes
@ -188,7 +189,13 @@ export const API = {
getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'), getConstitutionalChangeMeta: () => client.get('/constitutional-change/meta'),
getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`), getConstitutionalChangeById: (id: string) => client.get(`/constitutional-change/${id}`),
createConstitutionalChange: (data: any) => client.post('/constitutional-change', data), createConstitutionalChange: (data: any) => client.post('/constitutional-change', data),
updateConstitutionalChange: (id: string, action: string, data?: any) => client.post(`/constitutional-change/${id}/action`, { action, ...data }), /**
* POST `/constitutional-change/:id/action`
* Body: `{ action, comments? }` `action` must be `approve` | `sendBack` | `reject` | `revoke` (see `OFFBOARDING_ACTIONS` in `@/lib/offboarding-actions`).
* Backend also accepts `remarks` as an alias for `comments`.
*/
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 }), uploadConstitutionalDocuments: (id: string, documents: any[]) => client.post(`/constitutional-change/${id}/documents`, { documents }),
// SLA // SLA

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -0,0 +1,172 @@
import { render, screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { LoginPage } from "../pages/LoginPage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
describe("LoginPage", () => {
const adminEmail = "admin@royalenfield.com"
const adminPassword = "Admin@123"
beforeEach(() => {
jest.clearAllMocks()
window.alert = jest.fn()
})
function setup(onLogin = jest.fn().mockResolvedValue(undefined)) {
render(<LoginPage onLogin={onLogin} />)
return { onLogin }
}
it("renders branding and primary login controls", () => {
setup()
expect(
screen.getByRole("heading", { name: /royal enfield/i }),
).toBeInTheDocument()
expect(screen.getByLabelText(/^email address$/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument()
expect(screen.getByRole("button", { name: /^login$/i })).toBeEnabled()
expect(
screen.getByRole("button", { name: /prospective user login/i }),
).toBeInTheDocument()
expect(screen.getByText(/test user credentials/i)).toBeInTheDocument()
})
it("shows validation error when email or password is missing", async () => {
const user = userEvent.setup()
setup()
await user.click(screen.getByRole("button", { name: /^login$/i }))
expect(
await screen.findByText(/please enter both email and password/i),
).toBeInTheDocument()
})
it("calls onLogin with entered credentials on success", async () => {
const user = userEvent.setup()
const onLogin = jest.fn().mockResolvedValue(undefined)
setup(onLogin)
await user.type(
screen.getByLabelText(/^email address$/i),
adminEmail,
)
await user.type(
screen.getByLabelText(/^password$/i),
adminPassword,
)
await user.click(screen.getByRole("button", { name: /^login$/i }))
await waitFor(() => {
expect(onLogin).toHaveBeenCalledWith(adminEmail, adminPassword)
})
})
it("shows server error message when onLogin rejects", async () => {
const user = userEvent.setup()
const onLogin = jest
.fn()
.mockRejectedValue({ message: "Invalid credentials" })
setup(onLogin)
await user.type(
screen.getByLabelText(/^email address$/i),
adminEmail,
)
await user.type(screen.getByLabelText(/^password$/i), "wrong-pass")
await user.click(screen.getByRole("button", { name: /^login$/i }))
expect(
await screen.findByText(/invalid credentials/i),
).toBeInTheDocument()
})
it("toggles forgot-password view and returns with Back to Login", async () => {
const user = userEvent.setup()
setup()
await user.click(
screen.getByRole("button", { name: /forgot password/i }),
)
expect(
screen.getByRole("heading", { name: /reset password/i }),
).toBeInTheDocument()
expect(
screen.queryByRole("button", { name: /^login$/i }),
).not.toBeInTheDocument()
await user.click(
screen.getByRole("button", { name: /back to login/i }),
)
expect(
screen.getByRole("button", { name: /^login$/i }),
).toBeInTheDocument()
expect(
screen.queryByRole("heading", { name: /reset password/i }),
).not.toBeInTheDocument()
})
it("quick-login card triggers onLogin with mock user credentials", async () => {
const user = userEvent.setup()
const onLogin = jest.fn().mockResolvedValue(undefined)
setup(onLogin)
await user.click(
screen.getByText(/click to login as super admin/i),
)
await waitFor(() => {
expect(onLogin).toHaveBeenCalledWith(adminEmail, adminPassword)
})
})
it("shows loading state on submit while onLogin is pending", async () => {
const user = userEvent.setup()
let resolveLogin!: () => void
const pending = new Promise<void>((res) => {
resolveLogin = res
})
const onLogin = jest.fn().mockReturnValue(pending)
setup(onLogin)
await user.type(
screen.getByLabelText(/^email address$/i),
adminEmail,
)
await user.type(
screen.getByLabelText(/^password$/i),
adminPassword,
)
await user.click(screen.getByRole("button", { name: /^login$/i }))
expect(screen.getByText(/logging in/i)).toBeInTheDocument()
resolveLogin()
await waitFor(() => {
expect(screen.queryByText(/logging in/i)).not.toBeInTheDocument()
})
})
it("toggles password visibility", async () => {
const user = userEvent.setup()
setup()
const passwordInput = screen.getByLabelText(
/^password$/i,
) as HTMLInputElement
expect(passwordInput.type).toBe("password")
const pwdContainer = passwordInput.closest(".relative")
const toggleBtn = pwdContainer
? within(pwdContainer as HTMLElement).getAllByRole("button")[0]
: null
expect(toggleBtn).toBeTruthy()
await user.click(toggleBtn as HTMLElement)
expect((screen.getByLabelText(/^password$/i) as HTMLInputElement).type).toBe(
"text",
)
})
})

View File

@ -1,6 +1,6 @@
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '../../store'; import { RootState } from '@/store';
interface RoleGuardProps { interface RoleGuardProps {
children: React.ReactNode; children: React.ReactNode;

View File

@ -1,10 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { AlertCircle, Copy, Check, Eye, EyeOff } from 'lucide-react'; import { AlertCircle, Copy, Check, Eye, EyeOff } from 'lucide-react';
import { mockUsers } from '../../lib/mock-data'; import { mockUsers } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface LoginPageProps { interface LoginPageProps {
@ -287,7 +287,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
<div className="space-y-3"> <div className="space-y-3">
{mockUsers.map((user, index) => ( {mockUsers.map((user, index) => (
<div <div
key={user.id} key={user.email}
className="border border-slate-200 rounded-lg p-4 hover:border-amber-600 hover:bg-amber-50 transition-all cursor-pointer" className="border border-slate-200 rounded-lg p-4 hover:border-amber-600 hover:bg-amber-50 transition-all cursor-pointer"
onClick={() => quickLogin(user.email, user.password)} onClick={() => quickLogin(user.email, user.password)}
> >

View File

@ -1,13 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { AlertCircle, ArrowLeft, Smartphone } from 'lucide-react'; import { AlertCircle, ArrowLeft, Smartphone } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { setCredentials } from '../../store/slices/authSlice'; import { setCredentials } from '@/store/slices/authSlice';
export function ProspectiveLoginPage() { export function ProspectiveLoginPage() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -0,0 +1,156 @@
import { render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import type { User } from "@/lib/mock-data"
import { MEMORY_ROUTER_TEST_FUTURE } from "@/testing/reactRouterTest"
import { ConstitutionalChangeDetails } from "../pages/ConstitutionalChangeDetails"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
const mockGetConstitutionalChangeById = jest.fn()
const mockGetAuditLogs = jest.fn()
jest.mock("@/api/API", () => ({
API: {
getConstitutionalChangeById: (...args: unknown[]) =>
mockGetConstitutionalChangeById(...args),
getAuditLogs: (...args: unknown[]) => mockGetAuditLogs(...args),
updateConstitutionalChange: jest.fn(),
uploadConstitutionalDocuments: jest.fn(),
},
}))
function buildAsmUser(): User {
return {
id: "u1",
name: "ASM Person",
email: "asm@test.com",
password: "",
role: "ASM",
roleCode: "ASM",
}
}
function minimalRequest() {
return {
id: "req-internal-id",
requestId: "CCR-500",
status: "Submitted",
currentStage: "Submitted",
changeType: "Partnership",
description: "Expansion requires partnership.",
createdAt: "2025-03-15T09:30:00.000Z",
progressPercentage: 12,
dealer: {
fullName: "Jane Dealer",
dealerProfile: {
businessName: "Highway Garage",
dealerCode: {
dealerCode: "HG-01",
salesCode: "S1",
serviceCode: "V1",
gmaCode: "G1",
gearCode: "GR1",
},
registeredAddress: "Plot 9, Industrial Area",
application: { loiRequests: [], loaRequests: [] },
},
},
outlet: {
type: "Proprietorship",
name: "Main Outlet",
code: "OUT-1",
city: "Chennai",
state: "TN",
address: "Street 1",
},
documents: [],
timeline: [],
metadata: { jointApprovals: { zmRbm: {} } },
worknotes: [],
}
}
describe("ConstitutionalChangeDetails", () => {
const onBack = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
mockGetAuditLogs.mockResolvedValue({
data: { success: true, data: [] },
})
})
function setup(requestId = "CCR-500", user: User | null = buildAsmUser()) {
render(
<MemoryRouter future={MEMORY_ROUTER_TEST_FUTURE}>
<ConstitutionalChangeDetails
requestId={requestId}
onBack={onBack}
currentUser={user}
/>
</MemoryRouter>,
)
}
it("shows loading then not found when API returns no request", async () => {
mockGetConstitutionalChangeById.mockResolvedValue({
data: { success: false },
})
setup()
expect(screen.getByText(/loading request details/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.getByRole("heading", { name: /request not found/i })).toBeInTheDocument()
})
expect(
screen.getByText(/doesn't exist/i),
).toBeInTheDocument()
})
it("renders overview when request loads", async () => {
mockGetConstitutionalChangeById.mockResolvedValue({
data: { success: true, request: minimalRequest() },
})
setup()
await waitFor(() => {
expect(
screen.getByRole("heading", {
name: /CCR-500 - constitutional change details/i,
}),
).toBeInTheDocument()
})
expect(screen.getAllByText(/Highway Garage/i).length).toBeGreaterThanOrEqual(
1,
)
expect(screen.getByText(/request overview/i)).toBeInTheDocument()
expect(screen.getByText(/expansion requires partnership/i)).toBeInTheDocument()
expect(screen.getByText(/workflow progress/i)).toBeInTheDocument()
})
it("invokes onBack from Go Back when request missing", async () => {
mockGetConstitutionalChangeById.mockResolvedValue({
data: { success: false },
})
setup()
await waitFor(() => {
expect(screen.getByRole("button", { name: /^go back$/i })).toBeInTheDocument()
})
screen.getByRole("button", { name: /^go back$/i }).click()
expect(onBack).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,141 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ConstitutionalChangePage } from "../pages/ConstitutionalChangePage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
const mockGetConstitutionalChanges = jest.fn()
jest.mock("@/api/API", () => ({
API: {
getConstitutionalChanges: (...args: unknown[]) =>
mockGetConstitutionalChanges(...args),
getConstitutionalChangeMeta: jest.fn().mockResolvedValue({
data: {
success: true,
structureTargets: [
{ value: "Partnership", label: "Partnership" },
{ value: "LLP", label: "LLP" },
],
},
}),
getDealers: jest.fn().mockResolvedValue({
data: { success: true, data: [] },
}),
getOutlets: jest.fn().mockResolvedValue({ data: { outlets: [] } }),
createConstitutionalChange: jest.fn(),
},
}))
describe("ConstitutionalChangePage", () => {
const onViewDetails = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
mockGetConstitutionalChanges.mockResolvedValue({
data: { success: true, requests: [] },
})
})
function setup() {
render(<ConstitutionalChangePage onViewDetails={onViewDetails} />)
}
it("renders header and stats after loading", async () => {
setup()
await waitFor(() => {
expect(mockGetConstitutionalChanges).toHaveBeenCalled()
})
expect(
screen.getByRole("heading", {
name: /constitutional change management/i,
}),
).toBeInTheDocument()
expect(screen.getByText("Total Requests")).toBeInTheDocument()
expect(
screen.getAllByText(/Submitted \/ Review/i).length,
).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText(/^Completed$/i).length).toBeGreaterThanOrEqual(
1,
)
expect(
screen.getByRole("button", { name: /new request/i }),
).toBeInTheDocument()
})
it("shows empty state when there are no requests", async () => {
setup()
await waitFor(() => {
expect(
screen.getByText(
/no constitutional change requests found/i,
),
).toBeInTheDocument()
})
})
it("calls onViewDetails when View is clicked for a row", async () => {
const user = userEvent.setup()
mockGetConstitutionalChanges.mockResolvedValue({
data: {
success: true,
requests: [
{
requestId: "CCR-100",
status: "Submitted",
currentStage: "Submitted",
currentConstitution: "Proprietorship",
changeType: "Partnership",
progressPercentage: 5,
createdAt: "2024-06-01T10:00:00.000Z",
dealer: {
fullName: "Dealer User",
dealerProfile: {
dealerCode: { dealerCode: "DLR-1" },
businessName: "Roadside Motors",
registeredAddress: "City",
},
},
},
],
},
})
setup()
const viewBtn = await screen.findByRole("button", { name: /^view$/i })
await user.click(viewBtn)
expect(onViewDetails).toHaveBeenCalledWith("CCR-100")
})
it("opens New Request dialog and loads form fields", async () => {
const user = userEvent.setup()
setup()
await waitFor(() => {
expect(mockGetConstitutionalChanges).toHaveBeenCalled()
})
await user.click(
screen.getByRole("button", { name: /new request/i }),
)
expect(
screen.getByRole("heading", {
name: /create constitutional change request/i,
}),
).toBeInTheDocument()
expect(screen.getByText(/^Dealer \*$/i)).toBeInTheDocument()
})
})

View File

@ -0,0 +1,108 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { API } from "@/api/API"
import { DealerConstitutionalChangePage } from "../pages/DealerConstitutionalChangePage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
jest.mock("@/api/API", () => ({
API: {
getDealerDashboard: jest.fn(),
getConstitutionalChanges: jest.fn(),
getConstitutionalChangeMeta: jest.fn(),
createConstitutionalChange: jest.fn(),
},
}))
describe("DealerConstitutionalChangePage", () => {
const onViewDetails = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(API.getDealerDashboard).mockResolvedValue({
data: {
profile: {
constitutionType: "Proprietorship",
dealerCode: "DLR-99",
businessName: "Test Motors",
},
},
} as never)
jest.mocked(API.getConstitutionalChanges).mockResolvedValue({
data: { requests: [] },
} as never)
jest.mocked(API.getConstitutionalChangeMeta).mockResolvedValue({
data: {
success: true,
structureTargets: [
{ value: "Partnership", label: "Partnership" },
{ value: "LLP", label: "LLP" },
],
},
} as never)
})
function setup() {
render(
<DealerConstitutionalChangePage onViewDetails={onViewDetails} />,
)
}
it("renders dealer heading and stats after loading", async () => {
setup()
expect(
await screen.findByRole("heading", {
level: 1,
name: /my constitutional change requests/i,
}),
).toBeInTheDocument()
expect(API.getDealerDashboard).toHaveBeenCalled()
expect(screen.getByText("Total Requests")).toBeInTheDocument()
expect(screen.getByText("Pending")).toBeInTheDocument()
expect(screen.getByText("Completed")).toBeInTheDocument()
})
it("shows empty table message when no requests", async () => {
setup()
await waitFor(() => {
expect(
screen.getByText(
/no constitutional change requests found/i,
),
).toBeInTheDocument()
})
})
it("opens submit dialog from New Constitutional Change", async () => {
const user = userEvent.setup()
setup()
await screen.findByRole("heading", {
level: 1,
name: /my constitutional change requests/i,
})
await user.click(
await screen.findByRole("button", {
name: /new constitutional change/i,
}),
)
expect(
screen.getByRole("heading", {
name: /submit constitutional change request/i,
}),
).toBeInTheDocument()
expect(screen.getByText(/current dealership information/i)).toBeInTheDocument()
})
})

View File

@ -1,19 +1,20 @@
import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, ArrowRight, MessageSquare, Loader2, Ban, Undo2 } from 'lucide-react'; import { ArrowLeft, CheckCircle2, Clock, AlertCircle, Upload, Download, Eye, ArrowRight, MessageSquare, Loader2, Ban, Undo2 } from 'lucide-react';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { User as UserType } from '../../lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { OFFBOARDING_ACTIONS } from '@/lib/offboarding-actions';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface ConstitutionalChangeDetailsProps { interface ConstitutionalChangeDetailsProps {
requestId: string; requestId: string;
@ -23,9 +24,9 @@ interface ConstitutionalChangeDetailsProps {
// Workflow stages as per the process flow (SRS 12.2.4) // Workflow stages as per the process flow (SRS 12.2.4)
const workflowStages = [ const workflowStages = [
{ id: 1, name: 'Submitted', key: 'submitted', role: 'Dealer' },
{ id: 2, name: 'ASM Review', key: 'asm-review', role: 'ASM' }, { id: 2, name: 'ASM Review', key: 'asm-review', role: 'ASM' },
{ id: 3, name: 'ZM/RBM Review', key: 'zm-rbm-review', role: 'ZM/RBM' }, { id: 3, name: 'ZM/RBM Review', key: 'zm-rbm-review', role: 'ZM/RBM' },
{ id: 4, name: 'ZBH Review', key: 'zbh-review', role: 'ZBH' }, { id: 4, name: 'ZBH Review', key: 'zbh-review', role: 'ZBH' },
{ id: 5, name: 'DD Lead Review', key: 'lead-review', role: 'DD Lead' }, { id: 5, name: 'DD Lead Review', key: 'lead-review', role: 'DD Lead' },
{ id: 6, name: 'DD Head Review', key: 'head-review', role: 'DD Head' }, { id: 6, name: 'DD Head Review', key: 'head-review', role: 'DD Head' },
@ -40,7 +41,7 @@ const formatStageLabel = (label: string) =>
const formatStageRole = (role: string) => const formatStageRole = (role: string) =>
role === 'ZM/RBM' ? 'ZM+RBM' : role; role === 'ZM/RBM' ? 'ZM+RBM' : role;
// Document requirements mapping (same as in ConstitutionalChangePage) // Document requirements mapping (same as in ConstitutionalChangePage) — SRS §12.2.4 by target constitution
const documentRequirements: Record<string, number[]> = { const documentRequirements: Record<string, number[]> = {
'Partnership': [1, 2, 3, 4, 8, 9, 10, 16], 'Partnership': [1, 2, 3, 4, 8, 9, 10, 16],
'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16], 'LLP': [1, 2, 3, 7, 8, 9, 10, 11, 16],
@ -49,6 +50,9 @@ const documentRequirements: Record<string, number[]> = {
'Proprietorship': [1, 2, 3, 10, 16] 'Proprietorship': [1, 2, 3, 10, 16]
}; };
/** Ad-hoc / future uploads; not part of mandatory §12.2.4 checklist. Multiple files allowed (append). */
const OTHER_DOCUMENT_DOC_NUMBER = 99;
const documentNames: Record<number, string> = { const documentNames: Record<number, string> = {
1: 'GST Certificate', 1: 'GST Certificate',
2: 'Firm PAN Copy', 2: 'Firm PAN Copy',
@ -65,7 +69,8 @@ const documentNames: Record<number, string> = {
13: 'NBH Approval', 13: 'NBH Approval',
14: 'RBM Approval', 14: 'RBM Approval',
15: 'DD-Lead Approval', 15: 'DD-Lead Approval',
16: 'Declaration / Authorization Letter' 16: 'Declaration / Authorization Letter',
[OTHER_DOCUMENT_DOC_NUMBER]: 'Other'
}; };
@ -97,26 +102,29 @@ const getStatusColor = (status: string) => {
/** Audit rows were stored as UPDATED for approvals; avoid treating "UPDATED" as pending via substring "update". */ /** Audit rows were stored as UPDATED for approvals; avoid treating "UPDATED" as pending via substring "update". */
const getConstitutionalHistoryPresentation = (entry: any) => { const getConstitutionalHistoryPresentation = (entry: any) => {
const raw = String(entry.action || 'UPDATED').toUpperCase(); const action = String(entry.action || '').toUpperCase();
const desc = String(entry.description || '').toUpperCase();
const rem = String(entry.remarks || '').toUpperCase();
const details = entry.details || entry.newData || {}; const details = entry.details || entry.newData || {};
const targetStage = details.targetStage as string | undefined; const targetStage = String(details.targetStage || '').toUpperCase();
const remarks = String(entry.remarks || '').toLowerCase(); const detailsAction = String(details.action || '').toUpperCase();
if (raw === 'REJECTED') return { variant: 'danger' as const, badge: 'REJECTED' }; const combined = `${action} ${desc} ${rem} ${detailsAction}`.toUpperCase();
if (raw === 'CONSTITUTIONAL_REVOKED' || raw === 'REVOKED') return { variant: 'danger' as const, badge: 'REVOKED' };
if (raw === 'CONSTITUTIONAL_SENT_BACK') return { variant: 'pending' as const, badge: 'SENT BACK' }; if (combined.includes('REJECT')) return { variant: 'danger' as const, badge: action.replace(/_/g, ' ') || 'REJECTED' };
if (raw === 'DOCUMENT_REJECTED') return { variant: 'danger' as const, badge: 'DOCUMENT REJECTED' }; if (combined.includes('REVOK')) return { variant: 'danger' as const, badge: action.replace(/_/g, ' ') || 'REVOKED' };
if (raw === 'APPROVED' || raw === 'CREATED' || raw === 'DOCUMENT_UPLOADED' || raw === 'DOCUMENT_VERIFIED') { if (combined.includes('SENT BACK') || combined.includes('SEND BACK') || combined.includes('RECONSIDER')) {
return { variant: 'success' as const, badge: raw.replace(/_/g, ' ') }; return { variant: 'pending' as const, badge: action.replace(/_/g, ' ') || 'SENT BACK' };
} }
if (raw === 'UPDATED') {
if (remarks.includes('send') && remarks.includes('back')) return { variant: 'pending' as const, badge: 'SENT BACK' }; if (combined.includes('APPROV') || combined.includes('INITI') || combined.includes('CREATE') || combined.includes('VERIF') || combined.includes('UPLOAD')) {
if (remarks.includes('reject')) return { variant: 'danger' as const, badge: 'REJECTED' }; return { variant: 'success' as const, badge: action.replace(/_/g, ' ') || 'APPROVED' };
if (targetStage === 'Completed') return { variant: 'success' as const, badge: 'COMPLETED' };
if (targetStage) return { variant: 'success' as const, badge: 'APPROVED' };
return { variant: 'neutral' as const, badge: 'UPDATED' };
} }
return { variant: 'neutral' as const, badge: raw.replace(/_/g, ' ') || 'EVENT' };
if (targetStage === 'COMPLETED' || combined.includes('COMPLET')) return { variant: 'success' as const, badge: 'COMPLETED' };
if (targetStage) return { variant: 'success' as const, badge: 'APPROVED' };
return { variant: 'neutral' as const, badge: action.replace(/_/g, ' ') || 'EVENT' };
}; };
const normalizeConstitutionType = (value: string) => { const normalizeConstitutionType = (value: string) => {
@ -198,6 +206,19 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
})); }));
}, [auditLogs, request?.timeline]); }, [auditLogs, request?.timeline]);
/** Must run before any conditional return (Rules of Hooks). */
const uploadDocumentTypeOptions = useMemo(() => {
const normalized = normalizeConstitutionType(String(request?.changeType || ''));
const base = documentRequirements[normalized] || [];
const merged = [...base];
if (!merged.includes(OTHER_DOCUMENT_DOC_NUMBER)) merged.push(OTHER_DOCUMENT_DOC_NUMBER);
return merged.sort((a, b) => {
if (a === OTHER_DOCUMENT_DOC_NUMBER) return 1;
if (b === OTHER_DOCUMENT_DOC_NUMBER) return -1;
return a - b;
});
}, [request?.changeType]);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-[400px] space-y-4"> <div className="flex flex-col items-center justify-center min-h-[400px] space-y-4">
@ -239,20 +260,38 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
// Calculate current stage index mapping to backend stages // Calculate current stage index mapping to backend stages
const getCurrentStageIndex = () => { const getCurrentStageIndex = () => {
if (request.currentStage === 'Rejected' || request.currentStage === 'Revoked') return -1; let currentStage = request.currentStage || '';
// For terminal states, resolve the last active stage from timeline
if (['Rejected', 'Revoked', 'Withdrawn'].includes(request.status) && (currentStage === 'Rejected' || currentStage === 'Revoked' || !currentStage)) {
const lastEntry = [...(request.timeline || [])].reverse().find(e => e.stage && !['Rejected', 'Revoked', 'REJECTED', 'REVOKED'].includes(e.stage));
if (lastEntry) currentStage = lastEntry.stage;
}
const stageMap: Record<string, number> = { const stageMap: Record<string, number> = {
'Submitted': 1, 'ASM Review': 1,
'ASM Review': 2, 'ZM/RBM Review': 2,
'ZM/RBM Review': 3, 'ZM+RBM Review': 2,
'ZM+RBM Review': 3, 'ZBH Review': 3,
'ZBH Review': 4, 'DD Lead Review': 4,
'DD Lead Review': 5, 'DD Head Review': 5,
'DD Head Review': 6, 'NBH Approval': 6,
'NBH Approval': 7, 'Legal Review': 7,
'Legal Review': 8, 'Completed': 8
'Completed': 9
}; };
return stageMap[request.currentStage] || 1;
if (stageMap[currentStage]) return stageMap[currentStage];
// Case-insensitive fallback search
const normalized = currentStage.trim().toLowerCase();
for (const [key, val] of Object.entries(stageMap)) {
if (key.toLowerCase() === normalized || key.toLowerCase().includes(normalized) || normalized.includes(key.toLowerCase())) {
return val;
}
}
return 1;
}; };
const currentStageIndex = getCurrentStageIndex(); const currentStageIndex = getCurrentStageIndex();
@ -296,7 +335,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const currentStage = request.currentStage; const currentStage = request.currentStage;
const status = request.status; const status = request.status;
const userRole = currentUser.role || currentUser.roleCode; const userRole = currentUser.role || currentUser.roleCode;
const userRoleCode = String(currentUser.roleCode || '').toUpperCase(); const userRoleCode = String(currentUser.roleCode || currentUser.role || '').trim().toUpperCase();
const isFinalState = ['Completed', 'Rejected', 'Revoked'].includes(String(status || '')) || const isFinalState = ['Completed', 'Rejected', 'Revoked'].includes(String(status || '')) ||
['Rejected', 'Revoked', 'Completed'].includes(String(currentStage || '')); ['Rejected', 'Revoked', 'Completed'].includes(String(currentStage || ''));
@ -331,25 +370,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
currentStage !== 'Legal Review' && currentStage !== 'Legal Review' &&
currentStage !== 'Submitted'; currentStage !== 'Submitted';
const jointZmRbmMeta = (request.metadata as any)?.jointApprovals?.zmRbm || {}; let meta = request.metadata || {};
if (typeof meta === 'string') {
try {
meta = JSON.parse(meta);
} catch (e) {
console.error('Failed to parse metadata', e);
meta = {};
}
}
const jointZmRbmMeta = (meta as any)?.jointApprovals?.zmRbm || {};
const isZmRbmStage = currentStage === 'ZM/RBM Review' || currentStage === 'ZM+RBM Review'; const isZmRbmStage = currentStage === 'ZM/RBM Review' || currentStage === 'ZM+RBM Review';
const actorKey = userRoleCode === 'RBM' ? 'RBM' : (userRoleCode === 'DD-ZM' ? 'DD-ZM' : null); const actorKey = (userRoleCode === 'RBM') ? 'RBM' :
const approvedFromMetadata = actorKey ? Boolean(jointZmRbmMeta?.[actorKey]?.approvedByUserId) : false; (userRoleCode === 'DD-ZM' || userRoleCode === 'DD ZM' || userRoleCode === 'ZM') ? 'DD-ZM' : null;
const approvedFromTimeline = isZmRbmStage && Boolean(
(request.timeline || []).some((entry: any) => { // GAP CLOSURE: Rely primarily on metadata for joint approval status.
const stage = String(entry?.stage || '').trim(); const hasCurrentUserApprovedZmRbm = isZmRbmStage && actorKey && Boolean(jointZmRbmMeta?.[actorKey]?.approvedByUserId);
const action = String(entry?.action || '').toLowerCase();
const actor = String(entry?.user || '').trim().toLowerCase();
const me = String(currentUser?.name || '').trim().toLowerCase();
return (
(stage === 'ZM/RBM Review' || stage === 'ZM+RBM Review' || stage === 'RBM Review' || stage === 'ZM Review') &&
action.includes('approved') &&
me.length > 0 &&
actor === me
);
})
);
const hasCurrentUserApprovedZmRbm = isZmRbmStage && (approvedFromMetadata || approvedFromTimeline);
return { return {
canApprove: isCurrentlyAssigned && !isFinalState && !hasCurrentUserApprovedZmRbm, canApprove: isCurrentlyAssigned && !isFinalState && !hasCurrentUserApprovedZmRbm,
@ -383,20 +421,30 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
const handleSubmitAction = async (e: React.FormEvent) => { const handleSubmitAction = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
const remarksRequired = actionType === 'sendBack' || actionType === 'revoke'; const remarksTrimmed = String(comments || '').trim();
if (remarksRequired && !String(comments || '').trim()) { const isHighRiskAction = actionType === 'sendBack' || actionType === 'revoke';
toast.error('Remarks are required for Send Back and Revoke (SRS §12.2.3).');
if (isHighRiskAction && remarksTrimmed.length < 5) {
toast.error('Detailed remarks (minimum 5 characters) are required for Send Back and Revoke actions.');
return;
}
if (!remarksTrimmed && actionType !== 'approve') {
toast.error('Remarks are required for this action.');
return; return;
} }
try { try {
setIsActionLoading(true); setIsActionLoading(true);
const actionLabel = const actionPayload =
actionType === 'approve' ? 'Approve' : actionType === 'approve'
actionType === 'reject' ? 'Reject' : ? OFFBOARDING_ACTIONS.APPROVE
actionType === 'sendBack' ? 'Send Back' : : actionType === 'reject'
'Revoke'; ? OFFBOARDING_ACTIONS.REJECT
const response = await API.updateConstitutionalChange(requestId, actionLabel, { : actionType === 'sendBack'
? OFFBOARDING_ACTIONS.SEND_BACK
: OFFBOARDING_ACTIONS.REVOKE;
const response = await API.updateConstitutionalChange(requestId, actionPayload, {
comments comments
}) as any; }) as any;
@ -409,7 +457,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
toast.success(`Request ${actionText} successfully`); toast.success(`Request ${actionText} successfully`);
setIsActionDialogOpen(false); setIsActionDialogOpen(false);
setComments(''); setComments('');
fetchRequestDetails(); await fetchRequestDetails();
} }
} catch (error) { } catch (error) {
console.error('Submit action error:', error); console.error('Submit action error:', error);
@ -429,10 +477,14 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
try { try {
setIsUploadingDoc(true); setIsUploadingDoc(true);
const existingDocs = Array.isArray(request.documents) ? [...request.documents] : []; const existingDocs = Array.isArray(request.documents) ? [...request.documents] : [];
const existingIndex = existingDocs.findIndex((d: any) => d.docNumber === selectedDocType); const replaceSameSlot =
selectedDocType !== OTHER_DOCUMENT_DOC_NUMBER;
const existingIndex = replaceSameSlot
? existingDocs.findIndex((d: any) => Number(d?.docNumber) === selectedDocType)
: -1;
const payloadDoc = { const payloadDoc = {
docNumber: selectedDocType, docNumber: selectedDocType,
name: documentNames[selectedDocType], name: documentNames[selectedDocType] || 'Other',
fileName: uploadFile.name, fileName: uploadFile.name,
status: 'Pending Verification', status: 'Pending Verification',
uploadedOn: new Date().toISOString(), uploadedOn: new Date().toISOString(),
@ -676,20 +728,24 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
* While DB stage is still `Submitted`, the filing step is already done; the queue is at ASM. * While DB stage is still `Submitted`, the filing step is already done; the queue is at ASM.
* Show Submitted as completed and ASM Review as in progress (no extra dealer action). * Show Submitted as completed and ASM Review as in progress (no extra dealer action).
*/ */
const atSubmittedGate = request.currentStage === 'Submitted'; // Adjusted logic for streamlined workflow (no separate 'Submitted' stage gate)
const isCompleted = const isCompleted =
flowComplete || flowComplete ||
index < currentStageIndex - 1 || index < currentStageIndex - 1;
(atSubmittedGate && index === 0);
const isCurrent = const isCurrent =
!flowComplete && !flowComplete &&
(atSubmittedGate ? index === 1 : index === currentStageIndex - 1); index === currentStageIndex - 1;
const timelineEntry = getLatestStageTimelineEntry(stage.name); const timelineEntry = getLatestStageTimelineEntry(stage.name);
const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks; const explicitFeedback = timelineEntry?.comments || timelineEntry?.feedback || timelineEntry?.remarks;
const isJointZmRbmStage = stage.name === 'ZM/RBM Review'; const isJointZmRbmStage = stage.name === 'ZM/RBM Review';
const jointZmRbmMeta = (request.metadata as any)?.jointApprovals?.zmRbm || {}; let metaObj = request.metadata || {};
const rbmApproval = jointZmRbmMeta?.RBM; if (typeof metaObj === 'string') {
const ddZmApproval = jointZmRbmMeta?.['DD-ZM']; try { metaObj = JSON.parse(metaObj); } catch (e) { metaObj = {}; }
}
const jointZmRbmMetaObj = (metaObj as any)?.jointApprovals?.zmRbm || {};
const rbmApproval = jointZmRbmMetaObj?.RBM;
const ddZmApproval = jointZmRbmMetaObj?.['DD-ZM'];
const currentRoleNormalized = String(currentUser?.roleCode || currentUser?.role || '').toUpperCase(); const currentRoleNormalized = String(currentUser?.roleCode || currentUser?.role || '').toUpperCase();
const currentRoleApproval = const currentRoleApproval =
currentRoleNormalized === 'RBM' currentRoleNormalized === 'RBM'
@ -698,6 +754,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
? ddZmApproval ? ddZmApproval
: null; : null;
return ( return (
<div key={stage.id} className="flex items-start gap-4"> <div key={stage.id} className="flex items-start gap-4">
{/* Status Icon */} {/* Status Icon */}
@ -730,15 +787,9 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{formatStageLabel(stage.name)} {formatStageLabel(stage.name)}
</h4> </h4>
<p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}> <p className={`text-sm ${isCurrent ? 'text-amber-700' : 'text-slate-600'}`}>
{atSubmittedGate && index === 0 {`Responsible: ${formatStageRole(stage.role)}`}
? 'Dealer action: filing complete (no further step here).'
: `Responsible: ${formatStageRole(stage.role)}`}
{atSubmittedGate && index === 1 ? (
<span className="block mt-0.5 text-amber-800/90">
ASM approves to advance the request (first workflow action after submission).
</span>
) : null}
</p> </p>
</div> </div>
<Badge className={ <Badge className={
isCompleted ? 'bg-green-100 text-green-700 border-green-300' : isCompleted ? 'bg-green-100 text-green-700 border-green-300' :
@ -812,7 +863,7 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{/* Required Documents Sub-tab */} {/* Required Documents Sub-tab */}
<TabsContent value="required" className="mt-0"> <TabsContent value="required" className="mt-0">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-4">
<h4 className="text-slate-900">Document Checklist</h4> <h4 className="text-slate-900">Document Checklist</h4>
<Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@ -840,10 +891,10 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
}} }}
> >
<option value="">Select document type</option> <option value="">Select document type</option>
{requiredDocs.map((docNum) => ( {uploadDocumentTypeOptions.map((docNum) => (
<option key={docNum} value={String(docNum)}> <option key={docNum} value={String(docNum)}>
{isDocTypeUploaded(docNum) ? '✓ ' : ''} {docNum !== OTHER_DOCUMENT_DOC_NUMBER && isDocTypeUploaded(docNum) ? '✓ ' : ''}
{documentNames[docNum]} {documentNames[docNum] || `Document ${docNum}`}
</option> </option>
))} ))}
</select> </select>
@ -1035,8 +1086,20 @@ export function ConstitutionalChangeDetails({ requestId, onBack, currentUser }:
{pres.badge} {pres.badge}
</Badge> </Badge>
</div> </div>
<p className="text-slate-600 text-sm mt-2">{entry.description || entry.remarks || 'No remarks provided'}</p> <div className="mt-2 p-3 bg-slate-50 rounded border border-slate-100 italic text-slate-700 text-sm">
<p className="text-slate-500 text-sm mt-1">{formatDateTime(entry.timestamp || entry.createdAt)}</p> <span className="font-semibold non-italic text-slate-500 mr-2">Comments:</span>
{/*
Audit API puts user-entered text in `remarks` and a generated summary in `description`
(e.g. "Approval - Stage: ZM/RBM Review"). Prefer remarks so History matches the approve modal / Work Notes.
*/}
{String(entry.remarks || '').trim() ||
String(entry.description || '').trim() ||
'No remarks provided'}
</div>
<p className="text-slate-400 text-xs mt-2 font-medium uppercase tracking-wider">
{formatDateTime(entry.timestamp || entry.createdAt)}
</p>
</div> </div>
</div> </div>
); );

View File

@ -1,18 +1,18 @@
import { FileText, Calendar, Building, Plus, Eye, ArrowRight, Shield, Loader2 } from 'lucide-react'; import { FileText, Calendar, Building, Plus, Eye, ArrowRight, Shield, Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { User as UserType } from '../../lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change'; import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
interface ConstitutionalChangePageProps { interface ConstitutionalChangePageProps {
@ -29,6 +29,8 @@ const documentRequirements: Record<string, number[]> = {
}; };
// Document names // Document names
const OTHER_DOCUMENT_DOC_NUMBER = 99;
const documentNames: Record<number, string> = { const documentNames: Record<number, string> = {
1: 'GST', 1: 'GST',
2: 'Firm Pan Copy', 2: 'Firm Pan Copy',
@ -45,7 +47,8 @@ const documentNames: Record<number, string> = {
13: 'NBH Approval', 13: 'NBH Approval',
14: 'RBM Approval', 14: 'RBM Approval',
15: 'DD-Lead Approval', 15: 'DD-Lead Approval',
16: 'Declaration / Authorization Letter' 16: 'Declaration / Authorization Letter',
[OTHER_DOCUMENT_DOC_NUMBER]: 'Other'
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {

View File

@ -1,19 +1,19 @@
import { RefreshCcw, Plus, Eye, Calendar, FileText, Loader2 } from 'lucide-react'; import { RefreshCcw, Plus, Eye, Calendar, FileText, Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { User as UserType } from '../../lib/mock-data'; import { User as UserType } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { dealerService } from '../../services/dealer.service'; import { dealerService } from '@/services/dealer.service';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change'; import { normalizeDealerProfileConstitution } from '@/lib/constitutional-change';
interface DealerConstitutionalChangePageProps { interface DealerConstitutionalChangePageProps {

View File

@ -11,10 +11,10 @@ import {
Mail, Mail,
Inbox Inbox
} from 'lucide-react'; } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { dashboardStats, recentActivities } from '../../lib/mock-data'; import { dashboardStats, recentActivities } from '@/lib/mock-data';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '../ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
interface DashboardProps { interface DashboardProps {
onNavigate: (view: string, filter?: string) => void; onNavigate: (view: string, filter?: string) => void;

View File

@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { RefreshCcw, MapPin, Users, TrendingUp, Clock, CheckCircle, AlertCircle, ShoppingBag, Loader2 } from 'lucide-react'; import { RefreshCcw, MapPin, Users, TrendingUp, Clock, CheckCircle, AlertCircle, ShoppingBag, Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { User } from '../../lib/mock-data'; import { User } from '@/lib/mock-data';
import { dealerService } from '../../services/dealer.service'; import { dealerService } from '@/services/dealer.service';
interface DealerDashboardProps { interface DealerDashboardProps {
currentUser: User | null; currentUser: User | null;

View File

@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
FileText, FileText,
Clock, Clock,

View File

@ -1,20 +1,20 @@
import { IndianRupee, FileText, CheckCircle, Plus, Trash2, Save, Calculator, Clock, TrendingUp, TrendingDown } from 'lucide-react'; import { IndianRupee, FileText, CheckCircle, Plus, Trash2, Save, Calculator, Clock, TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { User } from '../../lib/mock-data'; import { User } from '@/lib/mock-data';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { settlementService } from '../../services/settlement.service'; import { settlementService } from '@/services/settlement.service';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface FinanceDashboardProps { interface FinanceDashboardProps {
currentUser: User | null; currentUser: User | null;
@ -28,7 +28,7 @@ interface FinanceLineItem {
id: string; id: string;
department: string; department: string;
description: string; description: string;
type: 'payable' | 'recovery'; type: 'payable' | 'receivable';
amount: number; amount: number;
} }
@ -131,23 +131,23 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
const [newLineItem, setNewLineItem] = useState({ const [newLineItem, setNewLineItem] = useState({
department: '', department: '',
description: '', description: '',
type: 'recovery' as 'payable' | 'recovery', type: 'receivable' as 'payable' | 'receivable',
amount: '' amount: ''
}); });
const [finalRemarks, setFinalRemarks] = useState(''); const [finalRemarks, setFinalRemarks] = useState('');
const calculateTotals = () => { const calculateTotals = () => {
const totalRecovery = lineItems const totalReceivable = lineItems
.filter(item => item.type === 'recovery') .filter(item => item.type === 'receivable')
.reduce((sum, item) => sum + item.amount, 0); .reduce((sum, item) => sum + item.amount, 0);
const totalPayable = lineItems const totalPayable = lineItems
.filter(item => item.type === 'payable') .filter(item => item.type === 'payable')
.reduce((sum, item) => sum + item.amount, 0); .reduce((sum, item) => sum + item.amount, 0);
const netAmount = totalPayable - totalRecovery; const netAmount = totalPayable - totalReceivable;
return { totalRecovery, totalPayable, netAmount }; return { totalReceivable, totalPayable, netAmount };
}; };
const handleSubmitFinanceSummary = async () => { const handleSubmitFinanceSummary = async () => {
@ -200,7 +200,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
}; };
setLineItems([...lineItems, item]); setLineItems([...lineItems, item]);
setNewLineItem({ department: '', description: '', type: 'recovery', amount: '' }); setNewLineItem({ department: '', description: '', type: 'receivable', amount: '' });
toast.success('Line item added'); toast.success('Line item added');
}; };
@ -209,7 +209,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
toast.info('Line item removed'); toast.info('Line item removed');
}; };
const { totalRecovery, totalPayable, netAmount } = calculateTotals(); const { totalReceivable, totalPayable, netAmount } = calculateTotals();
if (loading) { if (loading) {
return ( return (
@ -617,7 +617,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
{fnf.netAmount ? Math.abs(fnf.netAmount).toLocaleString('en-IN') : '0'} {fnf.netAmount ? Math.abs(fnf.netAmount).toLocaleString('en-IN') : '0'}
</p> </p>
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
{fnf.netAmount && fnf.netAmount >= 0 ? 'Payable to Dealer' : 'Recovery from Dealer'} {fnf.netAmount && fnf.netAmount >= 0 ? 'Payable to Dealer' : 'Receivable from Dealer'}
</p> </p>
</div> </div>
<div> <div>
@ -685,12 +685,12 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<Label className="text-xs">Type</Label> <Label className="text-xs">Type</Label>
<Select value={newLineItem.type} onValueChange={(value: 'payable' | 'recovery') => setNewLineItem({...newLineItem, type: value})}> <Select value={newLineItem.type} onValueChange={(value: 'payable' | 'receivable') => setNewLineItem({...newLineItem, type: value})}>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="recovery">Recovery</SelectItem> <SelectItem value="receivable">Receivable</SelectItem>
<SelectItem value="payable">Payable</SelectItem> <SelectItem value="payable">Payable</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@ -735,11 +735,11 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
<TableCell>{item.department}</TableCell> <TableCell>{item.department}</TableCell>
<TableCell>{item.description}</TableCell> <TableCell>{item.description}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
<Badge className={item.type === 'recovery' ? 'bg-red-100 text-red-700 border-red-200' : 'bg-green-100 text-green-700 border-green-200'}> <Badge className={item.type === 'receivable' ? 'bg-red-100 text-red-700 border-red-200' : 'bg-green-100 text-green-700 border-green-200'}>
{item.type === 'recovery' ? 'Recovery' : 'Payable'} {item.type === 'receivable' ? 'Receivable' : 'Payable'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className={`text-right font-medium ${item.type === 'recovery' ? 'text-red-700' : 'text-green-700'}`}> <TableCell className={`text-right font-medium ${item.type === 'receivable' ? 'text-red-700' : 'text-green-700'}`}>
{item.amount.toLocaleString('en-IN')} {item.amount.toLocaleString('en-IN')}
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -768,8 +768,8 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
<p className="text-3xl font-bold text-green-400">{totalPayable.toLocaleString('en-IN')}</p> <p className="text-3xl font-bold text-green-400">{totalPayable.toLocaleString('en-IN')}</p>
</div> </div>
<div> <div>
<p className="text-xs text-slate-400 uppercase tracking-wider mb-1">Total Recovery</p> <p className="text-xs text-slate-400 uppercase tracking-wider mb-1">Total receivable</p>
<p className="text-3xl font-bold text-red-400">{totalRecovery.toLocaleString('en-IN')}</p> <p className="text-3xl font-bold text-red-400">{totalReceivable.toLocaleString('en-IN')}</p>
</div> </div>
<div> <div>
<p className="text-xs text-slate-400 uppercase tracking-wider mb-1">Net Settlement</p> <p className="text-xs text-slate-400 uppercase tracking-wider mb-1">Net Settlement</p>
@ -777,7 +777,7 @@ export function FinanceDashboard({ onNavigate, onViewPaymentDetails, onViewAudit
{Math.abs(netAmount).toLocaleString('en-IN')} {Math.abs(netAmount).toLocaleString('en-IN')}
</p> </p>
<p className="text-xs text-slate-400 mt-1 italic"> <p className="text-xs text-slate-400 mt-1 italic">
{netAmount >= 0 ? 'Payable to Dealer' : 'Recoverable from Dealer'} {netAmount >= 0 ? 'Payable to Dealer' : 'Receivable from Dealer'}
</p> </p>
</div> </div>
</div> </div>

View File

@ -18,13 +18,13 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, Routes, Route, useParams } from 'react-router-dom'; import { useNavigate, Routes, Route, useParams } from 'react-router-dom';
import { RootState } from '../../store'; import { RootState } from '@/store';
import { logout } from '../../store/slices/authSlice'; import { logout } from '@/store/slices/authSlice';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { ProspectiveApplicationDetails } from '../applications/ProspectiveApplicationDetails'; import { ProspectiveApplicationDetails } from '@/features/onboarding/pages/ProspectiveApplicationDetails';
export function ProspectiveDashboardPage() { export function ProspectiveDashboardPage() {
const dispatch = useDispatch(); const dispatch = useDispatch();

View File

@ -0,0 +1,73 @@
import { render, screen, waitFor } from "@testing-library/react"
import { API } from "@/api/API"
import { FinanceFnFDetailsPage } from "../pages/FinanceFnFDetailsPage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
info: jest.fn(),
},
}))
jest.mock("@/components/ui/DocumentPreviewModal", () => ({
DocumentPreviewModal: () => null,
}))
jest.mock("@/features/onboarding/components/BankDetailsModal", () => ({
BankDetailsModal: () => null,
}))
jest.mock("@/api/API", () => ({
API: {
getSettlementDepartments: jest.fn().mockResolvedValue({
data: { success: true, departments: [] },
}),
getFnFSettlementById: jest.fn(),
getDealerBankDetails: jest.fn(),
},
}))
jest.mock("@/services/settlement.service", () => ({
settlementService: {
updateFnF: jest.fn(),
},
}))
describe("FinanceFnFDetailsPage", () => {
const onBack = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it("shows not found when settlement cannot be loaded", async () => {
jest.mocked(API.getFnFSettlementById).mockResolvedValue({
data: { success: false },
} as never)
render(
<FinanceFnFDetailsPage fnfId="bad-id" onBack={onBack} />,
)
expect(
await screen.findByText(/settlement case not found/i),
).toBeInTheDocument()
screen.getByRole("button", { name: /^go back$/i }).click()
expect(onBack).toHaveBeenCalled()
})
it("fetches departments and settlement on mount", async () => {
jest.mocked(API.getFnFSettlementById).mockResolvedValue({
data: { success: false },
} as never)
render(<FinanceFnFDetailsPage fnfId="fnf-xyz" onBack={onBack} />)
await waitFor(() => {
expect(API.getSettlementDepartments).toHaveBeenCalled()
expect(API.getFnFSettlementById).toHaveBeenCalledWith("fnf-xyz")
})
})
})

View File

@ -0,0 +1,79 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { API } from "@/api/API"
import { FinanceFnFPage } from "../pages/FinanceFnFPage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
jest.mock("@/api/API", () => ({
API: {
getFnFSettlements: jest.fn(),
},
}))
describe("FinanceFnFPage", () => {
const onViewFnFDetails = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(API.getFnFSettlements).mockResolvedValue({
data: { success: true, settlements: [] },
} as never)
})
it("renders finance header and queue after loading", async () => {
render(<FinanceFnFPage onViewFnFDetails={onViewFnFDetails} />)
expect(
await screen.findByRole("heading", {
level: 1,
name: /f&f financial settlement/i,
}),
).toBeInTheDocument()
expect(
screen.getByRole("heading", { name: /f&f settlement queue/i }),
).toBeInTheDocument()
expect(API.getFnFSettlements).toHaveBeenCalled()
})
it("calls onViewFnFDetails when View Details is clicked", async () => {
const user = userEvent.setup()
jest.mocked(API.getFnFSettlements).mockResolvedValue({
data: {
success: true,
settlements: [
{
id: "finance-fnf-99",
settlementId: "FNF-99",
status: "Calculated",
resignationId: "R-9",
resignation: { resignationId: "R-9" },
outlet: {
code: "DLR-X",
city: "Mumbai",
dealer: { fullName: "Finance Dealer" },
},
totalPayables: "10000",
totalReceivables: "5000",
netAmount: "-2500",
createdAt: "2025-03-01T12:00:00.000Z",
},
],
},
} as never)
render(<FinanceFnFPage onViewFnFDetails={onViewFnFDetails} />)
await user.click(
await screen.findByRole("button", { name: /view details/i }),
)
expect(onViewFnFDetails).toHaveBeenCalledWith("finance-fnf-99")
})
})

View File

@ -0,0 +1,86 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { onboardingService } from "@/services/onboarding.service"
import { FinancePaymentDetailsPage } from "../pages/FinancePaymentDetailsPage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
jest.mock("@/components/ui/DocumentPreviewModal", () => ({
DocumentPreviewModal: () => null,
}))
jest.mock("@/services/onboarding.service", () => ({
onboardingService: {
getApplicationById: jest.fn(),
getSecurityDeposit: jest.fn(),
getSystemConfigs: jest.fn(),
updateSecurityDeposit: jest.fn(),
},
}))
describe("FinancePaymentDetailsPage", () => {
const onBack = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(onboardingService.getApplicationById).mockResolvedValue({
id: "app-internal",
applicationId: "APP-001",
applicantName: "Sample Applicant",
city: "Chennai",
state: "TN",
email: "a@example.com",
phone: "9999999999",
} as Awaited<ReturnType<typeof onboardingService.getApplicationById>>)
jest.mocked(onboardingService.getSecurityDeposit).mockResolvedValue([])
jest.mocked(onboardingService.getSystemConfigs).mockResolvedValue({
SECURITY_DEPOSIT: { amount: 500000 },
FIRST_FILL: { amount: 1500000 },
} as never)
})
function setup(applicationId = "app-internal") {
render(
<FinancePaymentDetailsPage
applicationId={applicationId}
onBack={onBack}
/>,
)
}
it("renders payment verification header and applicant info", async () => {
setup()
expect(
await screen.findByRole("heading", {
name: /payment verification/i,
}),
).toBeInTheDocument()
expect(screen.getByText("APP-001")).toBeInTheDocument()
expect(screen.getByText("Sample Applicant")).toBeInTheDocument()
expect(onboardingService.getApplicationById).toHaveBeenCalledWith(
"app-internal",
)
})
it("calls onBack when back button is used", async () => {
const user = userEvent.setup()
setup()
await screen.findByRole("heading", { name: /payment verification/i })
const backBtn = screen.getAllByRole("button").find((b) =>
b.querySelector("svg.lucide-arrow-left"),
)
expect(backBtn).toBeTruthy()
await user.click(backBtn as HTMLElement)
expect(onBack).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,79 @@
import { render, screen, waitFor } from "@testing-library/react"
import { MemoryRouter } from "react-router-dom"
import { API } from "@/api/API"
import { MEMORY_ROUTER_TEST_FUTURE } from "@/testing/reactRouterTest"
import { FnFDetails } from "../pages/FnFDetails"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
jest.mock("@/api/API", () => ({
API: {
getFnFSettlementById: jest.fn(),
getAuditLogs: jest.fn().mockResolvedValue({
data: { success: true, data: [] },
}),
getDealerBankDetails: jest.fn(),
},
}))
jest.mock("@/components/ui/DocumentPreviewModal", () => ({
DocumentPreviewModal: () => null,
}))
jest.mock("@/features/onboarding/components/BankDetailsModal", () => ({
BankDetailsModal: () => null,
}))
describe("FnFDetails", () => {
const onBack = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
function setup(fnfId = "missing-id") {
render(
<MemoryRouter future={MEMORY_ROUTER_TEST_FUTURE}>
<FnFDetails
fnfId={fnfId}
onBack={onBack}
currentUser={{ role: "DD Lead", name: "Lead" }}
/>
</MemoryRouter>,
)
}
it("shows case not found when API returns unsuccessful payload", async () => {
jest.mocked(API.getFnFSettlementById).mockResolvedValue({
data: { success: false },
} as never)
setup()
expect(
await screen.findByText(/case not found/i),
).toBeInTheDocument()
screen.getByRole("button", { name: /^go back$/i }).click()
expect(onBack).toHaveBeenCalled()
})
it("requested settlement by id", async () => {
jest.mocked(API.getFnFSettlementById).mockResolvedValue({
data: { success: false },
} as never)
setup("fnf-uuid-123")
await waitFor(() => {
expect(API.getFnFSettlementById).toHaveBeenCalledWith(
"fnf-uuid-123",
)
})
})
})

View File

@ -0,0 +1,89 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { API } from "@/api/API"
import { FnFPage } from "../pages/FnFPage"
jest.mock("sonner", () => ({
toast: {
error: jest.fn(),
success: jest.fn(),
},
}))
jest.mock("@/api/API", () => ({
API: {
getFnFSettlements: jest.fn(),
},
}))
const mockUser = {
id: "u1",
name: "DD Lead",
email: "dd@test.com",
password: "",
role: "DD Lead" as const,
}
describe("FnFPage", () => {
const onViewDetails = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(API.getFnFSettlements).mockResolvedValue({
data: { success: true, settlements: [] },
} as never)
})
it("renders summary stats and main title after loading", async () => {
render(
<FnFPage currentUser={mockUser} onViewDetails={onViewDetails} />,
)
expect(
await screen.findByRole("heading", {
name: /full & final settlement cases/i,
}),
).toBeInTheDocument()
expect(screen.getByText("Newly created")).toBeInTheDocument()
expect(screen.getByText(/^Total$/)).toBeInTheDocument()
expect(API.getFnFSettlements).toHaveBeenCalled()
})
it("calls onViewDetails when View Details is clicked", async () => {
const user = userEvent.setup()
jest.mocked(API.getFnFSettlements).mockResolvedValue({
data: {
success: true,
settlements: [
{
id: "fnf-case-1",
settlementId: "SET-100",
status: "Initiated",
resignationId: "RES-1",
resignation: { resignationId: "RES-1" },
outlet: {
code: "OUT-9",
name: "City Motors",
city: "Pune",
dealer: { fullName: "Dealer Person" },
},
totalReceivables: "0",
totalPayables: "0",
createdAt: "2025-02-01T10:00:00.000Z",
},
],
},
} as never)
render(
<FnFPage currentUser={mockUser} onViewDetails={onViewDetails} />,
)
await user.click(
await screen.findByRole("button", { name: /view details/i }),
)
expect(onViewDetails).toHaveBeenCalledWith("fnf-case-1")
})
})

View File

@ -1,16 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { settlementService } from '../../services/settlement.service'; import { settlementService } from '@/services/settlement.service';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Progress } from '../ui/progress'; import { Progress } from '@/components/ui/progress';
import { import {
ArrowLeft, ArrowLeft,
IndianRupee, IndianRupee,
@ -36,25 +36,16 @@ import {
FileDown FileDown
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '../../lib/dateUtils'; import { formatDateTime } from '@/lib/dateUtils';
import { BankDetailsModal } from './BankDetailsModal'; import { BankDetailsModal } from '@/features/onboarding/components/BankDetailsModal';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '../ui/select'; } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../ui/dialog";
const ALL_DEPARTMENTS = [ const ALL_DEPARTMENTS = [
'Warranty Department', 'Accessories Department', 'Sales Department', 'RTO Department', 'Warranty Department', 'Accessories Department', 'Sales Department', 'RTO Department',
'Service Department', 'Parts Department', 'Finance Department', 'Insurance Department', 'Service Department', 'Parts Department', 'Finance Department', 'Insurance Department',
@ -102,16 +93,6 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
const [isBankModalOpen, setIsBankModalOpen] = useState(false); const [isBankModalOpen, setIsBankModalOpen] = useState(false);
const [editingBank, setEditingBank] = useState<any>(null); const [editingBank, setEditingBank] = useState<any>(null);
const [checklist, setChecklist] = useState<string[]>([]); const [checklist, setChecklist] = useState<string[]>([]);
const [showClearanceDialog, setShowClearanceDialog] = useState(false);
const [selectedDept, setSelectedDept] = useState<any>(null);
const [isUpdatingClearance, setIsUpdatingClearance] = useState(false);
const [clearanceForm, setClearanceForm] = useState({
status: 'Pending',
remarks: '',
amount: 0,
type: 'Recovery'
});
const [clearanceFile, setClearanceFile] = useState<File | null>(null);
useEffect(() => { useEffect(() => {
fetchDepartments(); fetchDepartments();
@ -176,6 +157,11 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
(typeof description === 'string' && (typeof description === 'string' &&
(description.startsWith(DEPARTMENT_CLAIM_PREFIX) || description.includes('Clearance:'))); (description.startsWith(DEPARTMENT_CLAIM_PREFIX) || description.includes('Clearance:')));
const isAutoSeededDeptMirror = (li: any) =>
li?.sourceType === 'FinanceValidated' &&
typeof li?.description === 'string' &&
li.description.includes('Auto-seeded from department claim');
const isFinanceValidatedLine = (description?: string, sourceType?: string) => const isFinanceValidatedLine = (description?: string, sourceType?: string) =>
sourceType === 'FinanceValidated' || sourceType === 'FinanceValidated' ||
(typeof description === 'string' && description.startsWith(FINANCE_VALIDATED_PREFIX)); (typeof description === 'string' && description.startsWith(FINANCE_VALIDATED_PREFIX));
@ -213,10 +199,16 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
allLineItems: (s.lineItems || []).filter((li: any) => li.isActive !== false), allLineItems: (s.lineItems || []).filter((li: any) => li.isActive !== false),
departmentResponses: ALL_DEPARTMENTS.map((deptName: string) => { departmentResponses: ALL_DEPARTMENTS.map((deptName: string) => {
const c = (s.clearances || []).find((clearance: any) => normalizeDepartment(clearance.department) === deptName); const c = (s.clearances || []).find((clearance: any) => normalizeDepartment(clearance.department) === deptName);
const relatedItems = (s.lineItems || []).filter( const lines = (s.lineItems || []).filter((li: any) => li.isActive !== false);
const claimLines = lines.filter(
(li: any) => (li: any) =>
normalizeDepartment(li.department) === deptName && isDepartmentClaimLine(li.description, li.sourceType), normalizeDepartment(li.department) === deptName && isDepartmentClaimLine(li.description, li.sourceType),
); );
const seededMirrorLines = lines.filter(
(li: any) =>
normalizeDepartment(li.department) === deptName && isAutoSeededDeptMirror(li),
);
const relatedItems = claimLines.length > 0 ? claimLines : seededMirrorLines;
// Calculate departmental net // Calculate departmental net
let deptPayables = 0; let deptPayables = 0;
@ -234,6 +226,12 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
? 'Dues Pending' ? 'Dues Pending'
: (rawStatus === 'Cleared' ? 'NOC Submitted' : rawStatus); : (rawStatus === 'Cleared' ? 'NOC Submitted' : rawStatus);
/** Net payable to dealer vs receivable from dealer — drives UI colors */
const duesFlow =
netAmount > 0 ? 'payable' as const :
netAmount < 0 ? 'recovery' as const :
null;
return { return {
id: c?.id || `dept-${deptName}`, id: c?.id || `dept-${deptName}`,
departmentName: deptName, departmentName: deptName,
@ -241,10 +239,11 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
remarks: c?.remarks || '-', remarks: c?.remarks || '-',
submittedDate: c?.clearedAt ? formatDateTime(c.clearedAt) : '-', submittedDate: c?.clearedAt ? formatDateTime(c.clearedAt) : '-',
amount: Math.abs(netAmount), amount: Math.abs(netAmount),
duesFlow,
amountType: netAmount > 0 amountType: netAmount > 0
? 'Payable Amount' ? 'Payable to dealer'
: netAmount < 0 : netAmount < 0
? 'Recovery Amount' ? 'Receivable from dealer'
: null, : null,
supportingDocument: c?.supportingDocument || null supportingDocument: c?.supportingDocument || null
}; };
@ -405,7 +404,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
deductions, deductions,
netSettlement, netSettlement,
settlementAmount: Math.abs(netSettlement), settlementAmount: Math.abs(netSettlement),
settlementType: netSettlement > 0 ? 'Payable to Dealer' : netSettlement < 0 ? 'Recovery from Dealer' : 'No Settlement Required' settlementType: netSettlement > 0 ? 'Payable to Dealer' : netSettlement < 0 ? 'Receivable from Dealer' : 'No Settlement Required'
}; };
}; };
@ -426,7 +425,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
.reduce((sum, item) => sum + (Number(item.amount) || 0), 0); .reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
const validatedNet = validatedPayable - validatedReceivable - validatedDeduction; const validatedNet = validatedPayable - validatedReceivable - validatedDeduction;
const validatedAmount = Math.abs(validatedNet); const validatedAmount = Math.abs(validatedNet);
const validatedType = validatedNet > 0 ? 'Payable' : validatedNet < 0 ? 'Recovery' : '-'; const validatedType = validatedNet > 0 ? 'Payable' : validatedNet < 0 ? 'Receivable' : '-';
const variance = validatedAmount - claimAmount; const variance = validatedAmount - claimAmount;
return { return {
@ -537,32 +536,6 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
} }
}; };
const handleUpdateClearance = async () => {
if (!selectedDept || !fnfId) return;
try {
setIsUpdatingClearance(true);
const formData = new FormData();
formData.append('status', clearanceForm.status);
formData.append('remarks', clearanceForm.remarks);
formData.append('amount', String(clearanceForm.amount));
formData.append('type', clearanceForm.type);
if (clearanceFile) formData.append('file', clearanceFile);
await API.updateFnFClearance(fnfId, selectedDept.id, formData);
toast.success(`Clearance updated for ${selectedDept.departmentName}`);
setShowClearanceDialog(false);
setActiveTab('departments');
fetchFnFDetails(false);
} catch (error) {
console.error("Update clearance error:", error);
toast.error("Failed to update department clearance");
} finally {
setIsUpdatingClearance(false);
}
};
// Handlers for Receivables // Handlers for Receivables
const handleAddReceivable = async () => { const handleAddReceivable = async () => {
if (!newReceivable.department || !newReceivable.description || !newReceivable.amount) { if (!newReceivable.department || !newReceivable.description || !newReceivable.amount) {
@ -829,7 +802,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center"> <div className="size-12 shrink-0 aspect-square rounded-full bg-amber-100 flex items-center justify-center">
<IndianRupee className="w-5 h-5" /> <IndianRupee className="w-5 h-5" />
</div> </div>
<div> <div>
@ -853,7 +826,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<Card className={`${ <Card className={`${
settlement.settlementType === 'Payable to Dealer' settlement.settlementType === 'Payable to Dealer'
? 'border-red-300 bg-red-50' ? 'border-red-300 bg-red-50'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'border-green-300 bg-green-50' ? 'border-green-300 bg-green-50'
: 'border-slate-300 bg-slate-50' : 'border-slate-300 bg-slate-50'
}`}> }`}>
@ -862,7 +835,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{settlement.settlementType === 'Payable to Dealer' ? ( {settlement.settlementType === 'Payable to Dealer' ? (
<TrendingDown className="w-12 h-12 text-red-600" /> <TrendingDown className="w-12 h-12 text-red-600" />
) : settlement.settlementType === 'Recovery from Dealer' ? ( ) : settlement.settlementType === 'Receivable from Dealer' ? (
<TrendingUp className="w-12 h-12 text-green-600" /> <TrendingUp className="w-12 h-12 text-green-600" />
) : ( ) : (
<CheckCircle className="w-12 h-12 text-slate-600" /> <CheckCircle className="w-12 h-12 text-slate-600" />
@ -871,7 +844,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<p className={`text-sm ${ <p className={`text-sm ${
settlement.settlementType === 'Payable to Dealer' settlement.settlementType === 'Payable to Dealer'
? 'text-red-700' ? 'text-red-700'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'text-green-700' ? 'text-green-700'
: 'text-slate-700' : 'text-slate-700'
}`}> }`}>
@ -889,7 +862,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<p className="text-xs text-slate-500 mt-1"> <p className="text-xs text-slate-500 mt-1">
{settlement.settlementType === 'Payable to Dealer' {settlement.settlementType === 'Payable to Dealer'
? 'Company will pay to dealer' ? 'Company will pay to dealer'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'Dealer must pay to company' ? 'Dealer must pay to company'
: 'No payment required'} : 'No payment required'}
</p> </p>
@ -1018,7 +991,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<div className={`p-4 rounded-lg border-2 ${ <div className={`p-4 rounded-lg border-2 ${
settlement.settlementType === 'Payable to Dealer' settlement.settlementType === 'Payable to Dealer'
? 'bg-red-100 border-red-300' ? 'bg-red-100 border-red-300'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'bg-green-100 border-green-300' ? 'bg-green-100 border-green-300'
: 'bg-slate-100 border-slate-300' : 'bg-slate-100 border-slate-300'
}`}> }`}>
@ -1028,7 +1001,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<p className={`text-sm ${ <p className={`text-sm ${
settlement.settlementType === 'Payable to Dealer' settlement.settlementType === 'Payable to Dealer'
? 'text-red-700' ? 'text-red-700'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'text-green-700' ? 'text-green-700'
: 'text-slate-700' : 'text-slate-700'
}`}> }`}>
@ -1601,7 +1574,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<div className={`p-4 rounded-lg border-2 ${ <div className={`p-4 rounded-lg border-2 ${
settlement.settlementType === 'Payable to Dealer' settlement.settlementType === 'Payable to Dealer'
? 'bg-red-100 border-red-400' ? 'bg-red-100 border-red-400'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'bg-green-100 border-green-400' ? 'bg-green-100 border-green-400'
: 'bg-slate-100 border-slate-400' : 'bg-slate-100 border-slate-400'
}`}> }`}>
@ -1611,7 +1584,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<p className={`text-lg ${ <p className={`text-lg ${
settlement.settlementType === 'Payable to Dealer' settlement.settlementType === 'Payable to Dealer'
? 'text-red-700' ? 'text-red-700'
: settlement.settlementType === 'Recovery from Dealer' : settlement.settlementType === 'Receivable from Dealer'
? 'text-green-700' ? 'text-green-700'
: 'text-slate-700' : 'text-slate-700'
}`}> }`}>
@ -1688,7 +1661,7 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<CardHeader> <CardHeader>
<CardTitle>All Department Responses</CardTitle> <CardTitle>All Department Responses</CardTitle>
<CardDescription> <CardDescription>
Status of NOC and dues clearance from all 16 departments Status of NOC and dues clearance from all 16 departments (read-only for Finance; updates are done by department stakeholders).
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1701,12 +1674,20 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
<TableHead>Amount</TableHead> <TableHead>Amount</TableHead>
<TableHead>Submitted Date</TableHead> <TableHead>Submitted Date</TableHead>
<TableHead>Remarks</TableHead> <TableHead>Remarks</TableHead>
<TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{fnfCase.departmentResponses.map((dept: any) => ( {fnfCase.departmentResponses.map((dept: any) => (
<TableRow key={dept.id}> <TableRow
key={dept.id}
className={
dept.duesFlow === 'recovery'
? 'bg-red-50/40'
: dept.duesFlow === 'payable'
? 'bg-emerald-50/40'
: ''
}
>
<TableCell>{dept.departmentName}</TableCell> <TableCell>{dept.departmentName}</TableCell>
<TableCell> <TableCell>
<Badge className={`border ${ <Badge className={`border ${
@ -1721,9 +1702,13 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
{dept.amountType ? ( {dept.amountType ? (
<Badge <Badge
variant="outline" variant="outline"
className={dept.amountType === 'Recovery' className={
? 'bg-red-50 text-red-700 border-red-200' dept.duesFlow === 'recovery'
: 'bg-green-50 text-green-700 border-green-200'} ? 'bg-red-100 text-red-900 border-red-400 font-semibold'
: dept.duesFlow === 'payable'
? 'bg-emerald-100 text-emerald-900 border-emerald-400 font-semibold'
: 'bg-slate-50 text-slate-700 border-slate-200'
}
> >
{dept.amountType} {dept.amountType}
</Badge> </Badge>
@ -1733,7 +1718,15 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</TableCell> </TableCell>
<TableCell> <TableCell>
{dept.amount ? ( {dept.amount ? (
<span className={`${dept.amountType === 'Recovery' ? 'text-red-600 font-bold' : 'text-green-600 font-bold'}`}> <span
className={`rounded-md px-2 py-0.5 font-semibold tabular-nums ${
dept.duesFlow === 'recovery'
? 'bg-red-100 text-red-800 ring-1 ring-red-300/70'
: dept.duesFlow === 'payable'
? 'bg-emerald-100 text-emerald-800 ring-1 ring-emerald-300/70'
: 'text-slate-700'
}`}
>
{dept.amount.toLocaleString('en-IN')} {dept.amount.toLocaleString('en-IN')}
</span> </span>
) : ( ) : (
@ -1759,25 +1752,6 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
className="text-amber-600 hover:text-blue-700 font-medium"
onClick={() => {
setSelectedDept(dept);
setClearanceForm({
status: dept.status,
remarks: dept.remarks === '-' ? '' : dept.remarks,
amount: dept.amount || 0,
type: dept.amountType || (dept.amount > 0 ? 'Recovery' : 'Payable')
});
setShowClearanceDialog(true);
}}
>
Update Status
</Button>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -2213,94 +2187,6 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div> </div>
</div> </div>
{/* Clearance Update Dialog */}
<Dialog open={showClearanceDialog} onOpenChange={setShowClearanceDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Update {selectedDept?.departmentName} Status</DialogTitle>
<DialogDescription>
Mark the department as cleared or report pending dues with amount.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="status" className="text-right">Status</Label>
<select
id="status"
className="col-span-3 flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
value={clearanceForm.status}
onChange={(e) => setClearanceForm({ ...clearanceForm, status: e.target.value })}
>
<option value="Pending">Pending</option>
<option value="NOC Submitted">NOC Submitted (Cleared)</option>
<option value="Dues Pending">Dues Pending (Hold)</option>
</select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">Type</Label>
<select
id="type"
className="col-span-3 flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
value={clearanceForm.type}
onChange={(e) => setClearanceForm({ ...clearanceForm, type: e.target.value })}
>
<option value="Recovery">Recovery (from Dealer)</option>
<option value="Payable">Payable (to Dealer)</option>
<option value="Deduction">Deduction (Penalties)</option>
</select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="amount" className="text-right">Amount</Label>
<div className="col-span-3 relative">
<span className="absolute left-3 top-2.5 text-slate-500 font-medium"></span>
<input
id="amount"
type="number"
className="flex h-10 w-full rounded-md border border-slate-200 bg-white pl-7 pr-3 py-2 text-sm"
value={clearanceForm.amount}
onChange={(e) => setClearanceForm({ ...clearanceForm, amount: Number(e.target.value) })}
/>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="remarks" className="text-right">Remarks</Label>
<textarea
id="remarks"
className="col-span-3 flex min-h-[80px] w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm"
placeholder="Enter description or dues details..."
value={clearanceForm.remarks}
onChange={(e) => setClearanceForm({ ...clearanceForm, remarks: e.target.value })}
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="file" className="text-right">Proof</Label>
<input
id="file"
type="file"
className="col-span-3 text-sm"
onChange={(e) => setClearanceFile(e.target.files?.[0] || null)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowClearanceDialog(false)}>Cancel</Button>
<Button
className="bg-amber-600 hover:bg-blue-700"
onClick={handleUpdateClearance}
disabled={isUpdatingClearance}
>
{isUpdatingClearance ? "Updating..." : "Save Changes"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BankDetailsModal <BankDetailsModal
isOpen={isBankModalOpen} isOpen={isBankModalOpen}
onClose={() => { onClose={() => {
@ -2320,3 +2206,5 @@ export function FinanceFnFDetailsPage({ fnfId, onBack }: FinanceFnFDetailsPagePr
</div> </div>
); );
} }

View File

@ -1,12 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import {
Table, Table,
TableBody, TableBody,
@ -14,7 +14,7 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '../ui/table'; } from '@/components/ui/table';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -22,8 +22,8 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '../ui/dialog'; } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
CheckCircle, CheckCircle,
AlertCircle, AlertCircle,
@ -37,7 +37,7 @@ import {
TrendingDown, TrendingDown,
MapPin MapPin
} from 'lucide-react'; } from 'lucide-react';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Using live data from API instead of mockFnFCases // Using live data from API instead of mockFnFCases
@ -213,7 +213,7 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
<TrendingUp className="w-8 h-8 text-purple-600" /> <TrendingUp className="w-8 h-8 text-purple-600" />
</div> </div>
<p className="text-[10px] text-slate-500 mt-1"> <p className="text-[10px] text-slate-500 mt-1">
{displaySettlements.reduce((sum, s) => sum + (s.financialData.netAmount || 0), 0) < 0 ? 'Net Recovery' : 'Net Payable'} {displaySettlements.reduce((sum, s) => sum + (s.financialData.netAmount || 0), 0) < 0 ? 'Net Receivable' : 'Net Payable'}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@ -865,3 +865,4 @@ export function FinanceFnFPage({ onViewFnFDetails }: FinanceFnFPageProps = {}) {
</div> </div>
); );
} }

View File

@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { import {
ArrowLeft, ArrowLeft,
IndianRupee, IndianRupee,
@ -18,9 +18,9 @@ import {
Clock Clock
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
// Simple helper for class merging if 'cn' is not available // Simple helper for class merging if 'cn' is not available
const cn = (...classes: any[]) => classes.filter(Boolean).join(' '); const cn = (...classes: any[]) => classes.filter(Boolean).join(' ');
@ -487,3 +487,4 @@ export function FinancePaymentDetailsPage({ applicationId, onBack }: FinancePaym
</div> </div>
); );
} }

View File

@ -11,16 +11,16 @@ import {
AlertCircle, AlertCircle,
Loader2, Loader2,
} from "lucide-react"; } from "lucide-react";
import { Button } from "../ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardDescription, CardDescription,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../ui/card"; } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "../ui/badge"; import { Badge } from "@/components/ui/badge";
import { Plus, Pencil, Trash2, Building2, CreditCard, Landmark } from "lucide-react"; import { Plus, Pencil, Trash2, Building2, CreditCard, Landmark } from "lucide-react";
import { import {
Dialog, Dialog,
@ -29,8 +29,8 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog"; } from "@/components/ui/dialog";
import { Label } from "../ui/label"; import { Label } from "@/components/ui/label";
import { import {
Table, Table,
TableBody, TableBody,
@ -38,16 +38,16 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from "../ui/table"; } from "@/components/ui/table";
import { Progress } from "../ui/progress"; import { Progress } from "@/components/ui/progress";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { settlementService } from "../../services/settlement.service"; import { settlementService } from "@/services/settlement.service";
import { API } from "../../api/API"; import { API } from "@/api/API";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { DocumentPreviewModal } from "../ui/DocumentPreviewModal"; import { DocumentPreviewModal } from "@/components/ui/DocumentPreviewModal";
import { formatDateTime, formatDateOnly } from "../../lib/dateUtils"; import { formatDateTime, formatDateOnly } from "@/lib/dateUtils";
import { BankDetailsModal } from "./BankDetailsModal"; import { BankDetailsModal } from "@/features/onboarding/components/BankDetailsModal";
interface FnFDetailsProps { interface FnFDetailsProps {
fnfId: string; fnfId: string;
@ -83,7 +83,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
const [clearanceForm, setClearanceForm] = useState({ const [clearanceForm, setClearanceForm] = useState({
remarks: "", remarks: "",
amount: 0, amount: 0,
type: "Recovery", type: "Receivable",
}); });
const [clearanceFile, setClearanceFile] = useState<File | null>(null); const [clearanceFile, setClearanceFile] = useState<File | null>(null);
@ -137,6 +137,12 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
(typeof description === "string" && (typeof description === "string" &&
(description.startsWith(DEPARTMENT_CLAIM_PREFIX) || description.includes('Clearance:'))); (description.startsWith(DEPARTMENT_CLAIM_PREFIX) || description.includes('Clearance:')));
/** Backend seeds FinanceValidated mirrors on fetch; use claims when present to avoid double-counting */
const isAutoSeededDeptMirror = (li: any) =>
li?.sourceType === "FinanceValidated" &&
typeof li?.description === "string" &&
li.description.includes("Auto-seeded from department claim");
const isFinanceValidatedLine = (description?: string, sourceType?: string) => const isFinanceValidatedLine = (description?: string, sourceType?: string) =>
sourceType === "FinanceValidated" || sourceType === "FinanceValidated" ||
(typeof description === "string" && description.startsWith(FINANCE_VALIDATED_PREFIX)); (typeof description === "string" && description.startsWith(FINANCE_VALIDATED_PREFIX));
@ -218,11 +224,16 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
const c = (s.clearances || []).find( const c = (s.clearances || []).find(
(clearance: any) => normalizeDepartment(clearance.department) === deptName, (clearance: any) => normalizeDepartment(clearance.department) === deptName,
); );
const relatedItems = allLineItems.filter( const claimLines = allLineItems.filter(
(li: any) => (li: any) =>
normalizeDepartment(li.department) === deptName && normalizeDepartment(li.department) === deptName &&
isDepartmentClaimLine(li.description, li.sourceType), isDepartmentClaimLine(li.description, li.sourceType),
); );
const seededMirrorLines = allLineItems.filter(
(li: any) =>
normalizeDepartment(li.department) === deptName && isAutoSeededDeptMirror(li),
);
const relatedItems = claimLines.length > 0 ? claimLines : seededMirrorLines;
// Calculate departmental net // Calculate departmental net
let deptPayables = 0; let deptPayables = 0;
@ -230,7 +241,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
relatedItems.forEach((li: any) => { relatedItems.forEach((li: any) => {
const amt = Math.abs(parseFloat(li.amount) || 0); const amt = Math.abs(parseFloat(li.amount) || 0);
if (li.itemType === 'Payable') deptPayables += amt; if (li.itemType === 'Payable') deptPayables += amt;
else deptRecoveries += amt; // Receivables & Deductions else deptRecoveries += amt; // Receivable, Recovery, Deduction
}); });
const netAmount = deptPayables - deptRecoveries; const netAmount = deptPayables - deptRecoveries;
@ -240,12 +251,18 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
? "Dues Pending" ? "Dues Pending"
: (rawStatus === "Cleared" ? "NOC Submitted" : rawStatus); : (rawStatus === "Cleared" ? "NOC Submitted" : rawStatus);
const duesFlow =
netAmount > 0 ? ("payable" as const)
: netAmount < 0 ? ("recovery" as const)
: null;
return { return {
id: c?.id || `dept-${deptName}`, id: c?.id || `dept-${deptName}`,
clearanceId: c?.id || null, clearanceId: c?.id || null,
departmentName: deptName, departmentName: deptName,
status: normalizedStatus, status: normalizedStatus,
amountType: netAmount > 0 ? "Payable" : netAmount < 0 ? "Recovery" : null, duesFlow,
amountType: netAmount > 0 ? "Payable to dealer" : netAmount < 0 ? "Receivable from dealer" : null,
amount: Math.abs(netAmount), amount: Math.abs(netAmount),
submittedDate: c?.clearedAt ? formatDateTime(c.clearedAt) : "-", submittedDate: c?.clearedAt ? formatDateTime(c.clearedAt) : "-",
remarks: c?.remarks || "-", remarks: c?.remarks || "-",
@ -514,7 +531,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
.reduce((sum: number, li: any) => sum + Math.abs(parseFloat(li.amount) || 0), 0); .reduce((sum: number, li: any) => sum + Math.abs(parseFloat(li.amount) || 0), 0);
const validatedNet = payable - receivable - deduction; const validatedNet = payable - receivable - deduction;
const validatedAmount = Math.abs(validatedNet); const validatedAmount = Math.abs(validatedNet);
const validatedType = validatedNet > 0 ? 'Payable' : validatedNet < 0 ? 'Recovery' : '-'; const validatedType = validatedNet > 0 ? 'Payable' : validatedNet < 0 ? 'Receivable' : '-';
return { return {
department: deptName, department: deptName,
claimAmount, claimAmount,
@ -548,7 +565,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<div className="text-right"> <div className="text-right">
<Badge className="bg-white/20 hover:bg-white/30 text-white border-none px-4 py-1 mb-2"> <Badge className="bg-white/20 hover:bg-white/30 text-white border-none px-4 py-1 mb-2">
{(fnfCase.totalRecoveryAmount || 0) > (fnfCase.totalPayableAmount || 0) {(fnfCase.totalRecoveryAmount || 0) > (fnfCase.totalPayableAmount || 0)
? "Recovery from Dealer" ? "Receivable from Dealer"
: "Payable to Dealer"} : "Payable to Dealer"}
</Badge> </Badge>
<p className="text-xs text-white/70"> <p className="text-xs text-white/70">
@ -686,9 +703,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
{/* Progress Steps */} {/* Progress Steps */}
<div className="space-y-8"> <div className="space-y-8">
{/* Step 1: F&F Initiated */} {/* Step 1: F&F Initiated */}
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<div className="flex flex-col items-center"> <div className="flex shrink-0 flex-col items-center">
<div className="w-12 h-12 rounded-full bg-green-100 border-2 border-green-600 flex items-center justify-center"> <div className="size-12 shrink-0 aspect-square rounded-full bg-green-100 border-2 border-green-600 flex items-center justify-center">
<Check className="w-6 h-6 text-green-600" /> <Check className="w-6 h-6 text-green-600" />
</div> </div>
<div className="w-0.5 h-full bg-green-300 mt-2"></div> <div className="w-0.5 h-full bg-green-300 mt-2"></div>
@ -741,10 +758,10 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
{/* Step 2: Department Responses Received */} {/* Step 2: Department Responses Received */}
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<div className="flex flex-col items-center"> <div className="flex shrink-0 flex-col items-center">
<div <div
className={`w-12 h-12 rounded-full flex items-center justify-center border-2 ${ className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${
responsesReceived === totalDepartments || responsesReceived === totalDepartments ||
["Finance Approval", "Completed"].includes( ["Finance Approval", "Completed"].includes(
fnfCase.status, fnfCase.status,
@ -881,10 +898,10 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
{/* Step 3: Finance Final Summary */} {/* Step 3: Finance Final Summary */}
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<div className="flex flex-col items-center"> <div className="flex shrink-0 flex-col items-center">
<div <div
className={`w-12 h-12 rounded-full flex items-center justify-center border-2 ${ className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${
fnfCase.status === "Completed" fnfCase.status === "Completed"
? "bg-green-100 border-green-600" ? "bg-green-100 border-green-600"
: fnfCase.status === "Finance Approval" : fnfCase.status === "Finance Approval"
@ -960,7 +977,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
<div className="text-center p-3 bg-red-100 rounded-lg"> <div className="text-center p-3 bg-red-100 rounded-lg">
<p className="text-xs text-red-700 mb-1"> <p className="text-xs text-red-700 mb-1">
Recovery Amount Receivable amount
</p> </p>
<p className="text-red-900"> <p className="text-red-900">
@ -995,10 +1012,10 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
{/* Step 4: Financial Discussion with Dealer */} {/* Step 4: Financial Discussion with Dealer */}
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<div className="flex flex-col items-center"> <div className="flex shrink-0 flex-col items-center">
<div <div
className={`w-12 h-12 rounded-full flex items-center justify-center border-2 ${ className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${
fnfCase.status === "Completed" fnfCase.status === "Completed"
? "bg-green-100 border-green-600" ? "bg-green-100 border-green-600"
: fnfCase.status === "Finance Approval" : fnfCase.status === "Finance Approval"
@ -1074,10 +1091,10 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
{/* Step 5: Full and Final Settlement */} {/* Step 5: Full and Final Settlement */}
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<div className="flex flex-col items-center"> <div className="flex shrink-0 flex-col items-center">
<div <div
className={`w-12 h-12 rounded-full flex items-center justify-center border-2 ${ className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${
fnfCase.status === "Completed" fnfCase.status === "Completed"
? "bg-green-100 border-green-600" ? "bg-green-100 border-green-600"
: "bg-slate-100 border-slate-300" : "bg-slate-100 border-slate-300"
@ -1125,10 +1142,10 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
{/* Step 6: F&F Complete */} {/* Step 6: F&F Complete */}
<div className="flex gap-4"> <div className="flex gap-4 items-start">
<div className="flex flex-col items-center"> <div className="flex shrink-0 flex-col items-center">
<div <div
className={`w-12 h-12 rounded-full flex items-center justify-center border-2 ${ className={`size-12 shrink-0 aspect-square rounded-full flex items-center justify-center border-2 ${
fnfCase.status === "Completed" fnfCase.status === "Completed"
? "bg-green-100 border-green-600" ? "bg-green-100 border-green-600"
: "bg-slate-100 border-slate-300" : "bg-slate-100 border-slate-300"
@ -1171,7 +1188,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<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-blue-50 border-green-300">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-green-600 flex items-center justify-center"> <div className="size-12 shrink-0 aspect-square rounded-full bg-green-600 flex items-center justify-center">
<CheckCircle2 className="w-7 h-7 text-white" /> <CheckCircle2 className="w-7 h-7 text-white" />
</div> </div>
<div> <div>
@ -1375,9 +1392,9 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<Badge <Badge
variant="outline" variant="outline"
className={ className={
dept.amountType === "Recovery" dept.duesFlow === "recovery"
? "bg-red-50 text-red-700 border-red-200" ? "bg-red-100 text-red-900 border-red-400 font-semibold"
: "bg-green-50 text-green-700 border-green-200" : "bg-emerald-100 text-emerald-900 border-emerald-400 font-semibold"
} }
> >
{dept.amountType} {dept.amountType}
@ -1389,7 +1406,11 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<TableCell> <TableCell>
{dept.amount ? ( {dept.amount ? (
<span <span
className={`${dept.amountType === "Recovery" ? "text-red-600 font-bold" : "text-green-600 font-bold"}`} className={`font-semibold tabular-nums ${
dept.duesFlow === "recovery"
? "text-red-700"
: "text-emerald-700"
}`}
> >
{dept.amount.toLocaleString()} {dept.amount.toLocaleString()}
</span> </span>
@ -1413,7 +1434,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
setClearanceForm({ setClearanceForm({
remarks: dept.remarks === "-" ? "" : dept.remarks, remarks: dept.remarks === "-" ? "" : dept.remarks,
amount: dept.amount || 0, amount: dept.amount || 0,
type: dept.amountType || "Recovery", type: dept.duesFlow === "payable" ? "Payable" : "Receivable",
}); });
setClearanceFile(null); setClearanceFile(null);
setShowClearanceDialog(true); setShowClearanceDialog(true);
@ -1474,7 +1495,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
<CardHeader> <CardHeader>
<CardTitle>Financial Summary</CardTitle> <CardTitle>Financial Summary</CardTitle>
<CardDescription> <CardDescription>
Consolidated view of all payable and recovery amounts Consolidated view of all payable and receivable amounts
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1492,13 +1513,13 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</div> </div>
<div className="p-6 bg-red-50 rounded-lg border border-red-200"> <div className="p-6 bg-red-50 rounded-lg border border-red-200">
<p className="text-sm text-red-700 mb-2"> <p className="text-sm text-red-700 mb-2">
Total Recovery Amount Total receivable amount
</p> </p>
<p className="text-3xl text-red-600"> <p className="text-3xl text-red-600">
{fnfCase.totalRecoveryAmount?.toLocaleString() || "0"} {fnfCase.totalRecoveryAmount?.toLocaleString() || "0"}
</p> </p>
<p className="text-xs text-red-600 mt-1"> <p className="text-xs text-red-600 mt-1">
Amount to be recovered from dealer Amount receivable from dealer
</p> </p>
</div> </div>
<div className="p-6 bg-amber-50 rounded-lg border border-amber-200"> <div className="p-6 bg-amber-50 rounded-lg border border-amber-200">
@ -1525,7 +1546,7 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
</p> </p>
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
{(fnfCase.netAmount || 0) < 0 {(fnfCase.netAmount || 0) < 0
? "Recovery from dealer" ? "Receivable from dealer"
: "Payment to dealer"} : "Payment to dealer"}
</p> </p>
</div> </div>
@ -1888,8 +1909,8 @@ export function FnFDetails({ fnfId, onBack, currentUser }: FnFDetailsProps) {
value={clearanceForm.type} value={clearanceForm.type}
onChange={(e) => setClearanceForm({ ...clearanceForm, type: e.target.value })} onChange={(e) => setClearanceForm({ ...clearanceForm, type: e.target.value })}
> >
<option value="Recovery">Recovery (from Dealer)</option> <option value="Receivable">Receivable (from dealer)</option>
<option value="Payable">Payable (to Dealer)</option> <option value="Payable">Payable (to dealer)</option>
<option value="Deduction">Deduction</option> <option value="Deduction">Deduction</option>
</select> </select>
</div> </div>

View File

@ -1,13 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { IndianRupee, Calendar, Eye, Send, FileCheck, Loader2 } from 'lucide-react'; import { IndianRupee, Calendar, Eye, Send, FileCheck, Loader2 } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { User } from '../../lib/mock-data'; import { User } from '@/lib/mock-data';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface FnFPageProps { interface FnFPageProps {
currentUser: User | null; currentUser: User | null;
@ -445,7 +445,7 @@ export function FnFPage({ currentUser, onViewDetails }: FnFPageProps) {
<p>{fnfCase.dealerName}</p> <p>{fnfCase.dealerName}</p>
</div> </div>
<div> <div>
<p className="text-slate-600">Recovery Amount</p> <p className="text-slate-600">Receivable amount</p>
<p className="text-red-600">{fnfCase.totalRecoveryAmount?.toLocaleString()}</p> <p className="text-red-600">{fnfCase.totalRecoveryAmount?.toLocaleString()}</p>
</div> </div>
<div> <div>

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '../../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface ASMDialogProps { interface ASMDialogProps {
isOpen: boolean; isOpen: boolean;

View File

@ -1,11 +1,11 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { UserCog, Plus, Edit2, Trash2, Users } from 'lucide-react'; import { UserCog, Plus, Edit2, Trash2, Users } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface ASMManagementProps { interface ASMManagementProps {
selectedZone: string; selectedZone: string;

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
interface AddRoleDialogProps { interface AddRoleDialogProps {
isOpen: boolean; isOpen: boolean;

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '../../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface DDLeadDialogProps { interface DDLeadDialogProps {
isOpen: boolean; isOpen: boolean;

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Users, Plus, Edit2, Trash2, MapPin } from 'lucide-react'; import { Users, Plus, Edit2, Trash2, MapPin } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface DDLeadManagementProps { interface DDLeadManagementProps {
selectedZone: string; selectedZone: string;
@ -115,3 +115,4 @@ export const DDLeadManagement: React.FC<DDLeadManagementProps> = ({
</Card> </Card>
); );
}; };

View File

@ -1,15 +1,15 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '../../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Plus, Edit2, Trash2, ShieldCheck, Layers, Settings2, Search, ChevronLeft, ChevronRight, Loader2, Database } from 'lucide-react'; import { Plus, Edit2, Trash2, ShieldCheck, Layers, Settings2, Search, ChevronLeft, ChevronRight, Loader2, Database } from 'lucide-react';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const DocumentConfigManagement: React.FC = () => { export const DocumentConfigManagement: React.FC = () => {
@ -495,3 +495,4 @@ export const DocumentConfigManagement: React.FC = () => {
</Card> </Card>
); );
}; };

View File

@ -0,0 +1,144 @@
import * as React from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Info } from 'lucide-react';
import { cn } from '@/components/ui/utils';
const quillModules = {
toolbar: [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link'],
['blockquote'],
['clean'],
],
};
const quillFormats = ['header', 'bold', 'italic', 'underline', 'list', 'bullet', 'link', 'blockquote'];
/** Handlebars helpers/partials or full HTML docs must be edited as source — rich editors strip or corrupt them. */
export function requiresAdvancedHtmlEditing(html: string): boolean {
const s = html || '';
return (
/\{\{\s*[>#\/]/.test(s) ||
/\{\{>\s*\w+/.test(s) ||
/<!DOCTYPE/i.test(s) ||
/<html[\s>]/i.test(s) ||
/<\/html>/i.test(s) ||
/<style\b/i.test(s) ||
/<head[\s>]/i.test(s)
);
}
export type EmailTemplateBodyEditorHandle = {
insertPlaceholder: (placeholder: string) => void;
};
type Props = {
value: string;
onChange: (html: string) => void;
/** visual | html */
tab: string;
onTabChange: (v: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
};
export const EmailTemplateBodyEditor = React.forwardRef<EmailTemplateBodyEditorHandle, Props>(
({ value, onChange, tab, onTabChange, textareaRef }, ref) => {
const quillRef = React.useRef<ReactQuill>(null);
const advanced = React.useMemo(() => requiresAdvancedHtmlEditing(value), [value]);
React.useImperativeHandle(ref, () => ({
insertPlaceholder: (placeholder: string) => {
const token = `{{${placeholder}}}`;
if (tab === 'html' && textareaRef.current) {
const el = textareaRef.current;
const start = el.selectionStart ?? 0;
const end = el.selectionEnd ?? 0;
const next = value.slice(0, start) + token + value.slice(end);
onChange(next);
requestAnimationFrame(() => {
el.focus();
const pos = start + token.length;
el.setSelectionRange(pos, pos);
});
return;
}
const editor = quillRef.current?.getEditor?.();
if (!editor) return;
const range = editor.getSelection(true);
const idx = range?.index ?? editor.getLength();
editor.insertText(idx, token, 'user');
},
}));
return (
<div className="flex flex-col gap-2 flex-1 min-h-0">
<Tabs value={tab} onValueChange={onTabChange} className="flex flex-col flex-1 gap-2 min-h-0">
<TabsList className="w-fit">
<TabsTrigger value="visual" disabled={advanced} className="text-xs">
Rich text
</TabsTrigger>
<TabsTrigger value="html" className="text-xs font-mono">
HTML source
</TabsTrigger>
</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">
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.
</AlertDescription>
</Alert>
)}
<TabsContent value="visual" className="flex-1 flex flex-col gap-2 mt-2 min-h-[420px] data-[state=inactive]:hidden">
<div
className={cn(
'email-quill rounded-md border border-slate-200 bg-white overflow-hidden flex flex-col flex-1',
'[&_.ql-toolbar]:border-slate-200 [&_.ql-toolbar]:bg-slate-50',
'[&_.ql-container]:border-0 [&_.ql-editor]:min-h-[380px] [&_.ql-editor]:text-sm [&_.ql-editor]:text-slate-900'
)}
>
<ReactQuill
ref={quillRef}
theme="snow"
value={value || ''}
onChange={onChange}
modules={quillModules}
formats={quillFormats}
/>
</div>
<p className="text-[10px] text-slate-500">
Use headings, lists, and links. Insert dynamic fields from <strong>Available Placeholders</strong> on the left.
</p>
</TabsContent>
<TabsContent value="html" className="flex-1 flex flex-col mt-2 min-h-[420px] data-[state=inactive]:hidden">
<Textarea
ref={textareaRef}
placeholder="Raw HTML / Handlebars — use for partials {{> ...}} or full layouts"
className="flex-1 min-h-[420px] font-mono text-xs text-slate-900 bg-slate-900/5 focus:bg-white p-4 resize-y"
value={value || ''}
onChange={(e) => onChange(e.target.value)}
spellCheck={false}
/>
<p className="text-[10px] text-slate-500 mt-1">
Full control for <code className="font-mono">{'{{> email_header}}'}</code>, styles, and edge cases. Backend still
sanitizes on save.
</p>
</TabsContent>
</Tabs>
</div>
);
}
);
EmailTemplateBodyEditor.displayName = 'EmailTemplateBodyEditor';

View File

@ -1,36 +1,32 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Mail, Plus, Edit2, Trash2, Calendar } from 'lucide-react'; import { Mail, Edit2, Trash2, Calendar } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
import { formatDateTime } from '../../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface EmailTemplatesProps { interface EmailTemplatesProps {
onAddTemplate: () => void;
onEditTemplate: (template: any) => void; onEditTemplate: (template: any) => void;
onDeleteTemplate: (id: string) => void; onDeleteTemplate: (id: string) => void;
} }
export const EmailTemplates: React.FC<EmailTemplatesProps> = ({ export const EmailTemplates: React.FC<EmailTemplatesProps> = ({
onAddTemplate, onEditTemplate, onDeleteTemplate onEditTemplate,
onDeleteTemplate
}) => { }) => {
const { emailTemplates } = useSelector((state: RootState) => state.master); const { emailTemplates } = useSelector((state: RootState) => state.master);
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div>
<div> <CardTitle>Email & Letter Templates</CardTitle>
<CardTitle>Email & Letter Templates</CardTitle> <CardDescription>
<CardDescription>Manage automated transactional email and onboarding letter templates</CardDescription> Templates and trigger codes come from system seed data. Edit wording and layout here; new triggers are not added from this screen.
</div> </CardDescription>
<Button onClick={onAddTemplate} className="bg-amber-600 hover:bg-amber-700">
<Plus className="w-4 h-4 mr-2" />
Add Template
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -95,3 +91,4 @@ export const EmailTemplates: React.FC<EmailTemplatesProps> = ({
</Card> </Card>
); );
}; };

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface LocationDialogProps { interface LocationDialogProps {
isOpen: boolean; isOpen: boolean;
@ -139,3 +139,4 @@ export const LocationDialog: React.FC<LocationDialogProps> = ({
</Dialog> </Dialog>
); );
}; };

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { MapPin, Plus, Edit2, Trash2, Globe } from 'lucide-react'; import { MapPin, Plus, Edit2, Trash2, Globe } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
import { formatDateTime } from '../../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface LocationManagementProps { interface LocationManagementProps {
onAddLocation: () => void; onAddLocation: () => void;
@ -204,3 +204,4 @@ export const LocationManagement: React.FC<LocationManagementProps> = ({
</div> </div>
); );
}; };

View File

@ -1,14 +1,14 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '../../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Checkbox } from '../../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface RegionDialogProps { interface RegionDialogProps {
isOpen: boolean; isOpen: boolean;
@ -309,3 +309,4 @@ export const RegionDialog: React.FC<RegionDialogProps> = ({
</Dialog> </Dialog>
); );
}; };

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Building2, Plus, Edit2, Trash2 } from 'lucide-react'; import { Building2, Plus, Edit2, Trash2 } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface RegionalManagementProps { interface RegionalManagementProps {
selectedZone: string; selectedZone: string;
@ -127,3 +127,4 @@ export const RegionalManagement: React.FC<RegionalManagementProps> = ({
</Card> </Card>
); );
}; };

View File

@ -5,10 +5,10 @@ import {
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../../ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "../../ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "../../ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "../../ui/badge"; import { Badge } from "@/components/ui/badge";
import { Shield, Save } from "lucide-react"; import { Shield, Save } from "lucide-react";
interface RoleDialogProps { interface RoleDialogProps {

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Shield, Plus, Pen } from 'lucide-react'; import { Shield, Plus, Pen } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface RolePermissionsProps { interface RolePermissionsProps {
onAddRole: () => void; onAddRole: () => void;
@ -75,3 +75,4 @@ export const RolePermissions: React.FC<RolePermissionsProps> = ({ onAddRole, onE
</Card> </Card>
); );
}; };

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Clock, Plus, Edit2, AlertTriangle, Bell, Save } from 'lucide-react'; import { Clock, Plus, Edit2, AlertTriangle, Bell, Save } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface SLAConfigurationProps { interface SLAConfigurationProps {
onConfigureSLA: (sla: any) => void; onConfigureSLA: (sla: any) => void;
@ -89,3 +89,4 @@ export const SLAConfiguration: React.FC<SLAConfigurationProps> = ({ onConfigureS
</Card> </Card>
); );
}; };

View File

@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Save, RefreshCw, IndianRupee, Settings } from 'lucide-react'; import { Save, RefreshCw, IndianRupee, Settings } from 'lucide-react';
import { masterService } from '../../../services/master.service'; import { masterService } from '@/services/master.service';
import { toast } from 'sonner'; import { toast } from 'sonner';
export const SecurityDepositMaster: React.FC = () => { export const SecurityDepositMaster: React.FC = () => {
@ -154,3 +154,5 @@ export const SecurityDepositMaster: React.FC = () => {
</div> </div>
); );
}; };

View File

@ -1,12 +1,21 @@
import React from 'react'; import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '../../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Switch } from '../../ui/switch'; import { Switch } from '@/components/ui/switch';
import { Loader2, Play, Info, Copy, CheckCircle2, Settings, Edit2 } from 'lucide-react'; import {
import { Badge } from '../../ui/badge'; EmailTemplateBodyEditor,
requiresAdvancedHtmlEditing,
type EmailTemplateBodyEditorHandle,
} from '@/features/master/components/EmailTemplateBodyEditor';
import { Loader2, Play, Info, CheckCircle2, Settings, Edit2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
composeEmailLayoutShell,
parseEmailLayoutShell,
} from '@/features/master/utils/emailTemplateLayoutShell';
interface TemplateDialogProps { interface TemplateDialogProps {
isOpen: boolean; isOpen: boolean;
@ -16,46 +25,73 @@ interface TemplateDialogProps {
testDataInput: string; testDataInput: string;
setTestDataInput: (data: string) => void; setTestDataInput: (data: string) => void;
previewLoading: boolean; previewLoading: boolean;
handlePreviewTemplate: () => void; handlePreviewTemplate: (body: string) => void;
previewContent: any; previewContent: any;
handleSaveTemplate: () => void; handleSaveTemplate: (body: string) => void;
} }
export const TemplateDialog: React.FC<TemplateDialogProps> = ({ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
isOpen, onOpenChange, editingTemplate, setEditingTemplate, isOpen,
testDataInput, setTestDataInput, previewLoading, handlePreviewTemplate, onOpenChange,
previewContent, handleSaveTemplate editingTemplate,
setEditingTemplate,
testDataInput,
setTestDataInput,
previewLoading,
handlePreviewTemplate,
previewContent,
handleSaveTemplate,
}) => { }) => {
const textareaRef = React.useRef<HTMLTextAreaElement>(null); const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const bodyEditorRef = React.useRef<EmailTemplateBodyEditorHandle>(null);
const [bodyTab, setBodyTab] = React.useState<'visual' | 'html'>('visual');
const [editableBody, setEditableBody] = React.useState('');
const [shellMode, setShellMode] = React.useState(false);
const [shellIncludesCta, setShellIncludesCta] = React.useState(false);
React.useEffect(() => {
if (!isOpen || !editingTemplate) return;
const parsed = parseEmailLayoutShell(editingTemplate.body || '');
setShellMode(parsed.isShellTemplate);
setShellIncludesCta(parsed.includesCta);
const nextEditable = parsed.isShellTemplate ? parsed.editableHtml : editingTemplate.body || '';
setEditableBody(nextEditable);
const preferHtml = requiresAdvancedHtmlEditing(nextEditable);
setBodyTab(preferHtml ? 'html' : 'visual');
}, [isOpen, editingTemplate?.id, editingTemplate?.body]);
const composeFullBody = React.useCallback(() => {
if (!shellMode) return editingTemplate?.body || '';
return composeEmailLayoutShell(editableBody, shellIncludesCta);
}, [shellMode, editableBody, shellIncludesCta, editingTemplate?.body]);
const insertPlaceholder = (placeholder: string) => { const insertPlaceholder = (placeholder: string) => {
if (!textareaRef.current) return; bodyEditorRef.current?.insertPlaceholder(placeholder);
const { selectionStart, selectionEnd } = textareaRef.current;
const text = editingTemplate?.body || '';
const newText = text.substring(0, selectionStart) + `{{${placeholder}}}` + text.substring(selectionEnd);
setEditingTemplate({ ...editingTemplate!, body: newText });
// Set focus back and move cursor
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
textareaRef.current.setSelectionRange(selectionStart + placeholder.length + 4, selectionStart + placeholder.length + 4);
}
}, 0);
}; };
const placeholders = editingTemplate?.placeholders || []; const placeholders = editingTemplate?.placeholders || [];
const editorValue = shellMode ? editableBody : editingTemplate?.body || '';
const onEditorChange = (html: string) => {
if (shellMode) {
setEditableBody(html);
if (requiresAdvancedHtmlEditing(html)) setBodyTab('html');
} else {
setEditingTemplate({ ...editingTemplate!, body: html });
if (requiresAdvancedHtmlEditing(html)) setBodyTab('html');
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-7xl w-full max-h-[95vh] overflow-y-auto"> <DialogContent className="sm:max-w-7xl w-full max-h-[95vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>{editingTemplate?.id ? 'Edit Email Template' : 'Add Email Template'}</DialogTitle> <DialogTitle>Edit Email Template</DialogTitle>
<DialogDescription>Configure automated email template with dynamic content</DialogDescription> <DialogDescription>Trigger codes are fixed by system seeding. Edit subject and message content below.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Left Column: Template Configuration */}
<div className="lg:col-span-4 space-y-6"> <div className="lg:col-span-4 space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200 space-y-4"> <div className="bg-slate-50 p-4 rounded-lg border border-slate-200 space-y-4">
@ -74,12 +110,14 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</div> </div>
<div> <div>
<Label className="text-xs uppercase tracking-wider text-slate-500">Trigger Code</Label> <Label className="text-xs uppercase tracking-wider text-slate-500">Trigger Code</Label>
<Input <p className="text-[10px] text-slate-500 mt-1 mb-1">
placeholder="e.g., APPLICATION_RECEIVED" Defined when the template row is seeded; it cannot be changed here.
className="mt-1.5 bg-white font-mono text-xs" </p>
value={editingTemplate?.templateCode || ''} <div className="mt-1.5 flex items-center">
onChange={(e) => setEditingTemplate({ ...editingTemplate!, templateCode: e.target.value })} <Badge variant="outline" className="bg-white font-mono text-xs px-3 py-1.5">
/> {editingTemplate?.templateCode || '—'}
</Badge>
</div>
</div> </div>
<div> <div>
<Label className="text-xs uppercase tracking-wider text-slate-500">Description</Label> <Label className="text-xs uppercase tracking-wider text-slate-500">Description</Label>
@ -97,35 +135,43 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
checked={editingTemplate?.isActive ?? true} checked={editingTemplate?.isActive ?? true}
onCheckedChange={(checked) => setEditingTemplate({ ...editingTemplate!, isActive: checked })} onCheckedChange={(checked) => setEditingTemplate({ ...editingTemplate!, isActive: checked })}
/> />
<Label htmlFor="active" className="text-sm cursor-pointer">Active</Label> <Label htmlFor="active" className="text-sm cursor-pointer">
Active
</Label>
</div> </div>
<Badge className={editingTemplate?.isActive ? "bg-green-100 text-green-700" : "bg-slate-100 text-slate-500"}> <Badge
className={
editingTemplate?.isActive ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'
}
>
{editingTemplate?.isActive ? 'Template Enabled' : 'Template Disabled'} {editingTemplate?.isActive ? 'Template Enabled' : 'Template Disabled'}
</Badge> </Badge>
</div> </div>
</div> </div>
{/* Placeholders Library */}
<div className="bg-amber-50 p-4 rounded-lg border border-amber-100"> <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"> <h3 className="text-sm font-semibold text-amber-900 flex items-center gap-2 mb-3">
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
Available Placeholders Available Placeholders
</h3> </h3>
<p className="text-[10px] text-amber-700 mb-4 leading-relaxed"> <p className="text-[10px] text-amber-700 mb-4 leading-relaxed">
Click on a placeholder below to insert it at your current cursor position in the editor. Click a placeholder to insert it at the cursor.
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{placeholders.length > 0 ? placeholders.map((p: string) => ( {placeholders.length > 0 ? (
<button placeholders.map((p: string) => (
key={p} <button
onClick={() => insertPlaceholder(p)} key={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" type="button"
> onClick={() => insertPlaceholder(p)}
{`{{${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"
</button> >
)) : ( {`{{${p}}}`}
</button>
))
) : (
<div className="w-full py-4 text-center border-2 border-dashed border-amber-200 rounded-lg"> <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> <p className="text-[10px] text-amber-600">No placeholders defined for this trigger</p>
</div> </div>
)} )}
</div> </div>
@ -133,7 +179,6 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</div> </div>
</div> </div>
{/* Middle Column: Editor */}
<div className="lg:col-span-4 space-y-4 flex flex-col h-full min-h-[600px]"> <div className="lg:col-span-4 space-y-4 flex flex-col h-full min-h-[600px]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold text-slate-900 flex items-center gap-2"> <h3 className="font-semibold text-slate-900 flex items-center gap-2">
@ -153,25 +198,20 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
/> />
</div> </div>
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col min-h-[480px]">
<Label className="text-xs font-semibold mb-1.5">Email Body (HTML/Handlebars)</Label> <Label className="text-xs font-semibold mb-1.5">Email Body</Label>
<div className="relative flex-1"> <EmailTemplateBodyEditor
<Textarea ref={bodyEditorRef}
ref={textareaRef} tab={bodyTab}
placeholder="Hello {{applicant_name}}, ..." onTabChange={(v) => setBodyTab(v as 'visual' | 'html')}
className="h-full min-h-[450px] font-mono text-sm text-slate-900 bg-slate-900/5 focus:bg-white transition-colors p-4 resize-none" textareaRef={textareaRef}
value={editingTemplate?.body || ''} value={editorValue}
onChange={(e) => setEditingTemplate({ ...editingTemplate!, body: e.target.value })} onChange={onEditorChange}
/> />
<div className="absolute bottom-3 right-3 opacity-30 pointer-events-none text-[10px] font-mono">
HTML Support Enabled
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* Right Column: Preview & Simulation */}
<div className="lg:col-span-4 space-y-6 lg:border-l lg:pl-8 flex flex-col h-full"> <div className="lg:col-span-4 space-y-6 lg:border-l lg:pl-8 flex flex-col h-full">
<div> <div>
<h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4"> <h3 className="font-semibold text-slate-900 flex items-center gap-2 mb-4">
@ -184,7 +224,12 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<Label className="text-xs font-semibold">Mock Test Data (JSON)</Label> <Label className="text-xs font-semibold">Mock Test Data (JSON)</Label>
<button <button
onClick={() => setTestDataInput('{"applicantName": "Rajesh Kumar", "location": "Mumbai South", "applicationId": "APP-2026-X12"}')} type="button"
onClick={() =>
setTestDataInput(
'{"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-amber-600 hover:underline"
> >
Reset to Sample Reset to Sample
@ -201,11 +246,16 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<Button <Button
variant="default" variant="default"
type="button"
className="w-full bg-green-600 hover:bg-green-700 shadow-md" className="w-full bg-green-600 hover:bg-green-700 shadow-md"
onClick={handlePreviewTemplate} onClick={() => handlePreviewTemplate(composeFullBody())}
disabled={previewLoading} disabled={previewLoading}
> >
{previewLoading ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <Play className="w-4 h-4 mr-2" />} {previewLoading ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Play className="w-4 h-4 mr-2" />
)}
Generate Mock Preview Generate Mock Preview
</Button> </Button>
</div> </div>
@ -225,8 +275,10 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</Badge> </Badge>
</div> </div>
<div className="p-6 bg-white overflow-auto flex-1 shadow-sm"> <div className="p-6 bg-white overflow-auto flex-1 shadow-sm">
<div className="max-w-none text-sm text-slate-800 email-preview-container" <div
dangerouslySetInnerHTML={{ __html: previewContent.html }} /> className="max-w-none text-sm text-slate-800 email-preview-container"
dangerouslySetInnerHTML={{ __html: previewContent.html }}
/>
</div> </div>
</> </>
) : ( ) : (
@ -236,7 +288,7 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
</div> </div>
<div> <div>
<p className="text-sm font-medium text-slate-500">Ready for Preview</p> <p className="text-sm font-medium text-slate-500">Ready for Preview</p>
<p className="text-[11px]">Click "Generate Mock Preview" to see how your placeholders resolve with the test data.</p> <p className="text-[11px]">Click &quot;Generate Mock Preview&quot; to see the rendered email.</p>
</div> </div>
</div> </div>
)} )}
@ -249,7 +301,12 @@ export const TemplateDialog: React.FC<TemplateDialogProps> = ({
<Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}> <Button variant="outline" className="flex-1" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
<Button className="flex-1 bg-amber-600 hover:bg-amber-700" onClick={handleSaveTemplate}> <Button
className="flex-1 bg-amber-600 hover:bg-amber-700"
type="button"
onClick={() => handleSaveTemplate(composeFullBody())}
disabled={!editingTemplate?.id || !editingTemplate?.templateCode?.trim()}
>
Save Template Save Template
</Button> </Button>
</div> </div>

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Shield, User, Mail } from 'lucide-react'; import { Shield, User, Mail } from 'lucide-react';
interface UserManagementTableProps { interface UserManagementTableProps {
@ -69,3 +69,4 @@ export const UserManagementTable: React.FC<UserManagementTableProps> = ({ userAs
</Card> </Card>
); );
}; };

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '../../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface ZMDialogProps { interface ZMDialogProps {
isOpen: boolean; isOpen: boolean;
@ -153,3 +153,4 @@ export const ZMDialog: React.FC<ZMDialogProps> = ({
</Dialog> </Dialog>
); );
}; };

View File

@ -1,11 +1,11 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Users, Plus, Edit2, Trash2 } from 'lucide-react'; import { Users, Plus, Edit2, Trash2 } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface ZMManagementProps { interface ZMManagementProps {
selectedZone: string; selectedZone: string;

View File

@ -1,12 +1,12 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '../../ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Globe, Plus, Edit2, Mail, Users, Shield } from 'lucide-react'; import { Globe, Plus, Edit2, Mail, Users, Shield } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface ZoneDetailsProps { interface ZoneDetailsProps {
selectedZone: string; selectedZone: string;
@ -160,3 +160,4 @@ export const ZoneDetails: React.FC<ZoneDetailsProps> = ({ selectedZone, onAddZon
</Card> </Card>
); );
}; };

View File

@ -1,10 +1,10 @@
import React from 'react'; import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '../../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface ZoneDialogProps { interface ZoneDialogProps {
isOpen: boolean; isOpen: boolean;

View File

@ -1,9 +1,9 @@
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Globe } from 'lucide-react'; import { Globe } from 'lucide-react';
import { RootState } from '../../../store'; import { RootState } from '@/store';
interface ZonesOverviewProps { interface ZonesOverviewProps {
selectedZone: string; selectedZone: string;

View File

@ -0,0 +1,37 @@
/**
* Allowed email template triggers keep aligned with backend
* Dealer_Onboarding_Backend/src/constants/allowed-email-template-codes.ts and seed-master-emails.
*/
export const ALLOWED_EMAIL_TEMPLATE_CODES = [
'APPLICANT_SHORTLISTED',
'CONSTITUTIONAL_CHANGE_SUBMITTED',
'CONSTITUTIONAL_CHANGE_UPDATE',
'DEALER_CODE_READY',
'GENERIC_NOTIFICATION',
'INTERVIEW_SCHEDULED',
'LOA_ISSUED',
'LOI_ISSUED',
'NON_OPPORTUNITY',
'ONBOARDING_STATUS_UPDATE',
'OPPORTUNITY',
'QUESTIONNAIRE_REMINDER',
'QUESTIONNAIRE_SUBMITTED',
'RELOCATION_RECEIVED',
'RELOCATION_SUBMITTED',
'RELOCATION_UPDATE',
'RESIGNATION_APPROVED',
'RESIGNATION_RECEIVED',
'RESIGNATION_SUBMITTED',
'RESIGNATION_UPDATE',
'SLA_BREACH_WARNING',
'TERMINATION_SCN_ISSUED',
'TERMINATION_UPDATE',
'USER_ASSIGNED',
'WORKNOTE_NOTIFICATION'
] as const;
const ALLOWED_SET = new Set<string>(ALLOWED_EMAIL_TEMPLATE_CODES);
export function isAllowedEmailTemplateCode(code: string): boolean {
return ALLOWED_SET.has(code.trim().toUpperCase());
}

View File

@ -2,40 +2,40 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { import {
Tabs, TabsContent, TabsList, TabsTrigger Tabs, TabsContent, TabsList, TabsTrigger
} from '../ui/tabs'; } from '@/components/ui/tabs';
import { Globe, Shield, Clock, Mail, MapPin, SlidersHorizontal, Settings, FileText } from 'lucide-react'; import { Globe, Shield, Clock, Mail, MapPin, SlidersHorizontal, Settings, FileText } from 'lucide-react';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Services & Hooks // Services & Hooks
import { masterService } from '../../services/master.service'; import { masterService } from '@/services/master.service';
import { useMasterData } from '../../hooks/useMasterData'; import { useMasterData } from '@/hooks/useMasterData';
// Sub-components // Sub-components
import { ZonesOverview } from './MasterPage/ZonesOverview'; import { ZonesOverview } from '@/features/master/components/ZonesOverview';
import { ZoneDetails } from './MasterPage/ZoneDetails'; import { ZoneDetails } from '@/features/master/components/ZoneDetails';
import { RegionalManagement } from './MasterPage/RegionalManagement'; import { RegionalManagement } from '@/features/master/components/RegionalManagement';
import { ASMManagement } from './MasterPage/ASMManagement'; import { ASMManagement } from '@/features/master/components/ASMManagement';
import { ZMManagement } from './MasterPage/ZMManagement'; import { ZMManagement } from '@/features/master/components/ZMManagement';
import { UserManagementTable } from './MasterPage/UserManagementTable'; import { UserManagementTable } from '@/features/master/components/UserManagementTable';
import { SLAConfiguration } from './MasterPage/SLAConfiguration'; import { SLAConfiguration } from '@/features/master/components/SLAConfiguration';
import { RolePermissions } from './MasterPage/RolePermissions'; import { RolePermissions } from '@/features/master/components/RolePermissions';
import { RoleDialog } from './MasterPage/RoleDialog'; import { RoleDialog } from '@/features/master/components/RoleDialog';
import { AddRoleDialog } from './MasterPage/AddRoleDialog'; import { AddRoleDialog } from '@/features/master/components/AddRoleDialog';
import { EmailTemplates } from './MasterPage/EmailTemplates'; import { EmailTemplates } from '@/features/master/components/EmailTemplates';
import { LocationManagement } from './MasterPage/LocationManagement'; import { LocationManagement } from '@/features/master/components/LocationManagement';
import { ASMDialog } from './MasterPage/ASMDialog'; import { ASMDialog } from '@/features/master/components/ASMDialog';
import { ZMDialog } from './MasterPage/ZMDialog'; import { ZMDialog } from '@/features/master/components/ZMDialog';
import { DDLeadManagement } from './MasterPage/DDLeadManagement'; import { DDLeadManagement } from '@/features/master/components/DDLeadManagement';
import { DDLeadDialog } from './MasterPage/DDLeadDialog'; import { DDLeadDialog } from '@/features/master/components/DDLeadDialog';
import { ZoneDialog } from './MasterPage/ZoneDialog'; import { ZoneDialog } from '@/features/master/components/ZoneDialog';
import { RegionDialog } from './MasterPage/RegionDialog'; import { RegionDialog } from '@/features/master/components/RegionDialog';
import { TemplateDialog } from './MasterPage/TemplateDialog'; import { TemplateDialog } from '@/features/master/components/TemplateDialog';
import { LocationDialog } from './MasterPage/LocationDialog'; import { LocationDialog } from '@/features/master/components/LocationDialog';
import { SecurityDepositMaster } from './MasterPage/SecurityDepositMaster'; import { SecurityDepositMaster } from '@/features/master/components/SecurityDepositMaster';
import { DocumentConfigManagement } from './MasterPage/DocumentConfigManagement'; import { DocumentConfigManagement } from '@/features/master/components/DocumentConfigManagement';
import { ApprovalPoliciesPage } from '../admin/ApprovalPoliciesPage'; import { ApprovalPoliciesPage } from '@/components/admin/ApprovalPoliciesPage';
import { RootState } from '../../store'; import { RootState } from '@/store';
export const MasterPage: React.FC = () => { export const MasterPage: React.FC = () => {
const { fetchInitialData, fetchAreas } = useMasterData(); const { fetchInitialData, fetchAreas } = useMasterData();
@ -317,30 +317,54 @@ export const MasterPage: React.FC = () => {
} }
}; };
const handleSaveTemplate = async () => { const handleSaveTemplate = async (body: string) => {
try { try {
const res = await (editingTemplate?.id if (!editingTemplate?.id) {
? masterService.updateEmailTemplate(editingTemplate.id, editingTemplate) toast.error('Open a template from the list to edit.');
: masterService.createEmailTemplate(editingTemplate)) as any; return;
}
const res = await masterService.updateEmailTemplate(editingTemplate.id, {
...editingTemplate,
body
}) as any;
if (res.success) { if (res.success) {
toast.success('Template saved'); toast.success('Template saved');
setShowTemplateDialog(false); setShowTemplateDialog(false);
fetchInitialData(); fetchInitialData();
} else {
toast.error(res.message || 'Error saving template');
} }
} catch (error) { toast.error('Error saving template'); } } catch (error: any) {
const msg = error?.response?.data?.message || error?.message || 'Error saving template';
toast.error(msg);
}
}; };
const handlePreviewTemplate = async () => { const handlePreviewTemplate = async (body: string) => {
setPreviewLoading(true); setPreviewLoading(true);
try { try {
let data: Record<string, unknown>;
try {
data = JSON.parse(testDataInput) as Record<string, unknown>;
} catch {
toast.error('Mock test data must be valid JSON');
return;
}
const res = await masterService.previewEmailTemplate({ const res = await masterService.previewEmailTemplate({
subject: editingTemplate?.subject, subject: editingTemplate?.subject,
body: editingTemplate?.body, body,
data: JSON.parse(testDataInput) data
}) as any; }) as any;
if (res.success) setPreviewContent(res.data); if (res.success) {
} catch (error) { toast.error('Preview failed'); } setPreviewContent(res.data);
finally { setPreviewLoading(false); } } else {
toast.error(res.message || 'Preview failed');
}
} catch (error: any) {
const d = error?.response?.data;
const detail = d?.error || d?.message;
toast.error(detail || error?.message || 'Preview failed');
} finally { setPreviewLoading(false); }
}; };
const handleSaveRole = async (roleId: string, permissions: string[]) => { const handleSaveRole = async (roleId: string, permissions: string[]) => {
@ -535,11 +559,6 @@ export const MasterPage: React.FC = () => {
<TabsContent value="templates" className="animate-in fade-in duration-300"> <TabsContent value="templates" className="animate-in fade-in duration-300">
<EmailTemplates <EmailTemplates
onAddTemplate={() => {
setEditingTemplate({ templateCode: '', subject: '', body: '', description: '', placeholders: [] });
setTestDataInput('{}');
setShowTemplateDialog(true);
}}
onEditTemplate={(template) => { onEditTemplate={(template) => {
setEditingTemplate(template); setEditingTemplate(template);
if (template.placeholders && Array.isArray(template.placeholders)) { if (template.placeholders && Array.isArray(template.placeholders)) {

View File

@ -0,0 +1,57 @@
/**
* Email templates store a Handlebars shell: header/footer partials + optional primary_cta (portal button).
* Admins edit only the inner HTML; shell is stripped for the editor and reassembled on save/preview.
*/
function decodeHandlebarsEntities(html: string): string {
const hb = /\{\{\{[\s\S]*?\}\}\}|\{\{[\s\S]*?\}\}/g;
return html.replace(hb, (block) =>
block
.replace(/&amp;lt;/gi, '<')
.replace(/&amp;gt;/gi, '>')
.replace(/&amp;quot;/gi, '"')
.replace(/&amp;#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
);
}
const HDR = /\{\{\s*>\s*email_header\s*\}\}/i;
const FTR = /\{\{\s*>\s*email_footer\s*\}\}/i;
const CTA = /\{\{\s*>\s*primary_cta\s*\}\}/i;
/** Wrapped partial tag — optional surrounding block element from rich text export */
const HDR_BLOCK =
/^[\s\r\n]*(?:<(?:p|div)[^>]*>)?\s*\{\{\s*>\s*email_header\s*\}\}\s*(?:<\/(?:p|div)>)?[\s\r\n]*/i;
const FTR_BLOCK =
/[\s\r\n]*(?:<(?:p|div)[^>]*>)?\s*\{\{\s*>\s*email_footer\s*\}\}\s*(?:<\/(?:p|div)>)?[\s\r\n]*$/i;
const CTA_BLOCK =
/(?:<(?:p|div)[^>]*>)?\s*\{\{\s*>\s*primary_cta\s*\}\}\s*(?:<\/(?:p|div)>)?/gi;
export type ParsedEmailShell = {
isShellTemplate: boolean;
editableHtml: string;
includesCta: boolean;
};
export function parseEmailLayoutShell(html: string): ParsedEmailShell {
const raw = html || '';
const decoded = decodeHandlebarsEntities(raw);
if (!HDR.test(decoded) || !FTR.test(decoded)) {
return { isShellTemplate: false, editableHtml: raw, includesCta: false };
}
const includesCta = CTA.test(decoded);
let inner = decoded.replace(HDR_BLOCK, '').replace(FTR_BLOCK, '').replace(CTA_BLOCK, '');
inner = inner.trim();
return { isShellTemplate: true, editableHtml: inner, includesCta };
}
/** Rebuild stored body for API; CTA URL/label come from backend at send time only. */
export function composeEmailLayoutShell(editableHtml: string, includesCta: boolean): string {
const inner = (editableHtml || '').trim();
const mid = includesCta ? `\n{{> primary_cta}}\n` : '\n';
return `{{> email_header}}\n${inner}${mid}{{> email_footer}}`;
}

View File

@ -0,0 +1,69 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { AllApplicationsPage } from "../pages/AllApplicationsPage"
import { renderWithProviders } from "./test-utils"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import { mockOnboardingService, createGlobalMockApplication } from "@/testing/mocks"
describe("AllApplicationsPage - Robust Admin Verification", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderPage = (apps = []) => {
;(mockOnboardingService.getApplications as jest.Mock).mockResolvedValue({
success: true,
data: apps
})
return renderWithProviders(<AllApplicationsPage />, {
preloadedState: {
auth: {
user: { id: "admin-1", role: "DD Admin" } as any,
token: "tok",
isAuthenticated: true,
loading: false,
error: null
}
}
})
}
it("should display all application stages in a consolidated table view", async () => {
const mockApps = [
createGlobalMockApplication({
id: "app-alpha",
applicantName: "Dealer Alpha",
overallStatus: SHARED_APP_STATUSES.LOI
}),
createGlobalMockApplication({
id: "app-gamma",
applicantName: "Dealer Gamma",
overallStatus: SHARED_APP_STATUSES.ARCH
})
]
renderPage(mockApps)
expect(await screen.findByText("Dealer Alpha")).toBeInTheDocument()
expect(screen.getByText("Dealer Gamma")).toBeInTheDocument()
// Verify status rendering in table
expect(screen.getByText(SHARED_APP_STATUSES.LOI)).toBeInTheDocument()
expect(screen.getByText(SHARED_APP_STATUSES.ARCH)).toBeInTheDocument()
})
it("should support real-time filtering via the global search input", async () => {
const mockApps = [
createGlobalMockApplication({ id: "app-1", applicantName: "Target Dealer", overallStatus: SHARED_APP_STATUSES.LOI }),
createGlobalMockApplication({ id: "app-2", applicantName: "Other Business", overallStatus: SHARED_APP_STATUSES.ARCH })
]
const user = userEvent.setup()
renderPage(mockApps)
// Search for Target (Testing frontend search logic)
const searchInput = await screen.findByPlaceholderText(/Search/i)
await user.type(searchInput, "Target")
expect(screen.getByText("Target Dealer")).toBeInTheDocument()
expect(screen.queryByText("Other Business")).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,91 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ApplicationDetails } from "../pages/ApplicationDetails"
import { renderWithProvidersAndRoute } from "./test-utils"
import { onboardingService } from "@/services/onboarding.service"
import { SHARED_APP_STATUSES, TEST_STRINGS } from "@/testing/constants"
import { mockOnboardingService, createGlobalMockApplication } from "@/testing/mocks"
describe("ApplicationReviewWorkflow - Robust Decision Verification", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderDetails = (overrides = {}) => {
const appData = createGlobalMockApplication(overrides)
;(mockOnboardingService.getApplicationById as jest.Mock).mockResolvedValue(appData)
return renderWithProvidersAndRoute(<ApplicationDetails />, {
initialEntry: `/applications/${appData.id}`,
routePath: "/applications/:id",
preloadedState: {
auth: {
user: { id: "admin-1", role: "DD Admin" } as any,
token: "tok",
isAuthenticated: true,
loading: false,
error: null
}
}
})
}
it("should verify statutory information update logic", async () => {
const user = userEvent.setup()
renderDetails()
const editBtn = await screen.findByTestId("onboarding-applicant-info-edit-statutory")
await user.click(editBtn)
const panInput = screen.getByTestId("onboarding-applicant-info-input-pan")
await user.clear(panInput)
await user.type(panInput, TEST_STRINGS.VALID_PAN)
const saveBtn = screen.getByTestId("onboarding-applicant-info-statutory-save")
await user.click(saveBtn)
await waitFor(() => {
expect(onboardingService.updateApplication).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ panNumber: TEST_STRINGS.VALID_PAN })
)
})
})
it("should enforce mandatory remarks for rejection actions", async () => {
const user = userEvent.setup()
// Status must be an administrative stage for Reject button to appear
renderDetails({ overallStatus: SHARED_APP_STATUSES.LOA })
const rejectBtn = await screen.findByTestId("onboarding-details-reject-button")
await user.click(rejectBtn)
// Attempt to submit without remarks
const submitBtn = screen.getByTestId("onboarding-reject-submit-button")
await user.click(submitBtn)
// API should NOT be called if remarks are empty (verifying business constraint)
expect(onboardingService.submitStageDecision).not.toHaveBeenCalled()
})
it("should verify the full approval workflow with remarks", async () => {
const user = userEvent.setup()
renderDetails({ overallStatus: SHARED_APP_STATUSES.LOA })
const approveBtn = await screen.findByTestId("onboarding-details-approve-button")
await user.click(approveBtn)
const remarkInput = screen.getByTestId("onboarding-approve-remark-textarea")
await user.type(remarkInput, "Decision: Approved based on secondary verification.")
const submitBtn = screen.getByTestId("onboarding-approve-submit-button")
await user.click(submitBtn)
await waitFor(() => {
expect(onboardingService.submitStageDecision).toHaveBeenCalledWith(expect.objectContaining({
decision: "Approved",
remarks: "Decision: Approved based on secondary verification."
}))
})
})
})

View File

@ -0,0 +1,165 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ApplicationsPage } from "../pages/ApplicationsPage"
import { renderWithProviders } from "./test-utils"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import { mockOnboardingService, createGlobalMockApplication } from "@/testing/mocks"
describe("ApplicationsPage - Robust Feature Verification", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderPage = (apps = []) => {
;(mockOnboardingService.getApplications as jest.Mock).mockResolvedValue({
success: true,
data: apps
})
return renderWithProviders(<ApplicationsPage onViewDetails={jest.fn()} />, {
preloadedState: {
auth: {
user: { id: "admin-1", role: "DD Admin" } as any,
token: "tok",
isAuthenticated: true,
loading: false,
error: null
}
}
})
}
it("should correctly filter Dealership Requests based on business rules", async () => {
// Business Rules:
// 1. Must be shortlisted (ddLeadShortlisted: true)
// 2. Must NOT be in initial Questionnaire/Submitted states
// 3. Must NOT be an excluded ID (5, 6, 7, 8)
const mockApps = [
createGlobalMockApplication({
id: "valid-1",
applicantName: "Visible Application",
overallStatus: SHARED_APP_STATUSES.FDD,
ddLeadShortlisted: true
}),
createGlobalMockApplication({
id: "invalid-status",
applicantName: "Hidden By Status",
overallStatus: SHARED_APP_STATUSES.SUBMITTED,
ddLeadShortlisted: true
}),
createGlobalMockApplication({
id: "not-shortlisted",
applicantName: "Hidden By Shortlist",
overallStatus: SHARED_APP_STATUSES.ARCH,
ddLeadShortlisted: false,
isShortlisted: false,
}),
createGlobalMockApplication({
id: "5", // Excluded ID
applicantName: "Hidden By Excluded ID",
overallStatus: SHARED_APP_STATUSES.LOI,
ddLeadShortlisted: true
})
]
renderPage(mockApps)
// Verify positive visibility
expect(await screen.findByText("Visible Application")).toBeInTheDocument()
// Verify negative visibility (proving the filter logic is actually working, not bypassed)
expect(screen.queryByText("Hidden By Status")).not.toBeInTheDocument()
expect(screen.queryByText("Hidden By Shortlist")).not.toBeInTheDocument()
expect(screen.queryByText("Hidden By Excluded ID")).not.toBeInTheDocument()
})
it("should search across name, ID, and email with case-insensitivity", async () => {
const user = userEvent.setup()
const mockApps = [
createGlobalMockApplication({
id: "app-search",
applicationId: "REG-SEARCH-001",
applicantName: "Specific Dealer Name",
overallStatus: SHARED_APP_STATUSES.LOI,
ddLeadShortlisted: true
})
]
renderPage(mockApps)
const searchInput = await screen.findByTestId("onboarding-applications-search-input")
// 1. Search by name (case-insensitive)
await user.type(searchInput, "specific")
expect(await screen.findByText("Specific Dealer Name")).toBeInTheDocument()
// 2. Search by Registration ID
await user.clear(searchInput)
await user.type(searchInput, "REG-SEARCH")
expect(await screen.findByText("Specific Dealer Name")).toBeInTheDocument()
})
it("should hide applications still in questionnaire stages (including Questionnaire Completed)", async () => {
const mockApps = [
createGlobalMockApplication({
id: "qc-1",
applicantName: "Hidden Questionnaire Completed",
overallStatus: "Questionnaire Completed",
ddLeadShortlisted: true,
}),
createGlobalMockApplication({
id: "show-1",
applicantName: "Visible Post Questionnaire",
overallStatus: SHARED_APP_STATUSES.LOI,
ddLeadShortlisted: true,
}),
]
renderPage(mockApps)
expect(await screen.findByText("Visible Post Questionnaire")).toBeInTheDocument()
expect(screen.queryByText("Hidden Questionnaire Completed")).not.toBeInTheDocument()
})
it("should filter by My Assignments when checkbox is enabled", async () => {
const user = userEvent.setup()
const mockApps = [
createGlobalMockApplication({
id: "mine-1",
applicantName: "Assigned To Me",
overallStatus: SHARED_APP_STATUSES.LOI,
ddLeadShortlisted: true,
assignedTo: "admin-1",
}),
createGlobalMockApplication({
id: "other-1",
applicantName: "Assigned To Someone Else",
overallStatus: SHARED_APP_STATUSES.LOI,
ddLeadShortlisted: true,
assignedTo: "other-user",
}),
]
renderPage(mockApps)
expect(await screen.findByText("Assigned To Me")).toBeInTheDocument()
expect(screen.getByText("Assigned To Someone Else")).toBeInTheDocument()
await user.click(screen.getByTestId("onboarding-applications-assignments-checkbox"))
expect(screen.getByText("Assigned To Me")).toBeInTheDocument()
expect(screen.queryByText("Assigned To Someone Else")).not.toBeInTheDocument()
})
it("should handle API failures gracefully without crashing the dashboard", async () => {
// Mock a 500 or Network error
;(mockOnboardingService.getApplications as jest.Mock).mockRejectedValue(new Error("Network Error"))
// Suppress console.error for this test to keep logs clean
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
renderPage([])
// The dashboard should still render its UI shell and empty table, not crash
expect(await screen.findByTestId("onboarding-applications-search-input")).toBeInTheDocument()
expect(screen.getByTestId("onboarding-applications-count-text")).toHaveTextContent("0 applications")
consoleSpy.mockRestore()
})
})

View File

@ -0,0 +1,108 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ApplicationDetails } from "../pages/ApplicationDetails"
import { renderWithProvidersAndRoute } from "./test-utils"
import { onboardingService } from "@/services/onboarding.service"
import { eorService } from "@/services/eor.service"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import { mockOnboardingService, createGlobalMockApplication } from "@/testing/mocks"
describe("DeepOnboardingStages - Robust Lifecycle Verification", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderAdminDetails = (overrides = {}) => {
const appData = createGlobalMockApplication(overrides)
;(mockOnboardingService.getApplicationById as jest.Mock).mockResolvedValue(appData)
return renderWithProvidersAndRoute(<ApplicationDetails />, {
initialEntry: `/applications/${appData.id}`,
routePath: "/applications/:id",
preloadedState: {
auth: {
user: { id: "admin-1", name: "Admin", role: "DD Admin" } as any,
token: "test-token",
isAuthenticated: true,
loading: false,
error: null
}
}
})
}
it("should verify the Admin review and approval loop during FDD stage", async () => {
const user = userEvent.setup()
// 1. Mock FDD status with a realistic report finding
renderAdminDetails({
overallStatus: SHARED_APP_STATUSES.FDD,
reports: [{
id: "rep-1",
findings: "Audit verified: Positive cash flows and assets.",
recommendation: "Green"
}]
})
// 2. Tab switching logic (audit content is mocked; approval actions are what we assert)
const fddTab = await screen.findByTestId("onboarding-tab-trigger-fdd")
await user.click(fddTab)
await screen.findByTestId("onboarding-tab-content-fdd")
expect(screen.getByTestId("onboarding-details-approve-button")).toBeInTheDocument()
})
it("should enforce role-based access to administrative actions", async () => {
const appData = createGlobalMockApplication({ overallStatus: SHARED_APP_STATUSES.LOA })
;(onboardingService.getApplicationById as jest.Mock).mockResolvedValue(appData)
renderWithProvidersAndRoute(<ApplicationDetails />, {
initialEntry: `/applications/${appData.id}`,
routePath: "/applications/:id",
preloadedState: {
auth: {
user: { id: "audit-1", name: "Auditor", role: "FDD" } as any,
token: "tok",
isAuthenticated: true,
loading: false,
error: null
}
}
})
// Verify 'Reject' and 'Approve' buttons ARE NOT present for FDD role during LOA stage
await waitFor(() => {
expect(screen.queryByTestId("onboarding-details-approve-button")).not.toBeInTheDocument()
expect(screen.queryByTestId("onboarding-details-reject-button")).not.toBeInTheDocument()
})
})
it("should verify EOR compliance check tracking", async () => {
const user = userEvent.setup()
;(eorService.getChecklist as jest.Mock).mockResolvedValue({
success: true,
data: {
id: "eor-1",
items: [{
id: "item-1",
description: "Mandatory Training",
isCompliant: false,
proofDocument: { fileName: "training-proof.pdf" },
}]
}
})
renderAdminDetails({ overallStatus: SHARED_APP_STATUSES.EOR })
await user.click(await screen.findByTestId("onboarding-tab-trigger-eor"))
expect(await screen.findByText(/Mandatory Training/i)).toBeInTheDocument()
// Verify item toggling logic
const verifyBtn = screen.getByTestId("onboarding-eor-verify-btn-0")
await user.click(verifyBtn)
await waitFor(() => {
expect(eorService.updateItem).toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,101 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { FDDApplicationDetails } from "../pages/FDDApplicationDetails"
import { renderWithProvidersAndRoute } from "./test-utils"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import { createGlobalMockApplication } from "@/testing/mocks"
import { API } from "@/api/API"
describe("FDD Agency Flow - Robust Submission Verification", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderAgencyView = (overrides = {}) => {
const appData = createGlobalMockApplication({
overallStatus: SHARED_APP_STATUSES.FDD,
...overrides,
})
return renderWithProvidersAndRoute(<FDDApplicationDetails />, {
initialEntry: `/fdd-details/${appData.id}`,
routePath: "/fdd-details/:id",
preloadedState: {
auth: {
user: { id: "agency-1", role: "FDD" } as any,
token: "agency-token",
isAuthenticated: true,
loading: false,
error: null,
},
},
})
}
it("should allow an agency user to submit a formal FDD audit report", async () => {
const user = userEvent.setup()
renderAgencyView()
expect(await screen.findByTestId("onboarding-fdd-details-name")).toHaveTextContent(/Global Test Applicant/i)
// 1. Select document type (verify dropdown functionality)
const select = await screen.findByTestId("onboarding-fdd-details-doc-type-select")
await user.selectOptions(select, "FDD Final Audit Report")
// 2. Upload file (verify upload trigger)
const file = new File(["dummy audit content"], "fdd_audit.pdf", { type: "application/pdf" })
const input = screen.getByTestId("onboarding-fdd-details-file-input")
await user.upload(input, file)
// 3. Verify actual API integration (Testing the real integration path)
await waitFor(() => {
expect(API.uploadDocument).toHaveBeenCalled()
expect(API.submitFddReport).toHaveBeenCalledWith(
expect.objectContaining({
assignmentId: "fdd-asg-test",
recommendation: "REVIEW_PENDING",
})
)
})
})
it("should support flagging non-responsive applicants and track status updates", async () => {
const user = userEvent.setup()
renderAgencyView()
const flagBtn = await screen.findByTestId("onboarding-fdd-details-flag-btn")
await user.click(flagBtn)
expect(
await screen.findByTestId("onboarding-fdd-details-flag-modal-title")
).toHaveTextContent(/Flag Applicant/i)
const confirmBtn = screen.getByTestId("onboarding-fdd-details-flag-modal-confirm")
await user.click(confirmBtn)
await waitFor(() => {
expect(API.flagNonResponsive).toHaveBeenCalled()
})
})
it("should handle error states during document upload", async () => {
;(API.uploadDocument as jest.Mock).mockRejectedValue(new Error("Upload Failed"))
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const user = userEvent.setup()
renderAgencyView()
const select = await screen.findByTestId("onboarding-fdd-details-doc-type-select")
await user.selectOptions(select, "FDD Final Audit Report")
const input = screen.getByTestId("onboarding-fdd-details-file-input")
const file = new File(["error"], "fail.pdf", { type: "application/pdf" })
await user.upload(input, file)
// Wait for error logging or UI feedback (if implemented)
await waitFor(() => {
expect(API.uploadDocument).toHaveBeenCalled()
})
consoleSpy.mockRestore()
})
})

View File

@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { NonOpportunitiesPage } from "../pages/NonOpportunitiesPage"
import { renderWithProviders } from "./test-utils"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import { mockOnboardingService, createGlobalMockApplication } from "@/testing/mocks"
describe("NonOpportunitiesPage - Lead generation filtering", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderPage = (apps = []) => {
;(mockOnboardingService.getApplications as jest.Mock).mockResolvedValue({
success: true,
data: apps,
})
return renderWithProviders(
<NonOpportunitiesPage onViewDetails={jest.fn()} />,
{
preloadedState: {
auth: {
user: { id: "admin-1", role: "DD Lead" } as any,
token: "tok",
isAuthenticated: true,
loading: false,
error: null,
},
},
}
)
}
it("should only list Submitted leads as non-opportunities", async () => {
const mockApps = [
createGlobalMockApplication({
id: "lead-1",
applicantName: "Lead Submitted Only",
overallStatus: SHARED_APP_STATUSES.SUBMITTED,
preferredLocation: "Mumbai",
state: "Maharashtra",
}),
createGlobalMockApplication({
id: "lead-2",
applicantName: "Not A Lead Row",
overallStatus: SHARED_APP_STATUSES.LOI,
preferredLocation: "Pune",
state: "Maharashtra",
}),
]
renderPage(mockApps)
expect(await screen.findByText("Lead Submitted Only")).toBeInTheDocument()
expect(screen.queryByText("Not A Lead Row")).not.toBeInTheDocument()
expect(screen.getByTestId("onboarding-non-opps-stat-total")).toHaveTextContent(
"Total Leads"
)
expect(screen.getByTestId("onboarding-non-opps-stat-total")).toHaveTextContent("1")
})
it("should narrow results when searching", async () => {
const user = userEvent.setup()
const mockApps = [
createGlobalMockApplication({
id: "s1",
applicantName: "Alpha Lead",
overallStatus: SHARED_APP_STATUSES.SUBMITTED,
}),
createGlobalMockApplication({
id: "s2",
applicantName: "Beta Lead",
overallStatus: SHARED_APP_STATUSES.SUBMITTED,
}),
]
renderPage(mockApps)
expect(await screen.findByText("Alpha Lead")).toBeInTheDocument()
const searchInput = screen.getByTestId("onboarding-non-opps-search-input")
await user.type(searchInput, "Beta")
expect(screen.getByText("Beta Lead")).toBeInTheDocument()
expect(screen.queryByText("Alpha Lead")).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,158 @@
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { OpportunityRequestsPage } from "../pages/OpportunityRequestsPage"
import { renderWithProviders } from "./test-utils"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import {
mockOnboardingService,
mockAdminService,
createGlobalMockApplication,
} from "@/testing/mocks"
describe("OpportunityRequestsFlow - Lead Triage Verification", () => {
beforeEach(() => {
jest.clearAllMocks()
})
const renderPage = (apps = []) => {
;(mockOnboardingService.getApplications as jest.Mock).mockResolvedValue({
success: true,
data: apps
})
return renderWithProviders(<OpportunityRequestsPage />, {
preloadedState: {
auth: {
user: { id: "admin-1", role: "DD Admin" } as any,
token: "test-token",
isAuthenticated: true,
loading: false,
error: null
}
}
})
}
it("should display the list of lead opportunities and support multi-field search", async () => {
const mockApps = [
createGlobalMockApplication({
id: "opp-1",
applicantName: "Primary Dealer",
overallStatus: SHARED_APP_STATUSES.OPP_REQUEST,
ddLeadShortlisted: false // Opportunities are NOT yet shortlisted
}),
createGlobalMockApplication({
id: "opp-2",
applicantName: "Secondary Motors",
overallStatus: SHARED_APP_STATUSES.OPP_REQUEST,
ddLeadShortlisted: false
})
]
const user = userEvent.setup()
renderPage(mockApps)
expect(await screen.findByText(/Primary Dealer/i)).toBeInTheDocument()
expect(screen.getByText(/Secondary Motors/i)).toBeInTheDocument()
const searchInput = screen.getByTestId("onboarding-opp-requests-search-input")
await user.type(searchInput, "Primary")
expect(screen.getByText(/Primary Dealer/i)).toBeInTheDocument()
expect(screen.queryByText(/Secondary Motors/i)).not.toBeInTheDocument()
})
it("should handle bulk shortlisting of selected leads", async () => {
;(mockAdminService.getAllUsers as jest.Mock).mockResolvedValue({
success: true,
data: [{ id: "assign-1", fullName: "Assignee One", email: "assign@test.com" }],
})
const mockApps = [
createGlobalMockApplication({
id: "app-1",
applicantName: "Dealer One",
overallStatus: SHARED_APP_STATUSES.OPP_REQUEST,
ddLeadShortlisted: false,
isShortlisted: false,
}),
createGlobalMockApplication({
id: "app-2",
applicantName: "Dealer Two",
overallStatus: SHARED_APP_STATUSES.OPP_REQUEST,
ddLeadShortlisted: false,
isShortlisted: false,
}),
]
const user = userEvent.setup()
renderPage(mockApps)
await screen.findByText(/Dealer One/i)
await user.click(
screen.getByTestId("onboarding-opp-requests-table-select-item-app-1")
)
await user.click(
screen.getByTestId("onboarding-opp-requests-table-select-item-app-2")
)
await user.click(screen.getByTestId("onboarding-opp-requests-bulk-shortlist-btn"))
await screen.findByTestId("onboarding-opp-requests-shortlist-modal")
await user.click(screen.getByTestId("onboarding-opp-requests-assignee-trigger"))
await user.click(
await screen.findByTestId("onboarding-opp-requests-assignee-item-0")
)
await user.click(screen.getByTestId("onboarding-opp-requests-shortlist-confirm-btn"))
await waitFor(() => {
expect(mockOnboardingService.shortlistApplications).toHaveBeenCalled()
})
})
it("should display an empty state message when no leads match the filters", async () => {
renderPage([])
expect(await screen.findByText(/No opportunity requests found/i)).toBeInTheDocument()
})
it("should exclude leads already shortlisted by DD Lead", async () => {
const mockApps = [
createGlobalMockApplication({
id: "still-waiting",
applicantName: "Waiting For DD Lead",
overallStatus: SHARED_APP_STATUSES.OPP_REQUEST,
ddLeadShortlisted: false,
isShortlisted: false,
}),
createGlobalMockApplication({
id: "already-done",
applicantName: "Already DD Lead Shortlisted",
overallStatus: SHARED_APP_STATUSES.OPP_REQUEST,
ddLeadShortlisted: true,
isShortlisted: true,
}),
]
renderPage(mockApps)
expect(await screen.findByText(/Waiting For DD Lead/i)).toBeInTheDocument()
expect(screen.queryByText(/Already DD Lead Shortlisted/i)).not.toBeInTheDocument()
})
it("should exclude applications outside early opportunity statuses", async () => {
const mockApps = [
createGlobalMockApplication({
id: "early",
applicantName: "Early Stage Lead",
overallStatus: "Shortlisted",
ddLeadShortlisted: false,
}),
createGlobalMockApplication({
id: "late-fdd",
applicantName: "Late FDD Stage",
overallStatus: SHARED_APP_STATUSES.FDD,
ddLeadShortlisted: false,
}),
]
renderPage(mockApps)
expect(await screen.findByText(/Early Stage Lead/i)).toBeInTheDocument()
expect(screen.queryByText(/Late FDD Stage/i)).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,347 @@
import { renderHook, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { ApplicationDetails } from "../pages/ApplicationDetails"
import { renderWithProvidersAndRoute } from "./test-utils"
import { onboardingService } from "@/services/onboarding.service"
import { SHARED_APP_STATUSES } from "@/testing/constants"
import { createGlobalMockApplication, mockOnboardingService } from "@/testing/mocks"
import { useApplicationDetailsPermissions } from "../hooks/useApplicationDetailsPermissions"
import { useApplicationDetailsStageData } from "../hooks/useApplicationDetailsStageData"
describe("useApplicationDetailsPermissions — role-based action eligibility", () => {
const verifiedDeposit = (type: string) =>
({ FIRST_FILL: { status: "Verified" }, SECURITY_DEPOSIT: { status: "Verified" } } as Record<string, { status: string }>)[type]
const baseApp = (status: string, extras: Record<string, unknown> = {}) =>
({
id: "app-1",
status,
stageApprovals: [],
...extras,
}) as any
const user = (role: string, id = "u1") => ({ id, role, name: "Tester" }) as any
it("allows DD Admin to approve during LOA Pending when deposits are verified and no decision exists", () => {
const { result } = renderHook(() =>
useApplicationDetailsPermissions({
application: baseApp("LOA Pending"),
interviews: [],
currentUser: user("DD Admin"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
)
expect(result.current.permissions.canApprove).toBe(true)
expect(result.current.permissions.canReject).toBe(true)
expect(result.current.permissions.canAssign).toBe(true)
})
it("denies FDD role approve/reject on LOA Pending (approval sequence not met)", () => {
const { result } = renderHook(() =>
useApplicationDetailsPermissions({
application: baseApp("LOA Pending"),
interviews: [],
currentUser: user("FDD"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
)
expect(result.current.permissions.canApprove).toBe(false)
expect(result.current.permissions.canReject).toBe(false)
})
it("blocks approve when LOA is payment-locked (first fill not verified)", () => {
const pendingFirstFill = (type: string) =>
type === "FIRST_FILL"
? { status: "Pending" }
: { status: "Verified" }
const { result } = renderHook(() =>
useApplicationDetailsPermissions({
application: baseApp("LOA Pending"),
interviews: [],
currentUser: user("DD Admin"),
getDeposit: pendingFirstFill,
eorProgress: 0,
})
)
expect(result.current.permissions.isLoaLocked).toBe(true)
expect(result.current.permissions.canApprove).toBe(false)
expect(result.current.permissions.canReject).toBe(false)
})
it("requires DD Head (or NBH after DD Head) sequence on LOI In Progress", () => {
const loiApp = baseApp("LOI In Progress")
const nbhBeforeHead = renderHook(() =>
useApplicationDetailsPermissions({
application: loiApp,
interviews: [],
currentUser: user("NBH"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
)
expect(nbhBeforeHead.result.current.permissions.canApprove).toBe(false)
const loiWithHeadApproval = baseApp("LOI In Progress", {
stageApprovals: [
{ stageCode: "LOI_APPROVAL", actorRole: "DD Head", decision: "Approved", actorUserId: "dh-1" },
],
})
const nbhAfterHead = renderHook(() =>
useApplicationDetailsPermissions({
application: loiWithHeadApproval,
interviews: [],
currentUser: user("NBH"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
)
expect(nbhAfterHead.result.current.permissions.canApprove).toBe(true)
const ddHead = renderHook(() =>
useApplicationDetailsPermissions({
application: loiApp,
interviews: [],
currentUser: user("DD Head"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
)
expect(ddHead.result.current.permissions.canApprove).toBe(true)
})
it("exposes canSchedule for DD AM / ASM when interviews are incomplete, not for RBM", () => {
const early = baseApp("Shortlisted")
const interviews: any[] = []
expect(
renderHook(() =>
useApplicationDetailsPermissions({
application: early,
interviews,
currentUser: user("DD AM"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
).result.current.permissions.canSchedule
).toBe(true)
expect(
renderHook(() =>
useApplicationDetailsPermissions({
application: early,
interviews,
currentUser: user("ASM"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
).result.current.permissions.canSchedule
).toBe(true)
expect(
renderHook(() =>
useApplicationDetailsPermissions({
application: early,
interviews,
currentUser: user("RBM"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
).result.current.permissions.canSchedule
).toBe(false)
})
it("allows Assign User only for DD Admin, Super Admin, DD AM", () => {
const app = baseApp("Shortlisted")
expect(
renderHook(() =>
useApplicationDetailsPermissions({
application: app,
interviews: [],
currentUser: user("DD Admin"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
).result.current.permissions.canAssign
).toBe(true)
expect(
renderHook(() =>
useApplicationDetailsPermissions({
application: app,
interviews: [],
currentUser: user("ASM"),
getDeposit: verifiedDeposit,
eorProgress: 0,
})
).result.current.permissions.canAssign
).toBe(false)
})
})
describe("useApplicationDetailsStageData — progress track transitions", () => {
const verifiedDeposit = (type: string) =>
({ FIRST_FILL: { status: "Verified" }, SECURITY_DEPOSIT: { status: "Verified" } } as Record<string, { status: string }>)[type]
const stagesOf = (application: any, interviews: any[] = [], documents: any[] = []) =>
renderHook(() =>
useApplicationDetailsStageData({
application,
documents,
interviews,
eorData: {},
getDeposit: verifiedDeposit,
})
).result.current.processStages
it("marks Questionnaire as active when status is Questionnaire Pending", () => {
const stages = stagesOf({ status: "Questionnaire Pending", progressTracking: [] })
const q = stages.find((s) => s.name === "Questionnaire")
expect(q?.status).toBe("active")
const sub = stages.find((s) => s.name === "Submitted")
expect(sub?.status).toBe("completed")
})
it("transitions first interview to active when Level 1 is pending and L1 is scheduled", () => {
const stages = stagesOf(
{ status: "Level 1 Interview Pending", progressTracking: [] },
[{ level: 1, status: "Scheduled" }]
)
const first = stages.find((s) => s.name === "1st Level Interview")
expect(first?.status).toBe("active")
})
it("marks Level 1 completed after Level 1 Approved", () => {
const stages = stagesOf({ status: "Level 1 Approved", progressTracking: [] })
expect(stages.find((s) => s.name === "1st Level Interview")?.status).toBe("completed")
expect(stages.find((s) => s.name === "2nd Level Interview")?.status).toBe("pending")
})
it("activates FDD stage when status is FDD Verification", () => {
const stages = stagesOf({ status: "FDD Verification", progressTracking: [] })
expect(stages.find((s) => s.name === "FDD")?.status).toBe("active")
expect(stages.find((s) => s.name === "3rd Level Interview")?.status).toBe("completed")
})
it("completes Dealership Active only when onboarded", () => {
const before = stagesOf({ status: "Inauguration", progressTracking: [] })
expect(before.find((s) => s.name === "Dealership Active")?.status).toBe("active")
const after = stagesOf({ status: "Onboarded", progressTracking: [] })
expect(after.find((s) => s.name === "Dealership Active")?.status).toBe("completed")
})
it("respects progressTracking from API when stage is active or completed", () => {
const stages = stagesOf({
status: "Shortlisted",
progressTracking: [{ stageName: "Questionnaire", status: "active" }],
})
expect(stages.find((s) => s.name === "Questionnaire")?.status).toBe("active")
})
it("sets LOA stage locked when first fill is not verified", () => {
const getDep = (type: string) =>
(type === "FIRST_FILL" ? { status: "Pending" } : { status: "Verified" }) as { status: string }
const { result } = renderHook(() =>
useApplicationDetailsStageData({
application: { status: "LOA Pending", progressTracking: [] },
documents: [],
interviews: [],
eorData: {},
getDeposit: getDep,
})
)
const loa = result.current.processStages.find((s) => s.name === "LOA")
expect(loa?.isLocked).toBe(true)
expect(loa?.status).toBe("active")
})
})
describe("ApplicationDetails UI — schedule button visibility by role", () => {
beforeEach(() => {
jest.clearAllMocks()
})
it("shows Schedule Interview for DD AM when pipeline allows scheduling", async () => {
const appData = createGlobalMockApplication({
overallStatus: "Shortlisted",
})
;(mockOnboardingService.getApplicationById as jest.Mock).mockResolvedValue(appData)
renderWithProvidersAndRoute(<ApplicationDetails />, {
initialEntry: `/applications/${appData.id}`,
routePath: "/applications/:id",
preloadedState: {
auth: {
user: { id: "am-1", name: "AM", role: "DD AM" } as any,
token: "t",
isAuthenticated: true,
loading: false,
error: null,
},
},
})
await waitFor(() => {
expect(screen.getByTestId("onboarding-details-schedule-button")).toBeInTheDocument()
})
})
it("hides Schedule Interview for RBM in the same application state", async () => {
const appData = createGlobalMockApplication({
overallStatus: "Shortlisted",
})
;(onboardingService.getApplicationById as jest.Mock).mockResolvedValue(appData)
renderWithProvidersAndRoute(<ApplicationDetails />, {
initialEntry: `/applications/${appData.id}`,
routePath: "/applications/:id",
preloadedState: {
auth: {
user: { id: "rbm-1", name: "RBM", role: "RBM" } as any,
token: "t",
isAuthenticated: true,
loading: false,
error: null,
},
},
})
await waitFor(() => {
expect(screen.queryByTestId("onboarding-details-schedule-button")).not.toBeInTheDocument()
})
})
it("shows progress tab active stage icon for FDD Verification", async () => {
const ue = userEvent.setup()
const appData = createGlobalMockApplication({
overallStatus: SHARED_APP_STATUSES.FDD,
})
;(mockOnboardingService.getApplicationById as jest.Mock).mockResolvedValue(appData)
renderWithProvidersAndRoute(<ApplicationDetails />, {
initialEntry: `/applications/${appData.id}`,
routePath: "/applications/:id",
preloadedState: {
auth: {
user: { id: "admin-1", name: "Admin", role: "DD Admin" } as any,
token: "t",
isAuthenticated: true,
loading: false,
error: null,
},
},
})
await ue.click(await screen.findByTestId("onboarding-tab-trigger-progress"))
const fddIcon = await screen.findByTestId("onboarding-progress-stage-icon-6")
expect(fddIcon.className).toMatch(/amber-500/)
})
})

View File

@ -0,0 +1,125 @@
import type { Page } from "@playwright/test";
/** Matches mock-data DD Admin row used on LoginPage quick-login cards */
export const E2E_DD_ADMIN = {
id: "18",
fullName: "Lince",
email: "lince@royalenfield.com",
password: "Admin@123",
role: "DD Admin",
} as const;
export type StubApplication = Record<string, unknown>;
const defaultApplications: StubApplication[] = [
{
id: "e2e-visible-1",
applicationId: "E2E-REG-001",
applicantName: "E2E Visible Dealer",
email: "dealer.e2e@example.com",
phone: "+91 9000000001",
overallStatus: "FDD",
ddLeadShortlisted: true,
createdAt: new Date().toISOString(),
},
];
/** Opportunity Requests: not yet DD Leadshortlisted + early-stage status */
export const opportunityRequestsApplications: StubApplication[] = [
{
id: "e2e-opp-1",
applicationId: "E2E-OPP-001",
applicantName: "E2E Opportunity Dealer",
email: "opp.e2e@example.com",
phone: "+91 9000000002",
overallStatus: "Questionnaire Pending",
ddLeadShortlisted: false,
preferredLocation: "Mumbai",
state: "Maharashtra",
createdAt: new Date().toISOString(),
},
];
/**
* Stubs REST calls so E2E runs without a live API. Matches default VITE_API_URL
* host (localhost:5000) and path prefix /api.
*/
export async function installOnboardingApiStubs(
page: Page,
options?: { applications?: StubApplication[] }
): Promise<void> {
const apps = options?.applications ?? defaultApplications;
await page.route("**/api/auth/login", async (route) => {
if (route.request().method() !== "POST") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
token: "e2e-playwright-token",
user: {
id: E2E_DD_ADMIN.id,
fullName: E2E_DD_ADMIN.fullName,
email: E2E_DD_ADMIN.email,
role: E2E_DD_ADMIN.role,
},
}),
});
});
await page.route("**/api/auth/me", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user: {
id: E2E_DD_ADMIN.id,
fullName: E2E_DD_ADMIN.fullName,
email: E2E_DD_ADMIN.email,
role: E2E_DD_ADMIN.role,
roleCode: E2E_DD_ADMIN.role,
},
}),
});
});
await page.route("**/api/onboarding/applications", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: apps }),
});
});
await page.route("**/api/admin/users", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
data: [
{
id: "assignee-1",
fullName: "E2E Assignee",
email: "assignee.e2e@example.com",
},
],
}),
});
});
}

View File

@ -0,0 +1,13 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { E2E_DD_ADMIN } from "./api-stubs";
/** Clicks the LoginPage mock-user card for DD Admin (Lince). */
export async function loginAsDdAdmin(page: Page): Promise<void> {
await page.goto("/admin-login");
await page
.locator("div.cursor-pointer")
.filter({ hasText: E2E_DD_ADMIN.email })
.click();
await expect(page).toHaveURL(/\/dashboard$/);
}

View File

@ -0,0 +1,36 @@
import { test, expect } from "@playwright/test";
import { installOnboardingApiStubs } from "./fixtures/api-stubs";
import { loginAsDdAdmin } from "./fixtures/login";
test.describe("Onboarding — Dealership Requests", () => {
test.beforeEach(async ({ page }) => {
await installOnboardingApiStubs(page);
});
test("DD Admin logs in via quick-login and sees shortlisted application", async ({
page,
}) => {
await loginAsDdAdmin(page);
await page.getByRole("button", { name: /Dealership Requests/i }).click();
await expect(page).toHaveURL(/\/applications$/);
await expect(
page.getByRole("heading", { name: /Dealership Requests/i })
).toBeVisible();
await expect(page.getByText("E2E Visible Dealer")).toBeVisible();
});
test("deep link to applications with restored session loads list", async ({
page,
}) => {
await page.addInitScript((token: string) => {
localStorage.setItem("token", token);
}, "e2e-session-token");
await page.goto("/applications");
await expect(page.getByText("E2E Visible Dealer")).toBeVisible();
});
});

View File

@ -0,0 +1,31 @@
import { test, expect } from "@playwright/test";
import {
installOnboardingApiStubs,
opportunityRequestsApplications,
} from "./fixtures/api-stubs";
import { loginAsDdAdmin } from "./fixtures/login";
test.describe("Onboarding — Opportunity Requests", () => {
test.beforeEach(async ({ page }) => {
await installOnboardingApiStubs(page, {
applications: opportunityRequestsApplications,
});
});
test("loads page and opportunity-stage applications (direct URL after login)", async ({
page,
}) => {
await loginAsDdAdmin(page);
// DD Admin has no sidebar entry for this route; deep link matches real usage / bookmarks.
await page.goto("/opportunity-requests");
await expect(page).toHaveURL(/\/opportunity-requests$/);
// Header h1 only — body also has an h3 containing "Opportunity Requests".
await expect(
page.getByRole("heading", { level: 1, name: "Opportunity Requests" })
).toBeVisible();
await expect(page.getByText("E2E Opportunity Dealer")).toBeVisible();
});
});

View File

@ -0,0 +1 @@
export const SHARED_APP_STATUSES = { SUBMITTED: "Submitted" };

View File

@ -0,0 +1,67 @@
import { TEST_STRINGS, GLOBAL_MOCK_IDS, SHARED_APP_STATUSES } from "./constants";
/**
* Global Unified Service Mock Factories
* Provides standard, passing responses for all major services.
*/
export const createGlobalMockApplication = (overrides = {}) => ({
id: GLOBAL_MOCK_IDS.APP_ID,
registrationNumber: GLOBAL_MOCK_IDS.REG_ID,
name: "Global Test Applicant",
email: TEST_STRINGS.VALID_EMAIL,
phone: TEST_STRINGS.VALID_PHONE,
status: SHARED_APP_STATUSES.SUBMITTED,
currentProgress: 50,
uploadedDocuments: [],
workNotes: [],
reports: [],
...overrides,
});
export const globalMockOnboardingService = {
getApplications: jest.fn().mockResolvedValue({ success: true, data: [] }),
getApplicationById: jest.fn().mockResolvedValue({ success: true, data: createGlobalMockApplication() }),
submitStageDecision: jest.fn().mockResolvedValue({ success: true, data: { statusUpdated: true } }),
updateApplication: jest.fn().mockResolvedValue({ success: true }),
getDocuments: jest.fn().mockResolvedValue({ success: true, data: [] }),
getSecurityDeposit: jest.fn().mockResolvedValue({ success: true, data: [] }),
getDocumentConfigs: jest.fn().mockResolvedValue({ success: true, data: [] }),
assignArchitectureTeam: jest.fn().mockResolvedValue({ success: true }),
shortlistApplications: jest.fn().mockResolvedValue({ success: true }),
};
export const globalMockCollaborationService = {
getWorknotes: jest.fn().mockResolvedValue({ success: true, data: [] }),
addWorknote: jest.fn().mockResolvedValue({ success: true }),
};
export const globalMockEORService = {
getChecklist: jest.fn().mockResolvedValue({ success: true, data: { items: [] } }),
updateItem: jest.fn().mockResolvedValue({ success: true }),
};
export const globalMockAdminService = {
getAllUsers: jest.fn().mockResolvedValue({ success: true, data: [] }),
};
/**
* Helper to prepare global mocks in any test file.
*/
export const setupUnifiedMocks = () => {
jest.mock("@/services/onboarding.service", () => ({ onboardingService: globalMockOnboardingService }));
jest.mock("@/services/collaboration.service", () => ({ collaborationService: globalMockCollaborationService }));
// Mapping worknoteService to collaborationService as they share logic in this codebase
jest.mock("@/services/worknote.service", () => ({ worknoteService: globalMockCollaborationService }));
jest.mock("@/services/eor.service", () => ({ eorService: globalMockEORService }));
jest.mock("@/services/admin.service", () => ({ adminService: globalMockAdminService }));
jest.mock("@/api/API", () => ({
API: {
...globalMockOnboardingService,
getFddAssignment: jest.fn().mockResolvedValue({ success: true, data: {} }),
uploadDocument: jest.fn().mockResolvedValue({ success: true }),
submitFddReport: jest.fn().mockResolvedValue({ success: true }),
flagNonResponsive: jest.fn().mockResolvedValue({ success: true }),
}
}));
};

View File

@ -0,0 +1,70 @@
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'
import { MemoryRouter, Routes, Route } from 'react-router-dom'
import type { RootState } from '@/store'
import authReducer from '@/store/slices/authSlice'
import masterReducer from '@/store/slices/masterSlice'
import { MEMORY_ROUTER_TEST_FUTURE } from '@/testing/reactRouterTest'
// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>
store?: ReturnType<typeof setupStore>
}
interface ProvidersAndRouteOptions extends ExtendedRenderOptions {
initialEntry: string
/** E.g. `/applications/:id` so `useParams` / `useNavigate` work under test */
routePath: string
}
export function setupStore(preloadedState?: PreloadedState<RootState>) {
return configureStore({
reducer: {
auth: authReducer,
master: masterReducer,
},
preloadedState
})
}
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
}: ExtendedRenderOptions = {}
) {
function Wrapper({ children }: PropsWithChildren<{}>): React.JSX.Element {
return <Provider store={store}>{children}</Provider>
}
// Return an object with the store and all of RTL's query functions
return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}
export function renderWithProvidersAndRoute(
routedScreen: React.ReactElement,
{
initialEntry,
routePath,
...rest
}: ProvidersAndRouteOptions
) {
const wrapped = (
<MemoryRouter initialEntries={[initialEntry]} future={MEMORY_ROUTER_TEST_FUTURE}>
<Routes>
<Route path={routePath} element={routedScreen} />
</Routes>
</MemoryRouter>
)
return renderWithProviders(wrapped, rest)
}

View File

@ -1,9 +1,9 @@
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '../ui/progress'; import { Progress } from '@/components/ui/progress';
import { Application } from '../../lib/mock-data'; import { Application } from '@/lib/mock-data';
import { MapPin, Phone, Mail, Award, Calendar, Building } from 'lucide-react'; import { MapPin, Phone, Mail, Award, Calendar, Building } from 'lucide-react';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface ApplicationCardProps { interface ApplicationCardProps {
application: Application; application: Application;
@ -50,24 +50,31 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
}; };
return ( return (
<div className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-lg transition-shadow"> <div
className="bg-white rounded-lg border border-slate-200 p-6 hover:shadow-lg transition-shadow"
data-testid={`onboarding-application-card-${application.id}`}
>
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between mb-4">
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className="text-slate-900">{application.name}</h3> <h3 className="text-slate-900" data-testid="onboarding-application-card-name">{application.name}</h3>
{application.tags?.map((tag) => ( {application.tags?.map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="outline" variant="outline"
className={tag === 'Approved' ? 'border-green-500 text-green-700' : 'border-teal-500 text-teal-700'} className={tag === 'Approved' ? 'border-green-500 text-green-700' : 'border-teal-500 text-teal-700'}
data-testid={`onboarding-application-card-tag-${tag}`}
> >
{tag} {tag}
</Badge> </Badge>
))} ))}
</div> </div>
<p className="text-slate-600">{application.registrationNumber}</p> <p className="text-slate-600" data-testid="onboarding-application-card-registration">{application.registrationNumber}</p>
</div> </div>
<Badge className={getStatusColor(application.status)}> <Badge
className={getStatusColor(application.status)}
data-testid="onboarding-application-card-status"
>
{application.status} {application.status}
</Badge> </Badge>
</div> </div>
@ -75,9 +82,9 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
<div className="space-y-3 mb-4"> <div className="space-y-3 mb-4">
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
<span>{application.preferredLocation}</span> <span data-testid="onboarding-application-card-location">{application.preferredLocation}</span>
{application.rank && application.totalApplicantsAtLocation && ( {application.rank && application.totalApplicantsAtLocation && (
<Badge variant="outline"> <Badge variant="outline" data-testid="onboarding-application-card-rank">
Rank {application.rank}/{application.totalApplicantsAtLocation} Rank {application.rank}/{application.totalApplicantsAtLocation}
</Badge> </Badge>
)} )}
@ -85,29 +92,29 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
<div className="flex items-start gap-2 text-slate-600"> <div className="flex items-start gap-2 text-slate-600">
<Building className="w-4 h-4 mt-0.5" /> <Building className="w-4 h-4 mt-0.5" />
<span className="text-sm">{application.businessAddress}</span> <span className="text-sm" data-testid="onboarding-application-card-address">{application.businessAddress}</span>
</div> </div>
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
<Mail className="w-4 h-4" /> <Mail className="w-4 h-4" />
<span>{application.email}</span> <span data-testid="onboarding-application-card-email">{application.email}</span>
</div> </div>
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
<Phone className="w-4 h-4" /> <Phone className="w-4 h-4" />
<span>{application.phone}</span> <span data-testid="onboarding-application-card-phone">{application.phone}</span>
</div> </div>
{application.questionnaireMarks !== undefined && ( {application.questionnaireMarks !== undefined && (
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
<Award className="w-4 h-4" /> <Award className="w-4 h-4" />
<span>Score: {application.questionnaireMarks}/100</span> <span data-testid="onboarding-application-card-score">Score: {application.questionnaireMarks}/100</span>
</div> </div>
)} )}
<div className="flex items-center gap-2 text-slate-600"> <div className="flex items-center gap-2 text-slate-600">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
<span>Submitted: {formatDateTime(application.submissionDate)}</span> <span data-testid="onboarding-application-card-submission-date">Submitted: {formatDateTime(application.submissionDate)}</span>
</div> </div>
</div> </div>
@ -115,14 +122,14 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
<div className="mb-4"> <div className="mb-4">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<span className="text-slate-600">Progress</span> <span className="text-slate-600">Progress</span>
<span className="text-slate-900">{application.progress}%</span> <span className="text-slate-900" data-testid="onboarding-application-card-progress-text">{application.progress}%</span>
</div> </div>
<Progress value={application.progress} className="h-2" /> <Progress value={application.progress} className="h-2" data-testid="onboarding-application-card-progress-bar" />
</div> </div>
{/* Deadline Warning */} {/* Deadline Warning */}
{application.deadline && application.status === 'Questionnaire Pending' && ( {application.deadline && application.status === 'Questionnaire Pending' && (
<div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-md"> <div className="mb-4 p-3 bg-orange-50 border border-orange-200 rounded-md" data-testid="onboarding-application-card-deadline-warning">
<p className="text-orange-800"> <p className="text-orange-800">
Deadline: {formatDateTime(application.deadline)} Deadline: {formatDateTime(application.deadline)}
</p> </p>
@ -132,6 +139,7 @@ export function ApplicationCard({ application, onViewDetails }: ApplicationCardP
<Button <Button
onClick={() => onViewDetails(application.id)} onClick={() => onViewDetails(application.id)}
className="w-full bg-amber-600 hover:bg-amber-700" className="w-full bg-amber-600 hover:bg-amber-700"
data-testid="onboarding-application-card-view-button"
> >
View Details View Details
</Button> </Button>

View File

@ -6,10 +6,10 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "../ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "../ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "../ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "../ui/input"; import { Input } from "@/components/ui/input";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
interface BankDetailsModalProps { interface BankDetailsModalProps {
@ -36,7 +36,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
Enter the dealer's bank information for settlement transfers. Enter the dealer's bank information for settlement transfers.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit} data-testid="onboarding-bank-details-form">
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4"> <div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="bankName" className="text-right text-xs">Bank Name</Label> <Label htmlFor="bankName" className="text-right text-xs">Bank Name</Label>
@ -48,6 +48,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
required required
placeholder="e.g. HDFC Bank, ICICI Bank" placeholder="e.g. HDFC Bank, ICICI Bank"
className="h-9" className="h-9"
data-testid="onboarding-bank-name-input"
/> />
</div> </div>
</div> </div>
@ -61,6 +62,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
required required
placeholder="Full name as per bank records" placeholder="Full name as per bank records"
className="h-9" className="h-9"
data-testid="onboarding-account-holder-name-input"
/> />
</div> </div>
</div> </div>
@ -74,6 +76,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
required required
placeholder="Enter account number" placeholder="Enter account number"
className="h-9" className="h-9"
data-testid="onboarding-account-number-input"
/> />
</div> </div>
</div> </div>
@ -87,6 +90,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
required required
placeholder="11-character code" placeholder="11-character code"
className="h-9" className="h-9"
data-testid="onboarding-ifsc-code-input"
/> />
</div> </div>
</div> </div>
@ -100,6 +104,7 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
required required
placeholder="Branch location" placeholder="Branch location"
className="h-9" className="h-9"
data-testid="onboarding-branch-name-input"
/> />
</div> </div>
</div> </div>
@ -111,14 +116,15 @@ export const BankDetailsModal: React.FC<BankDetailsModalProps> = ({
name="isPrimary" name="isPrimary"
defaultChecked={editingBank?.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-amber-600 focus:ring-amber-500"
data-testid="onboarding-is-primary-checkbox"
/> />
<Label htmlFor="isPrimaryModal" className="text-xs font-medium cursor-pointer">Set as primary account</Label> <Label htmlFor="isPrimaryModal" className="text-xs font-medium cursor-pointer">Set as primary account</Label>
</div> </div>
</div> </div>
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={onClose}>Cancel</Button> <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"> <Button type="submit" disabled={isSubmitting} size="sm" className="bg-amber-600" data-testid="onboarding-bank-details-submit">
{isSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null} {isSubmitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
{editingBank ? 'Update Account' : 'Save Bank Details'} {editingBank ? 'Update Account' : 'Save Bank Details'}
</Button> </Button>

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { ClipboardList } from 'lucide-react'; import { ClipboardList } from 'lucide-react';
interface QuestionnaireResponseViewProps { interface QuestionnaireResponseViewProps {
@ -10,7 +10,10 @@ const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ a
// If no responses or empty array // If no responses or empty array
if (!application.questionnaireResponses || application.questionnaireResponses.length === 0) { if (!application.questionnaireResponses || application.questionnaireResponses.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center py-12 text-slate-500 bg-slate-50 rounded-lg border border-dashed border-slate-300"> <div
className="flex flex-col items-center justify-center py-12 text-slate-500 bg-slate-50 rounded-lg border border-dashed border-slate-300"
data-testid="onboarding-questionnaire-empty"
>
<ClipboardList className="w-12 h-12 mb-3 text-slate-300" /> <ClipboardList className="w-12 h-12 mb-3 text-slate-300" />
<h3 className="text-lg font-medium text-slate-700">Response is Pending</h3> <h3 className="text-lg font-medium text-slate-700">Response is Pending</h3>
<p className="text-sm">The applicant has not submitted the questionnaire yet.</p> <p className="text-sm">The applicant has not submitted the questionnaire yet.</p>
@ -27,14 +30,14 @@ const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ a
const totalScore = application.score || application.questionnaireMarks || 0; // Fallback mapping const totalScore = application.score || application.questionnaireMarks || 0; // Fallback mapping
return ( return (
<div className="space-y-6"> <div className="space-y-6" data-testid="onboarding-questionnaire-view">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<ClipboardList className="w-5 h-5 text-amber-600" /> <ClipboardList className="w-5 h-5 text-amber-600" />
<h3 className="text-slate-900">Questionnaire Responses</h3> <h3 className="text-slate-900">Questionnaire Responses</h3>
</div> </div>
{totalScore !== undefined && ( {totalScore !== undefined && (
<Badge className="bg-amber-600">Score: {totalScore}/100</Badge> <Badge className="bg-amber-600" data-testid="onboarding-questionnaire-total-score">Score: {totalScore}/100</Badge>
)} )}
</div> </div>
@ -57,28 +60,35 @@ const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ a
const isImage = isFile && answer.startsWith('data:image'); const isImage = isFile && answer.startsWith('data:image');
return ( return (
<div key={resp.id} className="border border-slate-200 rounded-lg p-5 hover:border-amber-300 transition-colors"> <div
key={resp.id}
className="border border-slate-200 rounded-lg p-5 hover:border-amber-300 transition-colors"
data-testid={`onboarding-questionnaire-item-${index}`}
>
<div className="flex items-start gap-3 mb-3"> <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"> <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> <span className="text-amber-600">{index + 1}</span>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Badge variant="outline" className="text-slate-600 bg-slate-50"> <Badge variant="outline" className="text-slate-600 bg-slate-50" data-testid={`onboarding-questionnaire-item-section-${index}`}>
{section} {section}
</Badge> </Badge>
{(options.length > 0 && maxScore > 0) && ( {(options.length > 0 && maxScore > 0) && (
<Badge className={score > 0 ? "bg-green-600" : "bg-slate-400"}> <Badge
className={score > 0 ? "bg-green-600" : "bg-slate-400"}
data-testid={`onboarding-questionnaire-item-score-${index}`}
>
{score}/{maxScore} {score}/{maxScore}
</Badge> </Badge>
)} )}
</div> </div>
<h4 className="text-slate-900 font-medium">{questionText}</h4> <h4 className="text-slate-900 font-medium" data-testid={`onboarding-questionnaire-item-text-${index}`}>{questionText}</h4>
</div> </div>
</div> </div>
<div className="ml-11"> <div className="ml-11">
{isImage ? ( {isImage ? (
<div className="mt-2"> <div className="mt-2" data-testid={`onboarding-questionnaire-item-image-${index}`}>
<img <img
src={answer} src={answer}
alt="Response Attachment" alt="Response Attachment"
@ -90,19 +100,20 @@ const QuestionnaireResponseView: React.FC<QuestionnaireResponseViewProps> = ({ a
href={answer} href={answer}
download={`upload_${index}.pdf`} download={`upload_${index}.pdf`}
className="text-blue-600 underline text-sm break-all" className="text-blue-600 underline text-sm break-all"
data-testid={`onboarding-questionnaire-item-download-${index}`}
> >
Download Attachment Download Attachment
</a> </a>
) : ( ) : (
<p className="text-slate-600 leading-relaxed break-words whitespace-pre-wrap"> <div className="text-slate-600 leading-relaxed break-words whitespace-pre-wrap" data-testid={`onboarding-questionnaire-item-answer-${index}`}>
{resp.attachmentUrl ? ( {resp.attachmentUrl ? (
<a href={resp.attachmentUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline"> <a href={resp.attachmentUrl} target="_blank" rel="noreferrer" className="text-blue-600 underline" data-testid={`onboarding-questionnaire-item-attachment-${index}`}>
View Attachment View Attachment
</a> </a>
) : ( ) : (
answer answer
)} )}
</p> </div>
)} )}
</div> </div>
</div> </div>

View File

@ -12,12 +12,12 @@ import {
Phone, Phone,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { Application } from '../../../lib/mock-data'; import { Application } from '@/lib/mock-data';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Separator } from '../../ui/separator'; import { Separator } from '@/components/ui/separator';
interface StatutoryFormState { interface StatutoryFormState {
accountHolderName: string; accountHolderName: string;
@ -55,7 +55,7 @@ export function ApplicantInformationCard({
onStatutoryFormChange, onStatutoryFormChange,
}: ApplicantInformationCardProps) { }: ApplicantInformationCardProps) {
return ( return (
<Card> <Card data-testid="onboarding-applicant-info-card">
<CardHeader> <CardHeader>
<CardTitle>Applicant Information</CardTitle> <CardTitle>Applicant Information</CardTitle>
</CardHeader> </CardHeader>
@ -65,7 +65,7 @@ export function ApplicantInformationCard({
<User className="w-5 h-5 text-slate-400 mt-1" /> <User className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Full Name</p> <p className="text-slate-600">Full Name</p>
<p className="text-slate-900">{application.name}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-name">{application.name}</p>
</div> </div>
</div> </div>
@ -73,7 +73,7 @@ export function ApplicantInformationCard({
<Mail className="w-5 h-5 text-slate-400 mt-1" /> <Mail className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Email</p> <p className="text-slate-600">Email</p>
<p className="text-slate-900">{application.email}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-email">{application.email}</p>
</div> </div>
</div> </div>
@ -81,7 +81,7 @@ export function ApplicantInformationCard({
<Phone className="w-5 h-5 text-slate-400 mt-1" /> <Phone className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Phone</p> <p className="text-slate-600">Phone</p>
<p className="text-slate-900">{application.phone}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-phone">{application.phone}</p>
</div> </div>
</div> </div>
@ -89,7 +89,7 @@ export function ApplicantInformationCard({
<User className="w-5 h-5 text-slate-400 mt-1" /> <User className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Age</p> <p className="text-slate-600">Age</p>
<p className="text-slate-900">{application.age ? `${application.age} years` : 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-age">{application.age ? `${application.age} years` : 'N/A'}</p>
</div> </div>
</div> </div>
@ -97,7 +97,7 @@ export function ApplicantInformationCard({
<GraduationCap className="w-5 h-5 text-slate-400 mt-1" /> <GraduationCap className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Education</p> <p className="text-slate-600">Education</p>
<p className="text-slate-900">{application.education || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-education">{application.education || 'N/A'}</p>
</div> </div>
</div> </div>
@ -105,7 +105,7 @@ export function ApplicantInformationCard({
<MapPin className="w-5 h-5 text-slate-400 mt-1" /> <MapPin className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Preferred Location</p> <p className="text-slate-600">Preferred Location</p>
<p className="text-slate-900">{application.preferredLocation || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-preferred-location">{application.preferredLocation || 'N/A'}</p>
</div> </div>
</div> </div>
@ -113,18 +113,22 @@ export function ApplicantInformationCard({
<MapPin className="w-5 h-5 text-slate-400 mt-1" /> <MapPin className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Location Type</p> <p className="text-slate-600">Location Type</p>
<p className="text-slate-900">{application.locationType || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-location-type">{application.locationType || 'N/A'}</p>
</div> </div>
</div> </div>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Building2 className="w-5 h-5 text-slate-400 mt-1" /> <Building2 className="w-5 h-5 text-slate-400 mt-1" />
<div className="flex-1"> <div className="flex-1">
<p className="text-slate-600 flex items-center justify-between group cursor-pointer" onClick={onEditFirmType}> <p
className="text-slate-600 flex items-center justify-between group cursor-pointer"
onClick={onEditFirmType}
data-testid="onboarding-applicant-info-edit-firm-type"
>
Proposed 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-amber-600 transition-colors" />
</p> </p>
<p className="text-slate-900 font-black text-amber-700 tracking-tight leading-none mt-1"> <p className="text-slate-900 font-black text-amber-700 tracking-tight leading-none mt-1" data-testid="onboarding-applicant-info-firm-type">
{application.constitutionType || 'Not Provided'} {application.constitutionType || 'Not Provided'}
</p> </p>
</div> </div>
@ -134,7 +138,7 @@ export function ApplicantInformationCard({
<Bike className="w-5 h-5 text-slate-400 mt-1" /> <Bike className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Owns Bike</p> <p className="text-slate-600">Owns Bike</p>
<p className="text-slate-900">{application.ownRoyalEnfield === 'yes' ? 'Yes' : 'No'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-owns-bike">{application.ownRoyalEnfield === 'yes' ? 'Yes' : 'No'}</p>
</div> </div>
</div> </div>
@ -143,7 +147,7 @@ export function ApplicantInformationCard({
<Bike className="w-5 h-5 text-slate-400 mt-1" /> <Bike className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Bike Model</p> <p className="text-slate-600">Bike Model</p>
<p className="text-slate-900">{application.royalEnfieldModel || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-bike-model">{application.royalEnfieldModel || 'N/A'}</p>
</div> </div>
</div> </div>
)} )}
@ -152,7 +156,7 @@ export function ApplicantInformationCard({
<User className="w-5 h-5 text-slate-400 mt-1" /> <User className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Existing Dealer</p> <p className="text-slate-600">Existing Dealer</p>
<p className="text-slate-900">{application.existingDealer === 'yes' ? 'Yes' : 'No'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-existing-dealer">{application.existingDealer === 'yes' ? 'Yes' : 'No'}</p>
</div> </div>
</div> </div>
@ -161,7 +165,7 @@ export function ApplicantInformationCard({
<User className="w-5 h-5 text-slate-400 mt-1" /> <User className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Company Name</p> <p className="text-slate-600">Company Name</p>
<p className="text-slate-900">{application.companyName || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-company-name">{application.companyName || 'N/A'}</p>
</div> </div>
</div> </div>
)} )}
@ -170,7 +174,7 @@ export function ApplicantInformationCard({
<ClipboardList className="w-5 h-5 text-slate-400 mt-1" /> <ClipboardList className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Source</p> <p className="text-slate-600">Source</p>
<p className="text-slate-900">{application.source || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-source">{application.source || 'N/A'}</p>
</div> </div>
</div> </div>
@ -179,7 +183,7 @@ export function ApplicantInformationCard({
<Award className="w-5 h-5 text-slate-400 mt-1" /> <Award className="w-5 h-5 text-slate-400 mt-1" />
<div> <div>
<p className="text-slate-600">Questionnaire Score</p> <p className="text-slate-600">Questionnaire Score</p>
<p className="text-slate-900">{application.questionnaireMarks}/100</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-score">{application.questionnaireMarks}/100</p>
</div> </div>
</div> </div>
)} )}
@ -189,22 +193,22 @@ export function ApplicantInformationCard({
<div> <div>
<p className="text-slate-600 mb-2">Address</p> <p className="text-slate-600 mb-2">Address</p>
<p className="text-slate-900">{application.address || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-address">{application.address || 'N/A'}</p>
</div> </div>
<div> <div>
<p className="text-slate-600 mb-2">Pincode</p> <p className="text-slate-600 mb-2">Pincode</p>
<p className="text-slate-900">{application.pincode || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-pincode">{application.pincode || 'N/A'}</p>
</div> </div>
<div> <div>
<p className="text-slate-600 mb-2">Description</p> <p className="text-slate-600 mb-2">Description</p>
<p className="text-slate-900">{application.description || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-description">{application.description || 'N/A'}</p>
</div> </div>
<div> <div>
<p className="text-slate-600 mb-2">Past Experience</p> <p className="text-slate-600 mb-2">Past Experience</p>
<p className="text-slate-900">{application.pastExperience || 'N/A'}</p> <p className="text-slate-900" data-testid="onboarding-applicant-info-experience">{application.pastExperience || 'N/A'}</p>
</div> </div>
<div className="pt-6 border-t mt-6"> <div className="pt-6 border-t mt-6">
@ -218,6 +222,7 @@ export function ApplicantInformationCard({
size="sm" size="sm"
onClick={onEditStatutory} onClick={onEditStatutory}
className="h-8 text-amber-600 hover:text-amber-700 hover:bg-amber-50 gap-1.5" className="h-8 text-amber-600 hover:text-amber-700 hover:bg-amber-50 gap-1.5"
data-testid="onboarding-applicant-info-edit-statutory"
> >
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
Edit Details Edit Details
@ -226,7 +231,10 @@ export function ApplicantInformationCard({
</div> </div>
{isEditingStatutory ? ( {isEditingStatutory ? (
<div className="bg-slate-50/50 p-6 rounded-xl border-2 border-amber-100 space-y-4"> <div
className="bg-slate-50/50 p-6 rounded-xl border-2 border-amber-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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-[10px] uppercase font-bold text-slate-500">Legal Entity Name</Label> <Label className="text-[10px] uppercase font-bold text-slate-500">Legal Entity Name</Label>
@ -235,6 +243,7 @@ export function ApplicantInformationCard({
onChange={(e) => onStatutoryFormChange({ ...statutoryForm, accountHolderName: e.target.value })} onChange={(e) => onStatutoryFormChange({ ...statutoryForm, accountHolderName: e.target.value })}
placeholder="Enter Legal Entity Name" placeholder="Enter Legal Entity Name"
className="bg-white border-slate-200" className="bg-white border-slate-200"
data-testid="onboarding-applicant-info-input-legal-name"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -245,6 +254,7 @@ export function ApplicantInformationCard({
placeholder="10-digit PAN" placeholder="10-digit PAN"
maxLength={10} maxLength={10}
className="bg-white border-slate-200 uppercase" className="bg-white border-slate-200 uppercase"
data-testid="onboarding-applicant-info-input-pan"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -255,6 +265,7 @@ export function ApplicantInformationCard({
placeholder="15-digit GSTIN" placeholder="15-digit GSTIN"
maxLength={15} maxLength={15}
className="bg-white border-slate-200 uppercase" className="bg-white border-slate-200 uppercase"
data-testid="onboarding-applicant-info-input-gst"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -264,6 +275,7 @@ export function ApplicantInformationCard({
onChange={(e) => onStatutoryFormChange({ ...statutoryForm, registeredAddress: e.target.value })} onChange={(e) => onStatutoryFormChange({ ...statutoryForm, registeredAddress: e.target.value })}
placeholder="Enter Registered Office Address" placeholder="Enter Registered Office Address"
className="bg-white border-slate-200" className="bg-white border-slate-200"
data-testid="onboarding-applicant-info-input-registered-address"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -273,6 +285,7 @@ export function ApplicantInformationCard({
onChange={(e) => onStatutoryFormChange({ ...statutoryForm, bankName: e.target.value })} onChange={(e) => onStatutoryFormChange({ ...statutoryForm, bankName: e.target.value })}
placeholder="Enter Bank Name" placeholder="Enter Bank Name"
className="bg-white border-slate-200" className="bg-white border-slate-200"
data-testid="onboarding-applicant-info-input-bank-name"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -282,6 +295,7 @@ export function ApplicantInformationCard({
onChange={(e) => onStatutoryFormChange({ ...statutoryForm, accountNumber: e.target.value })} onChange={(e) => onStatutoryFormChange({ ...statutoryForm, accountNumber: e.target.value })}
placeholder="Enter Account Number" placeholder="Enter Account Number"
className="bg-white border-slate-200" className="bg-white border-slate-200"
data-testid="onboarding-applicant-info-input-account-number"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@ -292,6 +306,7 @@ export function ApplicantInformationCard({
placeholder="11-digit IFSC" placeholder="11-digit IFSC"
maxLength={11} maxLength={11}
className="bg-white border-slate-200 uppercase" className="bg-white border-slate-200 uppercase"
data-testid="onboarding-applicant-info-input-ifsc-code"
/> />
</div> </div>
</div> </div>
@ -301,6 +316,7 @@ export function ApplicantInformationCard({
size="sm" size="sm"
onClick={onCancelEditStatutory} onClick={onCancelEditStatutory}
disabled={isSavingStatutory} disabled={isSavingStatutory}
data-testid="onboarding-applicant-info-statutory-cancel"
> >
Cancel Cancel
</Button> </Button>
@ -309,30 +325,34 @@ export function ApplicantInformationCard({
onClick={onSaveStatutory} onClick={onSaveStatutory}
disabled={isSavingStatutory} disabled={isSavingStatutory}
className="bg-amber-600 hover:bg-amber-700" className="bg-amber-600 hover:bg-amber-700"
data-testid="onboarding-applicant-info-statutory-save"
> >
{isSavingStatutory ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save Details'} {isSavingStatutory ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Save Details'}
</Button> </Button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 bg-slate-50/50 p-4 rounded-xl border border-slate-100"> <div
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 bg-slate-50/50 p-4 rounded-xl border border-slate-100"
data-testid="onboarding-applicant-info-statutory-display"
>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Legal Entity Name</p> <p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Legal Entity Name</p>
<p className="text-xs font-semibold text-slate-900">{application.accountHolderName || 'Pending'}</p> <p className="text-xs font-semibold text-slate-900" data-testid="onboarding-applicant-info-display-legal-name">{application.accountHolderName || 'Pending'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">PAN Number</p> <p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">PAN Number</p>
<p className="text-xs font-semibold text-slate-900 uppercase">{application.panNumber || 'Pending'}</p> <p className="text-xs font-semibold text-slate-900 uppercase" data-testid="onboarding-applicant-info-display-pan">{application.panNumber || 'Pending'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">GST Number</p> <p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">GST Number</p>
<p className="text-xs font-semibold text-slate-900 uppercase">{application.gstNumber || 'Pending'}</p> <p className="text-xs font-semibold text-slate-900 uppercase" data-testid="onboarding-applicant-info-display-gst">{application.gstNumber || 'Pending'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Registered Address</p> <p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Registered Address</p>
<p className="text-xs font-semibold text-slate-900">{application.registeredAddress || 'Pending'}</p> <p className="text-xs font-semibold text-slate-900" data-testid="onboarding-applicant-info-display-registered-address">{application.registeredAddress || 'Pending'}</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1" data-testid="onboarding-applicant-info-display-bank-info">
<p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Bank Details</p> <p className="text-[10px] text-slate-500 uppercase font-bold tracking-tight">Bank Details</p>
<p className="text-xs font-semibold text-slate-900">{application.bankName || 'N/A'}</p> <p className="text-xs font-semibold text-slate-900">{application.bankName || 'N/A'}</p>
<p className="text-[10px] text-slate-600">A/C: {application.accountNumber || 'N/A'}</p> <p className="text-[10px] text-slate-600">A/C: {application.accountNumber || 'N/A'}</p>
@ -345,3 +365,5 @@ export function ApplicantInformationCard({
</Card> </Card>
); );
} }

View File

@ -1,12 +1,12 @@
import { Check, CheckCircle, Clock, Loader2 } from 'lucide-react'; import { Check, CheckCircle, Clock, Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '../../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
interface ApplicationDetailsActionModalsProps { interface ApplicationDetailsActionModalsProps {
application: any; application: any;
@ -128,7 +128,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
return ( return (
<> <>
<Dialog open={showApproveModal} onOpenChange={setShowApproveModal}> <Dialog open={showApproveModal} onOpenChange={setShowApproveModal}>
<DialogContent> <DialogContent data-testid="onboarding-approve-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Approve Application</DialogTitle> <DialogTitle>Approve Application</DialogTitle>
<DialogDescription>Provide approval remarks and optionally attach supporting documents.</DialogDescription> <DialogDescription>Provide approval remarks and optionally attach supporting documents.</DialogDescription>
@ -136,15 +136,27 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>Remark (Required)</Label> <Label>Remark (Required)</Label>
<Textarea placeholder="Enter approval remarks..." value={approvalRemark} onChange={(e) => setApprovalRemark(e.target.value)} className="mt-2" rows={4} /> <Textarea
placeholder="Enter approval remarks..."
value={approvalRemark}
onChange={(e) => setApprovalRemark(e.target.value)}
className="mt-2"
rows={4}
data-testid="onboarding-approve-remark-textarea"
/>
</div> </div>
<div> <div>
<Label>Attach File (Optional)</Label> <Label>Attach File (Optional)</Label>
<Input type="file" className="mt-2" onChange={(e) => setApprovalFile(e.target.files ? e.target.files[0] : null)} /> <Input
type="file"
className="mt-2"
onChange={(e) => setApprovalFile(e.target.files ? e.target.files[0] : null)}
data-testid="onboarding-approve-file-input"
/>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowApproveModal(false)} disabled={isApproving}>Cancel</Button> <Button variant="outline" className="flex-1" onClick={() => setShowApproveModal(false)} disabled={isApproving} data-testid="onboarding-approve-cancel-button">Cancel</Button>
<Button className="flex-1 bg-green-600 hover:bg-green-700" onClick={handleApprove} disabled={isApproving}> <Button className="flex-1 bg-green-600 hover:bg-green-700" onClick={handleApprove} disabled={isApproving} data-testid="onboarding-approve-submit-button">
{isApproving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Approving...</> : 'Submit Approval'} {isApproving ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Approving...</> : 'Submit Approval'}
</Button> </Button>
</div> </div>
@ -153,7 +165,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</Dialog> </Dialog>
<Dialog open={showOnboardModal} onOpenChange={setShowOnboardModal}> <Dialog open={showOnboardModal} onOpenChange={setShowOnboardModal}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-md" data-testid="onboarding-onboard-modal">
<DialogHeader> <DialogHeader>
<div className="mx-auto w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mb-4"> <div className="mx-auto w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mb-4">
<CheckCircle className="w-8 h-8 text-green-600" /> <CheckCircle className="w-8 h-8 text-green-600" />
@ -171,6 +183,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div className="mt-6 flex flex-col gap-3"> <div className="mt-6 flex flex-col gap-3">
<Button <Button
className="w-full bg-green-600 hover:bg-green-700 h-11 text-lg font-semibold shadow-lg shadow-green-100" className="w-full bg-green-600 hover:bg-green-700 h-11 text-lg font-semibold shadow-lg shadow-green-100"
data-testid="onboarding-onboard-confirm-button"
onClick={async () => { onClick={async () => {
setIsOnboarding(true); setIsOnboarding(true);
try { try {
@ -188,13 +201,13 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
> >
{isOnboarding ? <><Loader2 className="w-5 h-5 mr-2 animate-spin" />Processing Onboarding...</> : 'Confirm & Onboard Dealer'} {isOnboarding ? <><Loader2 className="w-5 h-5 mr-2 animate-spin" />Processing Onboarding...</> : 'Confirm & Onboard Dealer'}
</Button> </Button>
<Button variant="ghost" className="w-full text-slate-500 hover:text-slate-700" onClick={() => setShowOnboardModal(false)} disabled={isOnboarding}>Cancel</Button> <Button variant="ghost" className="w-full text-slate-500 hover:text-slate-700" onClick={() => setShowOnboardModal(false)} disabled={isOnboarding} data-testid="onboarding-onboard-cancel-button">Cancel</Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showRejectModal} onOpenChange={setShowRejectModal}> <Dialog open={showRejectModal} onOpenChange={setShowRejectModal}>
<DialogContent> <DialogContent data-testid="onboarding-reject-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Reject Application</DialogTitle> <DialogTitle>Reject Application</DialogTitle>
<DialogDescription>Please provide a clear reason for rejecting this application.</DialogDescription> <DialogDescription>Please provide a clear reason for rejecting this application.</DialogDescription>
@ -202,11 +215,18 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>Reason for Rejection (Required)</Label> <Label>Reason for Rejection (Required)</Label>
<Textarea placeholder="Enter rejection reason..." value={rejectionReason} onChange={(e) => setRejectionReason(e.target.value)} className="mt-2" rows={4} /> <Textarea
placeholder="Enter rejection reason..."
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="mt-2"
rows={4}
data-testid="onboarding-reject-remark-textarea"
/>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowRejectModal(false)} disabled={isRejecting}>Cancel</Button> <Button variant="outline" className="flex-1" onClick={() => setShowRejectModal(false)} disabled={isRejecting} data-testid="onboarding-reject-cancel-button">Cancel</Button>
<Button variant="destructive" className="flex-1" onClick={handleReject} disabled={isRejecting}> <Button variant="destructive" className="flex-1" onClick={handleReject} disabled={isRejecting} data-testid="onboarding-reject-submit-button">
{isRejecting ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Rejecting...</> : 'Confirm Rejection'} {isRejecting ? <><Loader2 className="w-4 h-4 mr-2 animate-spin" />Rejecting...</> : 'Confirm Rejection'}
</Button> </Button>
</div> </div>
@ -215,7 +235,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</Dialog> </Dialog>
<Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal}> <Dialog open={showScheduleModal} onOpenChange={setShowScheduleModal}>
<DialogContent> <DialogContent data-testid="onboarding-schedule-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Schedule Interview</DialogTitle> <DialogTitle>Schedule Interview</DialogTitle>
<DialogDescription>Set up an interview session with the applicant and relevant team members.</DialogDescription> <DialogDescription>Set up an interview session with the applicant and relevant team members.</DialogDescription>
@ -224,7 +244,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div> <div>
<Label>Interview Type</Label> <Label>Interview Type</Label>
<Select value={interviewType} onValueChange={setInterviewType}> <Select value={interviewType} onValueChange={setInterviewType}>
<SelectTrigger className="mt-2"><SelectValue placeholder="Select interview type" /></SelectTrigger> <SelectTrigger className="mt-2" data-testid="onboarding-schedule-type-select"><SelectValue placeholder="Select interview type" /></SelectTrigger>
<SelectContent> <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="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="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>
@ -235,30 +255,30 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div> <div>
<Label>Interview Mode</Label> <Label>Interview Mode</Label>
<Select value={interviewMode} onValueChange={setInterviewMode}> <Select value={interviewMode} onValueChange={setInterviewMode}>
<SelectTrigger className="mt-2"><SelectValue placeholder="Select interview mode" /></SelectTrigger> <SelectTrigger className="mt-2" data-testid="onboarding-schedule-mode-select"><SelectValue placeholder="Select interview mode" /></SelectTrigger>
<SelectContent><SelectItem value="virtual">Virtual</SelectItem><SelectItem value="physical">Physical</SelectItem></SelectContent> <SelectContent><SelectItem value="virtual">Virtual</SelectItem><SelectItem value="physical">Physical</SelectItem></SelectContent>
</Select> </Select>
</div> </div>
<div><Label>Date & Time</Label><Input type="datetime-local" className="mt-2" value={interviewDate} onChange={(e) => setInterviewDate(e.target.value)} /></div> <div><Label>Date & Time</Label><Input type="datetime-local" className="mt-2" value={interviewDate} onChange={(e) => setInterviewDate(e.target.value)} data-testid="onboarding-schedule-date-input" /></div>
{interviewMode === 'virtual' && <div><Label>Meeting Link</Label><Input placeholder="https://meet.google.com/..." className="mt-2" value={meetingLink} onChange={(e) => setMeetingLink(e.target.value)} /></div>} {interviewMode === 'virtual' && <div><Label>Meeting Link</Label><Input placeholder="https://meet.google.com/..." className="mt-2" value={meetingLink} onChange={(e) => setMeetingLink(e.target.value)} data-testid="onboarding-schedule-link-input" /></div>}
{interviewMode === 'physical' && <div><Label>Location</Label><Input placeholder="Enter interview location address" className="mt-2" value={location} onChange={(e) => setLocation(e.target.value)} /></div>} {interviewMode === 'physical' && <div><Label>Location</Label><Input placeholder="Enter interview location address" className="mt-2" value={location} onChange={(e) => setLocation(e.target.value)} data-testid="onboarding-schedule-location-input" /></div>}
<div> <div>
<Label>Interviewers</Label> <Label>Interviewers</Label>
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<Select value={selectedInterviewerId} onValueChange={setSelectedInterviewerId}> <Select value={selectedInterviewerId} onValueChange={setSelectedInterviewerId}>
<SelectTrigger className="flex-1"><SelectValue placeholder="Select interviewer" /></SelectTrigger> <SelectTrigger className="flex-1" data-testid="onboarding-schedule-interviewer-select"><SelectValue placeholder="Select interviewer" /></SelectTrigger>
<SelectContent>{users.map((user) => <SelectItem key={user.id} value={user.id}>{user.fullName || user.name} ({user.role?.roleName || user.roleCode})</SelectItem>)}</SelectContent> <SelectContent>{users.map((user) => <SelectItem key={user.id} value={user.id}>{user.fullName || user.name} ({user.role?.roleName || user.roleCode})</SelectItem>)}</SelectContent>
</Select> </Select>
<Button onClick={handleAddInterviewer} type="button" variant="secondary">Add</Button> <Button onClick={handleAddInterviewer} type="button" variant="secondary" data-testid="onboarding-schedule-add-interviewer-button">Add</Button>
</div> </div>
{scheduledInterviewParticipants.length > 0 && ( {scheduledInterviewParticipants.length > 0 && (
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
<Label className="text-xs text-muted-foreground">Selected Interviewers:</Label> <Label className="text-xs text-muted-foreground">Selected Interviewers:</Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{scheduledInterviewParticipants.map((p) => ( {scheduledInterviewParticipants.map((p) => (
<div key={p.id} className="flex items-center gap-1 bg-secondary px-2 py-1 rounded text-sm"> <div key={p.id} className="flex items-center gap-1 bg-secondary px-2 py-1 rounded text-sm" data-testid={`onboarding-schedule-participant-${p.id}`}>
<span>{p.fullName || p.name || 'Unknown'}</span> <span>{p.fullName || p.name || 'Unknown'}</span>
<button onClick={() => handleRemoveInterviewer(p.id)} className="text-muted-foreground hover:text-destructive">×</button> <button onClick={() => handleRemoveInterviewer(p.id)} className="text-muted-foreground hover:text-destructive" data-testid={`onboarding-schedule-remove-participant-${p.id}`}>×</button>
</div> </div>
))} ))}
</div> </div>
@ -266,15 +286,15 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
)} )}
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowScheduleModal(false)} disabled={isScheduling}>Cancel</Button> <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}>{isScheduling ? 'Scheduling...' : 'Schedule'}</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>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showAssignArchitectureModal} onOpenChange={setShowAssignArchitectureModal}> <Dialog open={showAssignArchitectureModal} onOpenChange={setShowAssignArchitectureModal}>
<DialogContent> <DialogContent data-testid="onboarding-architecture-assign-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Assign Architecture Team</DialogTitle> <DialogTitle>Assign Architecture Team</DialogTitle>
<DialogDescription>Select an architecture team lead for site planning and blueprints.</DialogDescription> <DialogDescription>Select an architecture team lead for site planning and blueprints.</DialogDescription>
@ -283,7 +303,7 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div> <div>
<Label>Select Architecture Lead</Label> <Label>Select Architecture Lead</Label>
<Select value={architectureLeadId} onValueChange={setArchitectureLeadId}> <Select value={architectureLeadId} onValueChange={setArchitectureLeadId}>
<SelectTrigger className="mt-2"><SelectValue placeholder="Search users..." /></SelectTrigger> <SelectTrigger className="mt-2" data-testid="onboarding-architecture-lead-select"><SelectValue placeholder="Search users..." /></SelectTrigger>
<SelectContent> <SelectContent>
{users.filter(u => u.roleCode === 'ARCHITECTURE' || u.role?.roleCode === 'ARCHITECTURE' || u.role === 'Architecture' || u.role === 'Architecture Team').map((u) => ( {users.filter(u => u.roleCode === 'ARCHITECTURE' || u.role?.roleCode === 'ARCHITECTURE' || u.role === 'Architecture' || u.role === 'Architecture Team').map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName} ({u.email})</SelectItem> <SelectItem key={u.id} value={u.id}>{u.fullName} ({u.email})</SelectItem>
@ -295,15 +315,15 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</Select> </Select>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowAssignArchitectureModal(false)} disabled={isAssigningArchitecture}>Cancel</Button> <Button variant="outline" className="flex-1" onClick={() => setShowAssignArchitectureModal(false)} disabled={isAssigningArchitecture} data-testid="onboarding-architecture-assign-cancel">Cancel</Button>
<Button className="flex-1 bg-blue-600 hover:bg-blue-700" onClick={handleAssignArchitecture} disabled={isAssigningArchitecture}>{isAssigningArchitecture ? 'Assigning...' : 'Assign Team'}</Button> <Button className="flex-1 bg-blue-600 hover:bg-blue-700" onClick={handleAssignArchitecture} disabled={isAssigningArchitecture} data-testid="onboarding-architecture-assign-submit">{isAssigningArchitecture ? 'Assigning...' : 'Assign Team'}</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showArchitectureStatusModal} onOpenChange={setShowArchitectureStatusModal}> <Dialog open={showArchitectureStatusModal} onOpenChange={setShowArchitectureStatusModal}>
<DialogContent> <DialogContent data-testid="onboarding-architecture-status-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Update Architecture Status</DialogTitle> <DialogTitle>Update Architecture Status</DialogTitle>
<DialogDescription>Mark the architectural work as completed and optionally add remarks.</DialogDescription> <DialogDescription>Mark the architectural work as completed and optionally add remarks.</DialogDescription>
@ -312,17 +332,24 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
<div> <div>
<Label>Status</Label> <Label>Status</Label>
<Select value={architectureStatus} onValueChange={setArchitectureStatus}> <Select value={architectureStatus} onValueChange={setArchitectureStatus}>
<SelectTrigger className="mt-2"><SelectValue placeholder="Select status" /></SelectTrigger> <SelectTrigger className="mt-2" data-testid="onboarding-architecture-status-select"><SelectValue placeholder="Select status" /></SelectTrigger>
<SelectContent><SelectItem value="COMPLETED">Completed</SelectItem><SelectItem value="REJECTED">Rejected / Needs Revision</SelectItem></SelectContent> <SelectContent><SelectItem value="COMPLETED">Completed</SelectItem><SelectItem value="REJECTED">Rejected / Needs Revision</SelectItem></SelectContent>
</Select> </Select>
</div> </div>
<div> <div>
<Label>Remarks (Optional)</Label> <Label>Remarks (Optional)</Label>
<Textarea placeholder="Enter any planning or site-visit remarks..." value={architectureRemarks} onChange={(e) => setArchitectureRemarks(e.target.value)} className="mt-2" rows={4} /> <Textarea
placeholder="Enter any planning or site-visit remarks..."
value={architectureRemarks}
onChange={(e) => setArchitectureRemarks(e.target.value)}
className="mt-2"
rows={4}
data-testid="onboarding-architecture-remarks-textarea"
/>
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowArchitectureStatusModal(false)} disabled={isUpdatingArchitecture}>Cancel</Button> <Button variant="outline" className="flex-1" onClick={() => setShowArchitectureStatusModal(false)} disabled={isUpdatingArchitecture} data-testid="onboarding-architecture-status-cancel">Cancel</Button>
<Button className="flex-1 bg-blue-600 hover:bg-blue-700" onClick={handleUpdateArchitectureStatus} disabled={isUpdatingArchitecture}>{isUpdatingArchitecture ? 'Updating...' : 'Update Status'}</Button> <Button className="flex-1 bg-blue-600 hover:bg-blue-700" onClick={handleUpdateArchitectureStatus} disabled={isUpdatingArchitecture} data-testid="onboarding-architecture-status-submit">{isUpdatingArchitecture ? 'Updating...' : 'Update Status'}</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@ -330,3 +357,5 @@ export function ApplicationDetailsActionModals(props: ApplicationDetailsActionMo
</> </>
); );
} }

View File

@ -1,15 +1,15 @@
import { AlertCircle, Building2, Download, Eye, FileText, Info, Loader2, ShieldAlert, ShieldCheck, Upload } from 'lucide-react'; import { AlertCircle, Building2, Download, Eye, FileText, Info, Loader2, ShieldAlert, ShieldCheck, Upload } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { cn, formatDateTime } from '@/components/ui/utils'; import { cn, formatDateTime } from '@/components/ui/utils';
import { DocumentPreviewModal } from '../../ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '../../ui/separator'; import { Separator } from '@/components/ui/separator';
import { import {
Table, Table,
TableBody, TableBody,
@ -17,8 +17,8 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '../../ui/table'; } from '@/components/ui/table';
import { Textarea } from '../../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
interface ApplicationDetailsExtendedModalsProps { interface ApplicationDetailsExtendedModalsProps {
[key: string]: any; [key: string]: any;
@ -96,7 +96,10 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
return ( return (
<> <>
<Dialog open={showKTMatrixModal} onOpenChange={setShowKTMatrixModal}> <Dialog open={showKTMatrixModal} onOpenChange={setShowKTMatrixModal}>
<DialogContent className="flex min-h-0 max-h-[90vh] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-lg"> <DialogContent
className="flex min-h-0 max-h-[90vh] w-[calc(100%-2rem)] max-w-lg flex-col gap-0 overflow-hidden p-0 sm:max-w-lg"
data-testid="onboarding-kt-matrix-modal"
>
<DialogHeader className="shrink-0 space-y-2 border-b px-5 py-4 text-left"> <DialogHeader className="shrink-0 space-y-2 border-b px-5 py-4 text-left">
<DialogTitle className="text-base">KT matrix</DialogTitle> <DialogTitle className="text-base">KT matrix</DialogTitle>
<DialogDescription className="text-sm leading-relaxed"> <DialogDescription className="text-sm leading-relaxed">
@ -121,12 +124,12 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
if (option) handleKTMatrixChange(criterion.name, option.value, option.score); if (option) handleKTMatrixChange(criterion.name, option.value, option.score);
}} }}
> >
<SelectTrigger id={`kt-matrix-${idx}`} className="h-10 w-full text-left text-sm font-normal"> <SelectTrigger id={`kt-matrix-${idx}`} className="h-10 w-full text-left text-sm font-normal" data-testid={`onboarding-kt-matrix-select-${idx}`}>
<SelectValue placeholder="Choose an option…" /> <SelectValue placeholder="Choose an option…" />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper" className="max-h-72 w-[var(--radix-select-trigger-width)]"> <SelectContent position="popper" className="max-h-72 w-[var(--radix-select-trigger-width)]">
{criterion.options.map((option: any) => ( {criterion.options.map((option: any) => (
<SelectItem key={option.value} value={option.value} className="py-2.5 text-sm leading-snug"> <SelectItem key={option.value} value={option.value} className="py-2.5 text-sm leading-snug" data-testid={`onboarding-kt-matrix-option-${idx}-${option.value}`}>
{option.label} <span className="text-muted-foreground">({option.score})</span> {option.label} <span className="text-muted-foreground">({option.score})</span>
</SelectItem> </SelectItem>
))} ))}
@ -136,22 +139,29 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
))} ))}
<div className="space-y-2 border-t border-border pt-6"> <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">Notes <span className="font-normal text-muted-foreground">(optional)</span></Label>
<Textarea id="kt-matrix-remarks" placeholder="Optional remarks…" className="min-h-[96px] resize-y text-sm leading-relaxed" value={ktMatrixRemarks} onChange={(e) => setKtMatrixRemarks(e.target.value)} /> <Textarea
id="kt-matrix-remarks"
placeholder="Optional remarks…"
className="min-h-[96px] resize-y text-sm leading-relaxed"
value={ktMatrixRemarks}
onChange={(e) => setKtMatrixRemarks(e.target.value)}
data-testid="onboarding-kt-matrix-remarks-textarea"
/>
</div> </div>
</div> </div>
</div> </div>
<div className="flex shrink-0 flex-col gap-4 border-t px-5 py-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex shrink-0 flex-col gap-4 border-t px-5 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">Weighted total <span className="font-semibold tabular-nums text-foreground">{calculateKTScore()}</span><span className="text-muted-foreground"> / 100</span></p> <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"> <div className="flex gap-2 sm:shrink-0">
<Button variant="outline" onClick={() => setShowKTMatrixModal(false)}>Cancel</Button> <Button variant="outline" onClick={() => setShowKTMatrixModal(false)} data-testid="onboarding-kt-matrix-cancel">Cancel</Button>
<Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length}>{isSubmittingKT ? 'Saving…' : 'Submit'}</Button> <Button onClick={handleSubmitKTMatrix} disabled={isSubmittingKT || Object.keys(ktMatrixSelectedValues).length < KT_MATRIX_CRITERIA.length} data-testid="onboarding-kt-matrix-submit">{isSubmittingKT ? 'Saving…' : 'Submit'}</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showLevel2FeedbackModal} onOpenChange={setShowLevel2FeedbackModal}> <Dialog open={showLevel2FeedbackModal} onOpenChange={setShowLevel2FeedbackModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto" data-testid="onboarding-level2-feedback-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Level 2 Interview Feedback</DialogTitle> <DialogTitle>Level 2 Interview Feedback</DialogTitle>
<DialogDescription>Provide detailed feedback from the Level 2 interview (DD Lead + ZBH evaluation).</DialogDescription> <DialogDescription>Provide detailed feedback from the Level 2 interview (DD Lead + ZBH evaluation).</DialogDescription>
@ -162,35 +172,35 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<div> <div>
<Label>Overall Performance Score</Label> <Label>Overall Performance Score</Label>
<Select value={level2Feedback.overallScore} onValueChange={(value) => handleLevel2Change('overallScore', value)}> <Select value={level2Feedback.overallScore} onValueChange={(value) => handleLevel2Change('overallScore', value)}>
<SelectTrigger className="mt-2"><SelectValue placeholder="Select score" /></SelectTrigger> <SelectTrigger className="mt-2" data-testid="onboarding-level2-overall-score-select"><SelectValue placeholder="Select score" /></SelectTrigger>
<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> <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> </Select>
</div> </div>
<Separator /> <Separator />
<div><Label>Strategic Vision</Label><Textarea placeholder="Evaluate the candidate's strategic thinking and long-term vision..." className="mt-2" rows={3} value={level2Feedback.strategicVision} onChange={(e) => handleLevel2Change('strategicVision', e.target.value)} /></div> <div><Label>Strategic Vision</Label><Textarea placeholder="Evaluate the candidate's strategic thinking and long-term vision..." className="mt-2" rows={3} value={level2Feedback.strategicVision} onChange={(e) => handleLevel2Change('strategicVision', e.target.value)} data-testid="onboarding-level2-strategic-vision-textarea" /></div>
<div><Label>Management Capabilities</Label><Textarea placeholder="Assess leadership and team management potential..." className="mt-2" rows={3} value={level2Feedback.managementCapabilities} onChange={(e) => handleLevel2Change('managementCapabilities', e.target.value)} /></div> <div><Label>Management Capabilities</Label><Textarea placeholder="Assess leadership and team management potential..." className="mt-2" rows={3} value={level2Feedback.managementCapabilities} onChange={(e) => handleLevel2Change('managementCapabilities', e.target.value)} data-testid="onboarding-level2-management-textarea" /></div>
<div><Label>Operational Understanding</Label><Textarea placeholder="Review understanding of dealership operations and processes..." className="mt-2" rows={3} value={level2Feedback.operationalUnderstanding} onChange={(e) => handleLevel2Change('operationalUnderstanding', e.target.value)} /></div> <div><Label>Operational Understanding</Label><Textarea placeholder="Review understanding of dealership operations and processes..." className="mt-2" rows={3} value={level2Feedback.operationalUnderstanding} onChange={(e) => handleLevel2Change('operationalUnderstanding', e.target.value)} data-testid="onboarding-level2-operational-textarea" /></div>
<div><Label>Key Strengths</Label><Textarea placeholder="List the candidate's key strengths and positive attributes..." className="mt-2" rows={3} value={level2Feedback.keyStrengths} onChange={(e) => handleLevel2Change('keyStrengths', e.target.value)} /></div> <div><Label>Key Strengths</Label><Textarea placeholder="List the candidate's key strengths and positive attributes..." className="mt-2" rows={3} value={level2Feedback.keyStrengths} onChange={(e) => handleLevel2Change('keyStrengths', e.target.value)} data-testid="onboarding-level2-strengths-textarea" /></div>
<div><Label>Areas of Concern</Label><Textarea placeholder="Highlight any concerns or areas needing improvement..." className="mt-2" rows={3} value={level2Feedback.areasOfConcern} onChange={(e) => handleLevel2Change('areasOfConcern', e.target.value)} /></div> <div><Label>Areas of Concern</Label><Textarea placeholder="Highlight any concerns or areas needing improvement..." className="mt-2" rows={3} value={level2Feedback.areasOfConcern} onChange={(e) => handleLevel2Change('areasOfConcern', e.target.value)} data-testid="onboarding-level2-concerns-textarea" /></div>
<div><Label>Additional Comments</Label><Textarea placeholder="Any additional observations or comments..." className="mt-2" rows={3} value={level2Feedback.additionalComments} onChange={(e) => handleLevel2Change('additionalComments', e.target.value)} /></div> <div><Label>Additional Comments</Label><Textarea placeholder="Any additional observations or comments..." className="mt-2" rows={3} value={level2Feedback.additionalComments} onChange={(e) => handleLevel2Change('additionalComments', e.target.value)} data-testid="onboarding-level2-comments-textarea" /></div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowLevel2FeedbackModal(false)}>Cancel</Button> <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}>{isSubmittingLevel2 ? 'Submitting...' : 'Submit Feedback'}</Button> <Button className="flex-1 bg-black hover:bg-zinc-800 text-white" onClick={handleSubmitLevel2Feedback} disabled={isSubmittingLevel2} data-testid="onboarding-level2-feedback-submit">{isSubmittingLevel2 ? 'Submitting...' : 'Submit Feedback'}</Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Dialog open={showFeedbackDetailsModal} onOpenChange={setShowFeedbackDetailsModal}> <Dialog open={showFeedbackDetailsModal} onOpenChange={setShowFeedbackDetailsModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto" data-testid="onboarding-feedback-details-modal">
<DialogHeader><DialogTitle>Interview Feedback Details</DialogTitle></DialogHeader> <DialogHeader><DialogTitle>Interview Feedback Details</DialogTitle></DialogHeader>
{selectedEvaluationForView && ( {selectedEvaluationForView && (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4 bg-slate-50 p-4 rounded-lg"> <div className="grid grid-cols-2 gap-4 bg-slate-50 p-4 rounded-lg">
<div><p className="text-sm font-medium text-slate-500">Interviewer</p><p className="font-semibold">{selectedEvaluationForView.evaluator?.fullName}</p></div> <div><p className="text-sm font-medium text-slate-500">Interviewer</p><p className="font-semibold" data-testid="onboarding-feedback-details-interviewer">{selectedEvaluationForView.evaluator?.fullName}</p></div>
<div><p className="text-sm font-medium text-slate-500">Role</p><p>{selectedEvaluationForView.evaluator?.role?.roleName || 'N/A'}</p></div> <div><p className="text-sm font-medium text-slate-500">Role</p><p data-testid="onboarding-feedback-details-role">{selectedEvaluationForView.evaluator?.role?.roleName || 'N/A'}</p></div>
<div><p className="text-sm font-medium text-slate-500">{selectedEvaluationForView.interview?.level === 1 ? 'Score (KT Matrix)' : 'Overall Score'}</p><p className="font-bold text-lg">{selectedEvaluationForView.ktMatrixScore ? `${selectedEvaluationForView.ktMatrixScore}/${selectedEvaluationForView.interview?.level === 1 ? '100' : '10'}` : 'N/A'}</p></div> <div><p className="text-sm font-medium text-slate-500">{selectedEvaluationForView.interview?.level === 1 ? 'Score (KT Matrix)' : 'Overall Score'}</p><p className="font-bold text-lg" data-testid="onboarding-feedback-details-score">{selectedEvaluationForView.ktMatrixScore ? `${selectedEvaluationForView.ktMatrixScore}/${selectedEvaluationForView.interview?.level === 1 ? '100' : '10'}` : 'N/A'}</p></div>
<div><p className="text-sm font-medium text-slate-500">Recommendation</p><Badge variant={selectedEvaluationForView.recommendation?.toLowerCase().includes('reject') ? 'destructive' : selectedEvaluationForView.recommendation?.toLowerCase().includes('hold') ? 'secondary' : 'default'}>{selectedEvaluationForView.recommendation || 'N/A'}</Badge></div> <div><p className="text-sm font-medium text-slate-500">Recommendation</p><Badge variant={selectedEvaluationForView.recommendation?.toLowerCase().includes('reject') ? 'destructive' : selectedEvaluationForView.recommendation?.toLowerCase().includes('hold') ? 'secondary' : 'default'} data-testid="onboarding-feedback-details-recommendation">{selectedEvaluationForView.recommendation || 'N/A'}</Badge></div>
</div> </div>
<Separator /> <Separator />
<div> <div>
@ -198,7 +208,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
{selectedEvaluationForView.feedbackDetails?.length > 0 ? ( {selectedEvaluationForView.feedbackDetails?.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{selectedEvaluationForView.feedbackDetails.map((detail: any, index: number) => ( {selectedEvaluationForView.feedbackDetails.map((detail: any, index: number) => (
<div key={index} className="border-b last:border-0 pb-3 last:pb-0"> <div key={index} className="border-b last:border-0 pb-3 last:pb-0" data-testid={`onboarding-feedback-detail-item-${index}`}>
<p className="font-medium text-slate-900">{detail.feedbackType}</p> <p className="font-medium text-slate-900">{detail.feedbackType}</p>
<p className="text-slate-700 mt-1 whitespace-pre-wrap text-sm">{detail.comments}</p> <p className="text-slate-700 mt-1 whitespace-pre-wrap text-sm">{detail.comments}</p>
</div> </div>
@ -214,7 +224,7 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
</Dialog> </Dialog>
<Dialog open={showLevel3FeedbackModal} onOpenChange={setShowLevel3FeedbackModal}> <Dialog open={showLevel3FeedbackModal} onOpenChange={setShowLevel3FeedbackModal}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto" data-testid="onboarding-level3-feedback-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Level 3 Interview Feedback</DialogTitle> <DialogTitle>Level 3 Interview Feedback</DialogTitle>
<DialogDescription>Provide detailed feedback from the Level 3 interview (NBH + DD-Head evaluation).</DialogDescription> <DialogDescription>Provide detailed feedback from the Level 3 interview (NBH + DD-Head evaluation).</DialogDescription>
@ -225,21 +235,265 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
<div> <div>
<Label>Overall Performance Score</Label> <Label>Overall Performance Score</Label>
<Select value={level3Feedback.overallScore} onValueChange={(value) => handleLevel3Change('overallScore', value)}> <Select value={level3Feedback.overallScore} onValueChange={(value) => handleLevel3Change('overallScore', value)}>
<SelectTrigger className="mt-2"><SelectValue placeholder="Select score" /></SelectTrigger> <SelectTrigger className="mt-2" data-testid="onboarding-level3-overall-score-select"><SelectValue placeholder="Select score" /></SelectTrigger>
<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> <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> </Select>
</div> </div>
<Separator /> <Separator />
<div><Label>Business Vision & Strategy</Label><Textarea placeholder="Evaluate the candidate's long-term business vision and strategic planning..." className="mt-2" rows={3} value={level3Feedback.strategicVision} onChange={(e) => handleLevel3Change('strategicVision', e.target.value)} /></div> <div><Label>Business Vision & Strategy</Label><Textarea placeholder="Evaluate the candidate's long-term business vision and strategic planning..." className="mt-2" rows={3} value={level3Feedback.strategicVision} onChange={(e) => handleLevel3Change('strategicVision', e.target.value)} data-testid="onboarding-level3-strategic-vision-textarea" /></div>
<div><Label>Leadership & Decision Making</Label><Textarea placeholder="Assess leadership qualities and decision-making capabilities..." className="mt-2" rows={3} value={level3Feedback.managementCapabilities} onChange={(e) => handleLevel3Change('managementCapabilities', e.target.value)} /></div> <div><Label>Leadership & Decision Making</Label><Textarea placeholder="Assess leadership qualities and decision-making capabilities..." className="mt-2" rows={3} value={level3Feedback.managementCapabilities} onChange={(e) => handleLevel3Change('managementCapabilities', e.target.value)} data-testid="onboarding-level3-management-textarea" /></div>
<div><Label>Operational & Financial Readiness</Label><Textarea placeholder="Review financial commitment and investment readiness..." className="mt-2" rows={3} value={level3Feedback.operationalUnderstanding} onChange={(e) => handleLevel3Change('operationalUnderstanding', e.target.value)} /></div> <div><Label>Operational & Financial Readiness</Label><Textarea placeholder="Review financial commitment and investment readiness..." className="mt-2" rows={3} value={level3Feedback.operationalUnderstanding} onChange={(e) => handleLevel3Change('operationalUnderstanding', e.target.value)} data-testid="onboarding-level3-operational-textarea" /></div>
<div><Label>Brand Alignment</Label><Textarea placeholder="Evaluate alignment with Royal Enfield brand values and culture..." className="mt-2" rows={3} value={level3Feedback.brandAlignment} onChange={(e) => handleLevel3Change('brandAlignment', e.target.value)} /></div> <div><Label>Brand Alignment</Label><Textarea placeholder="Evaluate alignment with Royal Enfield brand values and culture..." className="mt-2" rows={3} value={level3Feedback.brandAlignment} onChange={(e) => handleLevel3Change('brandAlignment', e.target.value)} data-testid="onboarding-level3-brand-alignment-textarea" /></div>
<div><Label>Key Strengths</Label><Textarea placeholder="List the candidate's key strengths and exceptional qualities..." className="mt-2" rows={3} value={level3Feedback.keyStrengths} onChange={(e) => handleLevel3Change('keyStrengths', e.target.value)} /></div> <div><Label>Key Strengths</Label><Textarea placeholder="List the candidate's key strengths and exceptional qualities..." className="mt-2" rows={3} value={level3Feedback.keyStrengths} onChange={(e) => handleLevel3Change('keyStrengths', e.target.value)} data-testid="onboarding-level3-strengths-textarea" /></div>
<div><Label>Areas of Concern</Label><Textarea placeholder="Highlight any red flags or major concerns..." className="mt-2" rows={3} value={level3Feedback.areasOfConcern} onChange={(e) => handleLevel3Change('areasOfConcern', e.target.value)} /></div> <div><Label>Areas of Concern</Label><Textarea placeholder="Highlight any red flags or major concerns..." className="mt-2" rows={3} value={level3Feedback.areasOfConcern} onChange={(e) => handleLevel3Change('areasOfConcern', e.target.value)} data-testid="onboarding-level3-concerns-textarea" /></div>
<div><Label>Executive Summary</Label><Textarea placeholder="Provide a comprehensive executive summary of the interview and final thoughts..." className="mt-2" rows={4} value={level3Feedback.executiveSummary} onChange={(e) => handleLevel3Change('executiveSummary', e.target.value)} /></div> <div><Label>Executive Summary</Label><Textarea placeholder="Provide a comprehensive executive summary of the interview and final thoughts..." className="mt-2" rows={4} value={level3Feedback.executiveSummary} onChange={(e) => handleLevel3Change('executiveSummary', e.target.value)} data-testid="onboarding-level3-summary-textarea" /></div>
<div className="flex gap-3"> <div className="flex gap-3">
<Button variant="outline" className="flex-1" onClick={() => setShowLevel3FeedbackModal(false)}>Cancel</Button> <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}>{isSubmittingLevel3 ? 'Submitting...' : 'Submit Feedback'}</Button> <Button className="flex-1 bg-black hover:bg-zinc-800 text-white" onClick={handleSubmitLevel3Feedback} disabled={isSubmittingLevel3} data-testid="onboarding-level3-feedback-submit">{isSubmittingLevel3 ? 'Submitting...' : 'Submit Feedback'}</Button>
</div>
</div>
</DialogContent>
</Dialog>
<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>
<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">
<Table className="w-full table-auto">
<TableHeader className="bg-slate-50/80 sticky top-0 z-10">
<TableRow className="hover:bg-transparent border-b">
<TableHead className="w-[45%] min-w-[150px] font-semibold text-slate-900 py-3">Document Name</TableHead>
<TableHead className="w-[15%] min-w-[100px] font-semibold text-slate-900 py-3">Type</TableHead>
<TableHead className="w-[15%] min-w-[100px] font-semibold text-slate-900 py-3">Upload Date</TableHead>
<TableHead className="w-[15%] min-w-[140px] font-semibold text-slate-900 py-3">Uploaded By</TableHead>
<TableHead className="text-right w-[10%] min-w-[80px] font-semibold text-slate-900 py-3">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{getDocumentsForStage(selectedStage || '').map((doc: any, index: number) => (
<TableRow key={doc.id} className="hover:bg-slate-50/50 transition-colors" data-testid={`onboarding-document-row-${index}`}>
<TableCell className="py-3"><div className="flex items-center gap-2 min-w-0"><FileText className="w-4 h-4 text-slate-400 shrink-0" /><span className="truncate font-medium text-slate-700" title={doc.fileName} data-testid={`onboarding-document-name-${index}`}>{doc.fileName}</span></div></TableCell>
<TableCell className="py-3"><Badge variant="outline" className="capitalize whitespace-nowrap font-normal border-slate-200 bg-white" data-testid={`onboarding-document-type-${index}`}>{doc.documentType?.toLowerCase() || 'Other'}</Badge></TableCell>
<TableCell className="py-3 whitespace-nowrap text-slate-600">{formatDateTime(doc.createdAt)}</TableCell>
<TableCell className="py-3 text-slate-600">{doc.uploader?.fullName || (doc.uploadedBy ? 'System User' : 'Applicant')}</TableCell>
<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 = process.env.NEXT_PUBLIC_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>
))}
</TableBody>
</Table>
</div>
) : (
<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 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="space-y-2">
<Label className="text-slate-700 font-semibold px-1">Stage context</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</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 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>
</div>
<div className="space-y-2">
<Label className="text-slate-700 font-semibold px-1">Select File</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" />
</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">
{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>
</div>
)}
</DialogContent>
</Dialog>
<DocumentPreviewModal isOpen={showPreviewModal} onClose={() => setShowPreviewModal(false)} document={previewDoc} />
<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="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>
</DialogHeader>
<div className="space-y-4">
{(currentUser?.role !== 'FDD' && currentUser?.roleCode !== 'FDD') && (
<div className="space-y-2">
<Label className="text-[10px] font-black uppercase tracking-widest text-slate-400">Auditor Recommendation</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>
))}
</div>
</div>
)}
<div className="space-y-2">
<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"
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="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"
disabled={isFinalizingFdd || !fddAuditFindings}
data-testid="onboarding-fdd-finalize-submit"
onClick={async () => {
try {
setIsFinalizingFdd(true);
await onboardingService.submitStageDecision({
applicationId: application!.id,
stageCode: 'FDD_VERIFICATION',
decision: 'Approved',
remarks: (currentUser?.role === 'FDD' || currentUser?.roleCode === 'FDD')
? `Findings: ${fddAuditFindings}`
: `[RECOMMENDATION: ${fddAuditRecommendation}] \nFindings: ${fddAuditFindings}`,
nextStatus: 'LOI In Progress',
nextProgress: 65
});
toast.success('FDD Audit finalized and submitted.');
setShowFddFinalizeModal(false);
fetchApplication();
} catch {
toast.error('Submission failed');
} finally {
setIsFinalizingFdd(false);
}
}}
>
{isFinalizingFdd ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Confirm & Submit'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={showFddFlagModal} onOpenChange={setShowFddFlagModal}>
<DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl rounded-3xl" data-testid="onboarding-fdd-flag-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-red-600/20 to-transparent" /><div className="w-20 h-20 bg-red-600/20 rounded-full flex items-center justify-center relative z-10 shadow-[0_0_40px_rgba(220,38,38,0.2)]"><ShieldAlert className="w-10 h-10 text-red-500" /></div></div>
<div className="p-8 space-y-6 bg-white text-center">
<DialogHeader>
<DialogTitle className="text-2xl font-black text-slate-900 tracking-tight">Flag Non-Responsive</DialogTitle>
<DialogDescription className="text-slate-500 pt-2 leading-relaxed text-sm font-medium">Are you sure you want to flag this applicant? This will notify the DD Admin that the audit cannot proceed due to applicant's non-cooperation.</DialogDescription>
</DialogHeader>
<div className="bg-red-50 p-4 rounded-2xl flex gap-3 border border-red-100"><AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" /><p className="text-[11px] text-red-800 text-left font-medium">"Applicant is unresponsive to multiple queries and financial document requests."</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={() => setShowFddFlagModal(false)} disabled={isFddFlagging} data-testid="onboarding-fdd-flag-cancel">Go Back</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-red-600"
disabled={isFddFlagging}
data-testid="onboarding-fdd-flag-submit"
onClick={async () => {
try {
setIsFddFlagging(true);
await onboardingService.submitStageDecision({
applicationId: application!.id,
stageCode: 'FDD_VERIFICATION',
decision: 'Rejected',
remarks: 'Applicant is non-responsive to FDD queries.'
});
toast.error('Applicant flagged as non-responsive.');
setShowFddFlagModal(false);
fetchApplication();
} catch {
toast.error('Action failed');
} finally {
setIsFddFlagging(false);
}
}}
>
{isFddFlagging ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Confirm Flag'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<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="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>
</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</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>
<SelectContent>
<SelectItem value="Proprietorship" data-testid="onboarding-firm-type-proprietorship">Proprietorship</SelectItem>
<SelectItem value="Partnership" data-testid="onboarding-firm-type-partnership">Partnership</SelectItem>
<SelectItem value="Limited Liability partnership" data-testid="onboarding-firm-type-llp">LLP (Limited Liability partnership)</SelectItem>
<SelectItem value="Private Limited Company" data-testid="onboarding-firm-type-pvt-ltd">Private Limited Company</SelectItem>
<SelectItem value="Public Limited Company" data-testid="onboarding-firm-type-pub-ltd">Public Limited Company</SelectItem>
</SelectContent>
</Select>
</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>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
@ -483,3 +737,5 @@ export function ApplicationDetailsExtendedModals(props: ApplicationDetailsExtend
</> </>
); );
} }

View File

@ -1,10 +1,10 @@
import { AlertCircle, CheckCircle, ClipboardList, Download, Eye, FileText, ShieldAlert, ShieldCheck, Upload } from 'lucide-react'; import { AlertCircle, CheckCircle, ClipboardList, Download, Eye, FileText, ShieldAlert, ShieldCheck, Upload } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { cn, formatDateTime } from '@/components/ui/utils'; import { cn, formatDateTime } from '@/components/ui/utils';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface Props { interface Props {
application: any; application: any;
@ -74,7 +74,7 @@ export function ApplicationDetailsFddAuditContent({
if (!hasAssignment && !['FDD Verification', 'LOI In Progress', 'Payment Pending'].includes(application.status)) { if (!hasAssignment && !['FDD Verification', 'LOI In Progress', 'Payment Pending'].includes(application.status)) {
return ( return (
<div className="space-y-6"> <div className="space-y-6" data-testid="onboarding-fdd-no-assignment">
<div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200"> <div className="flex flex-col items-center justify-center p-12 bg-slate-50 rounded-2xl border-2 border-dashed border-slate-200">
<ShieldCheck className="w-12 h-12 text-slate-300 mb-4" /> <ShieldCheck className="w-12 h-12 text-slate-300 mb-4" />
<h3 className="text-slate-900 font-semibold uppercase tracking-widest text-xs">No FDD Assignment</h3> <h3 className="text-slate-900 font-semibold uppercase tracking-widest text-xs">No FDD Assignment</h3>
@ -84,7 +84,7 @@ export function ApplicationDetailsFddAuditContent({
</div> </div>
{(currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin') && ( {(currentUser?.role === 'DD Admin' || currentUser?.role === 'Super Admin') && (
<Card className="border-amber-100 bg-amber-50/30 overflow-hidden rounded-2xl"> <Card className="border-amber-100 bg-amber-50/30 overflow-hidden rounded-2xl" data-testid="onboarding-fdd-initiate-card">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<CardTitle className="text-xs font-black uppercase tracking-widest text-amber-800 flex items-center gap-2"> <CardTitle className="text-xs font-black uppercase tracking-widest text-amber-800 flex items-center gap-2">
<ShieldAlert className="w-4 h-4" /> <ShieldAlert className="w-4 h-4" />
@ -99,10 +99,11 @@ export function ApplicationDetailsFddAuditContent({
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" 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} value={selectedAgencyId}
onChange={(e) => setSelectedAgencyId(e.target.value)} onChange={(e) => setSelectedAgencyId(e.target.value)}
data-testid="onboarding-fdd-agency-select"
> >
<option value="">Choose partner agency...</option> <option value="">Choose partner agency...</option>
{(fddAgencies || []).map((agency: any) => ( {(fddAgencies || []).map((agency: any) => (
<option key={agency.id} value={agency.id}> <option key={agency.id} value={agency.id} data-testid={`onboarding-fdd-agency-option-${agency.id}`}>
{agency.fullName || agency.name} ({agency.email}) {agency.fullName || agency.name} ({agency.email})
</option> </option>
))} ))}
@ -113,6 +114,7 @@ export function ApplicationDetailsFddAuditContent({
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]" 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} onClick={handleAssignAgency}
disabled={isAssigningAgency || !selectedAgencyId} disabled={isAssigningAgency || !selectedAgencyId}
data-testid="onboarding-fdd-assign-button"
> >
{isAssigningAgency ? 'Assigning...' : 'Assign & Start Audit'} {isAssigningAgency ? 'Assigning...' : 'Assign & Start Audit'}
</Button> </Button>
@ -126,22 +128,22 @@ export function ApplicationDetailsFddAuditContent({
} }
return ( return (
<div className="space-y-8"> <div className="space-y-8" data-testid="onboarding-fdd-audit-content">
{hasAssignment && ( {hasAssignment && (
<div className="flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-xl mb-6"> <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="flex items-center gap-3">
<div className="p-2 bg-amber-100 rounded-lg"> <div className="p-2 bg-amber-100 rounded-lg">
<ShieldCheck className="w-5 h-5 text-amber-600" /> <ShieldCheck className="w-5 h-5 text-amber-600" />
</div> </div>
<div> <div>
<h4 className="text-sm font-bold text-slate-900">FDD Assignment Active</h4> <h4 className="text-sm font-bold text-slate-900">FDD Assignment Active</h4>
{primaryFddUser && <p className="text-xs text-slate-500 font-medium">Assigned to: {primaryFddUser.name}</p>} {primaryFddUser && <p className="text-xs text-slate-500 font-medium" data-testid="onboarding-fdd-assigned-user">Assigned to: {primaryFddUser.name}</p>}
</div> </div>
</div> </div>
</div> </div>
)} )}
<Card className="border-slate-200 shadow-sm overflow-hidden rounded-2xl"> <Card className="border-slate-200 shadow-sm overflow-hidden rounded-2xl" data-testid="onboarding-fdd-checklist-card">
<CardHeader className="bg-slate-50/50 border-b border-slate-100 py-4"> <CardHeader className="bg-slate-50/50 border-b border-slate-100 py-4">
<CardTitle className="text-sm font-black uppercase tracking-widest text-slate-500 flex items-center justify-between"> <CardTitle className="text-sm font-black uppercase tracking-widest text-slate-500 flex items-center justify-between">
<div className="flex items-center gap-2"><ClipboardList className="w-4 h-4" /> Financial Artefacts Checklist</div> <div className="flex items-center gap-2"><ClipboardList className="w-4 h-4" /> Financial Artefacts Checklist</div>
@ -150,23 +152,23 @@ export function ApplicationDetailsFddAuditContent({
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{mandatoryFinancialDocs.map((docType) => { {mandatoryFinancialDocs.map((docType, index) => {
const doc = getDocByTypeName(docType.type); const doc = getDocByTypeName(docType.type);
return ( return (
<div key={docType.type} className="flex items-center justify-between p-4 px-6 hover:bg-slate-50/50 transition-colors"> <div key={docType.type} className="flex items-center justify-between p-4 px-6 hover:bg-slate-50/50 transition-colors" data-testid={`onboarding-fdd-checklist-item-${index}`}>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', doc ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-50 text-slate-300')}> <div className={cn('w-8 h-8 rounded-lg flex items-center justify-center', doc ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-50 text-slate-300')}>
{doc ? <CheckCircle className="w-5 h-5" /> : <AlertCircle className="w-5 h-5" />} {doc ? <CheckCircle className="w-5 h-5" /> : <AlertCircle className="w-5 h-5" />}
</div> </div>
<div> <div>
<p className="text-sm font-bold text-slate-800">{docType.label}</p> <p className="text-sm font-bold text-slate-800">{docType.label}</p>
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter"> <p className="text-[10px] text-slate-400 font-bold uppercase tracking-tighter" data-testid={`onboarding-fdd-checklist-status-${index}`}>
{doc ? `Uploaded: ${formatDateTime(doc.createdAt)}` : 'Missing in Documentation'} {doc ? `Uploaded: ${formatDateTime(doc.createdAt)}` : 'Missing in Documentation'}
</p> </p>
</div> </div>
</div> </div>
{doc ? ( {doc ? (
<Button variant="ghost" size="sm" className="h-8 text-blue-600 font-black text-[10px] uppercase tracking-widest hover:bg-blue-50" onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }}> <Button variant="ghost" size="sm" className="h-8 text-blue-600 font-black text-[10px] uppercase tracking-widest hover:bg-blue-50" onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} data-testid={`onboarding-fdd-checklist-preview-${index}`}>
<Eye className="w-4 h-4 mr-1" /> Preview <Eye className="w-4 h-4 mr-1" /> Preview
</Button> </Button>
) : ( ) : (
@ -174,6 +176,7 @@ export function ApplicationDetailsFddAuditContent({
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 border-slate-200 text-slate-500 font-black text-[10px] uppercase tracking-widest hover:bg-slate-50 hover:text-blue-600" className="h-8 border-slate-200 text-slate-500 font-black text-[10px] uppercase tracking-widest hover:bg-slate-50 hover:text-blue-600"
data-testid={`onboarding-fdd-checklist-upload-${index}`}
onClick={() => { onClick={() => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
@ -221,32 +224,35 @@ export function ApplicationDetailsFddAuditContent({
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Supporting Audit Documents</h3> <h3 className="text-lg font-semibold text-slate-900">Supporting Audit Documents</h3>
<Badge variant="outline" className="bg-slate-50 text-slate-500 border-slate-200"> <Badge variant="outline" className="bg-slate-50 text-slate-500 border-slate-200" data-testid="onboarding-fdd-support-docs-count">
{(documents || []).filter(isFddSupportDoc).length} Document(s) {(documents || []).filter(isFddSupportDoc).length} Document(s)
</Badge> </Badge>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <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) => ( {(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"> <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 className="flex items-center gap-3"> <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="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"> <div className="overflow-hidden">
<p className="text-slate-900 font-bold text-sm truncate max-w-[150px]" title={doc.fileName}>{doc.fileName}</p> <p className="text-slate-900 font-bold text-sm truncate max-w-[150px]" title={doc.fileName} data-testid={`onboarding-fdd-support-doc-name-${index}`}>{doc.fileName}</p>
<p className="text-slate-500 text-[10px] font-medium uppercase">{doc.documentType}</p> <p className="text-slate-500 text-[10px] font-medium uppercase">{doc.documentType}</p>
</div> </div>
</div> </div>
<div className="flex gap-1"> <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={() => window.open(`http://localhost:5000/${doc.filePath}`, '_blank')}> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-amber-600 hover:bg-amber-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" /> <Download className="w-4 h-4" />
</Button> </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); }}> <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}`}>
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
))} ))}
{(documents || []).filter(isFddSupportDoc).length === 0 && ( {(documents || []).filter(isFddSupportDoc).length === 0 && (
<div className="col-span-full p-8 text-center bg-slate-50 rounded-xl border border-dashed border-slate-200"> <div className="col-span-full p-8 text-center bg-slate-50 rounded-xl border border-dashed border-slate-200" data-testid="onboarding-fdd-support-docs-empty">
<p className="text-slate-400 text-sm">No supporting audit documents uploaded yet.</p> <p className="text-slate-400 text-sm">No supporting audit documents uploaded yet.</p>
</div> </div>
)} )}
@ -255,3 +261,5 @@ export function ApplicationDetailsFddAuditContent({
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
import { ArrowLeft, MessageSquare, ShieldAlert } from 'lucide-react'; import { ArrowLeft, MessageSquare, ShieldAlert } from 'lucide-react';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Application } from '../../../lib/mock-data'; import { Application } from '@/lib/mock-data';
interface ApplicationDetailsHeaderProps { interface ApplicationDetailsHeaderProps {
application: Application; application: Application;
@ -20,7 +20,10 @@ export function ApplicationDetailsHeader({
return ( return (
<> <>
{isNonResponsive && ( {isNonResponsive && (
<div className="bg-red-50 border border-red-200 p-4 rounded-2xl flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-500"> <div
className="bg-red-50 border border-red-200 p-4 rounded-2xl flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-500"
data-testid="onboarding-details-non-responsive-banner"
>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="bg-red-100 p-2 rounded-xl"> <div className="bg-red-100 p-2 rounded-xl">
<ShieldAlert className="w-6 h-6 text-red-600" /> <ShieldAlert className="w-6 h-6 text-red-600" />
@ -35,6 +38,7 @@ export function ApplicationDetailsHeader({
variant="outline" variant="outline"
size="sm" size="sm"
className="bg-white border-red-200 text-red-600 hover:bg-red-50 font-black text-[10px] uppercase tracking-widest hidden sm:block h-9" className="bg-white border-red-200 text-red-600 hover:bg-red-50 font-black text-[10px] uppercase tracking-widest hidden sm:block h-9"
data-testid="onboarding-details-review-audit-button"
onClick={() => { onClick={() => {
const worknotesTab = document.querySelector('[value="worknotes"]') as HTMLElement; const worknotesTab = document.querySelector('[value="worknotes"]') as HTMLElement;
worknotesTab?.click(); worknotesTab?.click();
@ -48,12 +52,12 @@ export function ApplicationDetailsHeader({
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button variant="outline" size="icon" onClick={onBack} className="shrink-0"> <Button variant="outline" size="icon" onClick={onBack} className="shrink-0" data-testid="onboarding-details-back-button">
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
</Button> </Button>
<div className="truncate"> <div className="truncate">
<h1 className="text-slate-900 truncate leading-tight">{application.name}</h1> <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">{application.registrationNumber}</p> <p className="text-slate-600 truncate text-sm" data-testid="onboarding-details-registration-number">{application.registrationNumber}</p>
</div> </div>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@ -61,6 +65,7 @@ export function ApplicationDetailsHeader({
variant="outline" variant="outline"
className="relative hover:bg-amber-50 hover:border-amber-300 hover:text-amber-700 transition-all shadow-sm" className="relative hover:bg-amber-50 hover:border-amber-300 hover:text-amber-700 transition-all shadow-sm"
onClick={onOpenWorknotes} onClick={onOpenWorknotes}
data-testid="onboarding-details-view-work-notes"
> >
<MessageSquare className="w-4 h-4 mr-2" /> <MessageSquare className="w-4 h-4 mr-2" />
View Work Notes View Work Notes
@ -70,3 +75,5 @@ export function ApplicationDetailsHeader({
</> </>
); );
} }

View File

@ -15,22 +15,22 @@ import {
Zap, Zap,
} from 'lucide-react'; } from 'lucide-react';
import { cn, formatDateTime } from '@/components/ui/utils'; import { cn, formatDateTime } from '@/components/ui/utils';
import { Alert, AlertDescription, AlertTitle } from '../../ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '../../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Input } from '../../ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '../../ui/label'; import { Label } from '@/components/ui/label';
import { Progress } from '../../ui/progress'; import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '../../ui/separator'; import { Separator } from '@/components/ui/separator';
interface ApplicationDetailsSidebarProps { interface ApplicationDetailsSidebarProps {
application: any; application: any;
@ -101,30 +101,33 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<Card> <Card data-testid="onboarding-details-summary-card">
<CardHeader> <CardHeader>
<CardTitle>Summary</CardTitle> <CardTitle>Summary</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<p className="text-slate-600">Registration ID</p> <p className="text-slate-600">Registration ID</p>
<p className="text-slate-900">{application.registrationNumber}</p> <p className="text-slate-900" data-testid="onboarding-details-summary-reg-id">{application.registrationNumber}</p>
</div> </div>
<div> <div>
<p className="text-slate-600">Current Status</p> <p className="text-slate-600">Current Status</p>
<Badge className={cn( <Badge
"mt-1", className={cn(
application.status === 'Onboarded' ? "bg-green-600 hover:bg-green-700 text-white" : "mt-1",
application.status === 'Rejected' ? "bg-red-600" : application.status === 'Onboarded' ? "bg-green-600 hover:bg-green-700 text-white" :
"bg-amber-600" application.status === 'Rejected' ? "bg-red-600" :
)}> "bg-amber-600"
)}
data-testid="onboarding-details-summary-status"
>
{application.status} {application.status}
</Badge> </Badge>
</div> </div>
{application.rank && ( {application.rank && (
<div> <div>
<p className="text-slate-600">Rank</p> <p className="text-slate-600">Rank</p>
<p className="text-slate-900"> <p className="text-slate-900" data-testid="onboarding-details-summary-rank">
{application.rank} of {application.totalApplicantsAtLocation} {application.rank} of {application.totalApplicantsAtLocation}
<span className="text-slate-500"> in {application.preferredLocation}</span> <span className="text-slate-500"> in {application.preferredLocation}</span>
</p> </p>
@ -133,27 +136,27 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
<div> <div>
<p className="text-slate-600">Progress</p> <p className="text-slate-600">Progress</p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Progress value={application.progress} className="flex-1" /> <Progress value={application.progress} className="flex-1" data-testid="onboarding-details-summary-progress-bar" />
<span className="text-slate-900">{application.progress}%</span> <span className="text-slate-900" data-testid="onboarding-details-summary-progress-text">{application.progress}%</span>
</div> </div>
</div> </div>
{application.deadline && ( {application.deadline && (
<div> <div>
<p className="text-slate-600">Questionnaire Deadline</p> <p className="text-slate-600">Questionnaire Deadline</p>
<p className="text-slate-900">{formatDateTime(application.deadline)}</p> <p className="text-slate-900" data-testid="onboarding-details-summary-deadline">{formatDateTime(application.deadline)}</p>
</div> </div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
{application.isShortlisted !== false && ( {application.isShortlisted !== false && (
<Card> <Card data-testid="onboarding-details-actions-card">
<CardHeader> <CardHeader>
<CardTitle>Actions</CardTitle> <CardTitle>Actions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{permissions.isLoaLocked && ( {permissions.isLoaLocked && (
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800"> <Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800" data-testid="onboarding-details-loa-locked-alert">
<Lock className="w-4 h-4 text-amber-600" /> <Lock className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-semibold">LOA approval locked</AlertTitle> <AlertTitle className="text-amber-900 font-semibold">LOA approval locked</AlertTitle>
<AlertDescription className="text-amber-800"> <AlertDescription className="text-amber-800">
@ -168,7 +171,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
!['LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded', 'Rejected'].includes( !['LOA Issued', 'EOR In Progress', 'EOR Complete', 'Inauguration', 'Approved', 'Onboarded', 'Rejected'].includes(
application.status, application.status,
) && ( ) && (
<Alert className="mb-4 border-violet-200 bg-violet-50/90 text-violet-950"> <Alert className="mb-4 border-violet-200 bg-violet-50/90 text-violet-950" data-testid="onboarding-details-first-fill-verified-alert">
<Info className="h-4 w-4 text-violet-700" /> <Info className="h-4 w-4 text-violet-700" />
<AlertTitle className="font-semibold">First Fill verified on file</AlertTitle> <AlertTitle className="font-semibold">First Fill verified on file</AlertTitle>
<AlertDescription className="text-sm text-violet-900/90 leading-relaxed"> <AlertDescription className="text-sm text-violet-900/90 leading-relaxed">
@ -182,7 +185,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)} )}
{permissions.isSecurityDetailsLocked && ( {permissions.isSecurityDetailsLocked && (
<Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800"> <Alert variant="destructive" className="mb-4 bg-amber-50 border-amber-200 text-amber-800" data-testid="onboarding-details-security-locked-alert">
<Lock className="w-4 h-4 text-amber-600" /> <Lock className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-semibold">Security Details approval locked</AlertTitle> <AlertTitle className="text-amber-900 font-semibold">Security Details approval locked</AlertTitle>
<AlertDescription className="text-amber-800"> <AlertDescription className="text-amber-800">
@ -193,7 +196,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)} )}
{['Security Details', 'Payment Pending'].includes(application.status) && ( {['Security Details', 'Payment Pending'].includes(application.status) && (
<Alert className="mb-4 border-sky-200 bg-sky-50/90 text-sky-900"> <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" /> <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 Details review</AlertTitle>
<AlertDescription className="text-sm text-sky-900/90 leading-relaxed"> <AlertDescription className="text-sm text-sky-900/90 leading-relaxed">
@ -205,7 +208,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)} )}
{isNonResponsive && isAdmin && ( {isNonResponsive && isAdmin && (
<Alert variant="destructive" className="mb-4 bg-red-50 border-red-200 text-red-800"> <Alert variant="destructive" className="mb-4 bg-red-50 border-red-200 text-red-800" data-testid="onboarding-details-non-responsive-alert">
<AlertCircle className="w-4 h-4 text-red-600" /> <AlertCircle className="w-4 h-4 text-red-600" />
<AlertTitle className="text-red-900 font-black uppercase tracking-tighter"> Non-Responsive Flag</AlertTitle> <AlertTitle className="text-red-900 font-black uppercase tracking-tighter"> Non-Responsive Flag</AlertTitle>
<AlertDescription className="text-red-800 text-xs font-bold leading-tight"> <AlertDescription className="text-red-800 text-xs font-bold leading-tight">
@ -215,7 +218,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)} )}
{isAdmin && (application.status === 'Level 3 Approved' || application.status === 'FDD Verification') && (!application.fddAssignments || application.fddAssignments.length === 0) && ( {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"> <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" /> <AlertCircle className="w-4 h-4 text-amber-600" />
<AlertTitle className="text-amber-900 font-bold">FDD Assignment Required</AlertTitle> <AlertTitle className="text-amber-900 font-bold">FDD Assignment Required</AlertTitle>
<AlertDescription className="text-amber-800 font-medium"> <AlertDescription className="text-amber-800 font-medium">
@ -225,34 +228,37 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)} )}
{permissions.canApprove && ( {permissions.canApprove && (
<Button className="w-full bg-green-600 hover:bg-green-700 font-bold" onClick={onOpenApproveModal}> <Button className="w-full bg-green-600 hover:bg-green-700 font-bold" onClick={onOpenApproveModal} data-testid="onboarding-details-approve-button">
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
{['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'} {['Inauguration', 'Approved'].includes(application.status) ? 'Onboard Dealer' : 'Approve'}
</Button> </Button>
)} )}
{permissions.canReject && ( {permissions.canReject && (
<Button variant="destructive" className="w-full font-bold" onClick={onOpenRejectModal}> <Button variant="destructive" className="w-full font-bold" onClick={onOpenRejectModal} data-testid="onboarding-details-reject-button">
<XCircle className="w-4 h-4 mr-2" /> <XCircle className="w-4 h-4 mr-2" />
Reject Reject
</Button> </Button>
)} )}
{permissions.showDecisionMessage && ( {permissions.showDecisionMessage && (
<div className={`w-full p-2 text-center rounded border ${(currentUserStageAction?.decision === 'Approved' || currentUserEvaluation?.decision === 'Approved' || currentUserEvaluation?.recommendation === 'Approved' || currentUserEvaluation?.decision === 'Selected') ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'}`}> <div
className={`w-full p-2 text-center rounded border ${(currentUserStageAction?.decision === 'Approved' || currentUserEvaluation?.decision === 'Approved' || currentUserEvaluation?.recommendation === 'Approved' || currentUserEvaluation?.decision === 'Selected') ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'}`}
data-testid="onboarding-details-decision-message"
>
You have {(currentUserStageAction?.decision === 'Approved' || currentUserEvaluation?.decision === 'Approved' || currentUserEvaluation?.recommendation === 'Approved' || currentUserEvaluation?.decision === 'Selected') ? 'Approved' : 'Rejected'} You have {(currentUserStageAction?.decision === 'Approved' || currentUserEvaluation?.decision === 'Approved' || currentUserEvaluation?.recommendation === 'Approved' || currentUserEvaluation?.decision === 'Selected') ? 'Approved' : 'Rejected'}
</div> </div>
)} )}
<Separator /> <Separator />
<Button variant="outline" className="w-full" onClick={onOpenWorknote}> <Button variant="outline" className="w-full" onClick={onOpenWorknote} data-testid="onboarding-details-worknote-button">
<MessageSquare className="w-4 h-4 mr-2" /> <MessageSquare className="w-4 h-4 mr-2" />
Work Note Work Note
</Button> </Button>
{permissions.canSchedule && ( {permissions.canSchedule && (
<Button variant="outline" className="w-full" onClick={onOpenScheduleModal}> <Button variant="outline" className="w-full" onClick={onOpenScheduleModal} data-testid="onboarding-details-schedule-button">
<Calendar className="w-4 h-4 mr-2" /> <Calendar className="w-4 h-4 mr-2" />
Schedule Interview Schedule Interview
</Button> </Button>
@ -262,7 +268,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
['Dealer Code Generation', 'LOA Pending', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion'].includes(application.status) && ( ['Dealer Code Generation', 'LOA Pending', 'Architecture Team Assigned', 'Architecture Document Upload', 'Architecture Team Completion'].includes(application.status) && (
<> <>
{!application.dealerCode && ( {!application.dealerCode && (
<Button className="w-full bg-blue-600 hover:bg-blue-700" onClick={handleGenerateDealerCodes}> <Button className="w-full bg-blue-600 hover:bg-blue-700" onClick={handleGenerateDealerCodes} data-testid="onboarding-details-generate-dealer-codes">
<Zap className="w-4 h-4 mr-2" /> <Zap className="w-4 h-4 mr-2" />
Generate Dealer Codes Generate Dealer Codes
</Button> </Button>
@ -273,6 +279,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
variant="outline" variant="outline"
className="w-full border-blue-200 hover:bg-blue-50 text-blue-700" className="w-full border-blue-200 hover:bg-blue-50 text-blue-700"
onClick={onOpenAssignArchitectureModal} onClick={onOpenAssignArchitectureModal}
data-testid="onboarding-details-assign-architecture"
> >
<GitBranch className="w-4 h-4 mr-2" /> <GitBranch className="w-4 h-4 mr-2" />
Assign Architecture Team Assign Architecture Team
@ -284,15 +291,16 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
{activeInterviewForUser && !hasSubmittedFeedback && ( {activeInterviewForUser && !hasSubmittedFeedback && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full" data-testid="onboarding-details-feedback-dropdown-trigger">
<Star className="w-4 h-4 mr-2" /> <Star className="w-4 h-4 mr-2" />
Interview Feedback Interview Feedback
<ChevronDown className="w-4 h-4 ml-auto" /> <ChevronDown className="w-4 h-4 ml-auto" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56"> <DropdownMenuContent className="w-56" data-testid="onboarding-details-feedback-dropdown-content">
<DropdownMenuItem <DropdownMenuItem
key={activeInterviewForUser.id} key={activeInterviewForUser.id}
data-testid={`onboarding-details-feedback-item-${activeInterviewForUser.id}`}
onClick={() => { onClick={() => {
setSelectedInterviewForFeedback(activeInterviewForUser); setSelectedInterviewForFeedback(activeInterviewForUser);
if (activeInterviewForUser.level === 1) setShowKTMatrixModal(true); if (activeInterviewForUser.level === 1) setShowKTMatrixModal(true);
@ -308,12 +316,12 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
{application.status === 'Questionnaire Pending' && ( {application.status === 'Questionnaire Pending' && (
<> <>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full" data-testid="onboarding-details-send-reminder">
<Mail className="w-4 h-4 mr-2" /> <Mail className="w-4 h-4 mr-2" />
Send Reminder Send Reminder
</Button> </Button>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full" data-testid="onboarding-details-extend-deadline">
<Clock className="w-4 h-4 mr-2" /> <Clock className="w-4 h-4 mr-2" />
Extend Deadline Extend Deadline
</Button> </Button>
@ -321,7 +329,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
)} )}
{application.dealer && ( {application.dealer && (
<div className="p-4 bg-green-50 border border-green-200 rounded-lg space-y-3"> <div className="p-4 bg-green-50 border border-green-200 rounded-lg space-y-3" data-testid="onboarding-details-dealer-active-banner">
<div className="flex items-center gap-2 text-green-800 font-semibold"> <div className="flex items-center gap-2 text-green-800 font-semibold">
<CheckCircle className="w-5 h-5 text-green-600" /> <CheckCircle className="w-5 h-5 text-green-600" />
Dealer Profile Active Dealer Profile Active
@ -330,12 +338,12 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
This application has been successfully onboarded as a dealer. A user account has been created for the dealer. This application has been successfully onboarded as a dealer. A user account has been created for the dealer.
</div> </div>
{application.dealerCode && ( {application.dealerCode && (
<div className="flex items-center justify-between text-xs font-mono bg-white p-2 rounded border border-green-100"> <div className="flex items-center justify-between text-xs font-mono bg-white p-2 rounded border border-green-100" data-testid="onboarding-details-active-dealer-code">
<span className="text-slate-500">Dealer Code:</span> <span className="text-slate-500">Dealer Code:</span>
<span className="font-bold text-slate-900">{application.dealerCode.code}</span> <span className="font-bold text-slate-900">{application.dealerCode.code}</span>
</div> </div>
)} )}
<Button className="w-full bg-green-600 hover:bg-green-700 text-white" onClick={onGoToDashboard}> <Button className="w-full bg-green-600 hover:bg-green-700 text-white" onClick={onGoToDashboard} data-testid="onboarding-details-goto-dashboard">
<Zap className="w-4 h-4 mr-2" /> <Zap className="w-4 h-4 mr-2" />
Go to Dealer Dashboard Go to Dealer Dashboard
</Button> </Button>
@ -345,12 +353,12 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
{currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role) && ( {currentUser && ['DD Admin', 'Super Admin'].includes(currentUser.role) && (
<Dialog open={showAssignModal} onOpenChange={setShowAssignModal}> <Dialog open={showAssignModal} onOpenChange={setShowAssignModal}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" className="w-full"> <Button variant="outline" className="w-full" data-testid="onboarding-details-assign-user-trigger">
<User className="w-4 h-4 mr-2" /> <User className="w-4 h-4 mr-2" />
Assign User Assign User
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent data-testid="onboarding-details-assign-user-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Assign User to Application</DialogTitle> <DialogTitle>Assign User to Application</DialogTitle>
<DialogDescription> <DialogDescription>
@ -361,7 +369,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
<div> <div>
<Label>Select User</Label> <Label>Select User</Label>
<Select value={selectedUser} onValueChange={setSelectedUser}> <Select value={selectedUser} onValueChange={setSelectedUser}>
<SelectTrigger className="mt-2"> <SelectTrigger className="mt-2" data-testid="onboarding-details-assign-user-select">
<SelectValue placeholder="Search users..." /> <SelectValue placeholder="Search users..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -376,7 +384,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
<div> <div>
<Label>Assignment Role</Label> <Label>Assignment Role</Label>
<Select value={participantType} onValueChange={setParticipantType}> <Select value={participantType} onValueChange={setParticipantType}>
<SelectTrigger className="mt-2"> <SelectTrigger className="mt-2" data-testid="onboarding-details-assign-role-select">
<SelectValue placeholder="Select role" /> <SelectValue placeholder="Select role" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -390,6 +398,7 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
className="w-full bg-amber-600 hover:bg-amber-700 font-bold h-11" className="w-full bg-amber-600 hover:bg-amber-700 font-bold h-11"
onClick={handleAddParticipant} onClick={handleAddParticipant}
disabled={isAssigningParticipant} disabled={isAssigningParticipant}
data-testid="onboarding-details-assign-user-submit"
> >
{isAssigningParticipant ? 'Assigning...' : 'Assign User'} {isAssigningParticipant ? 'Assigning...' : 'Assign User'}
</Button> </Button>
@ -403,3 +412,4 @@ export function ApplicationDetailsSidebar(props: ApplicationDetailsSidebarProps)
</div> </div>
); );
} }

View File

@ -21,13 +21,13 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cn, formatDateTime } from '@/components/ui/utils'; import { cn, formatDateTime } from '@/components/ui/utils';
import QuestionnaireResponseView from '../QuestionnaireResponseView'; import QuestionnaireResponseView from '../QuestionnaireResponseView';
import { Alert, AlertDescription, AlertTitle } from '../../ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '../../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '../../ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '../../ui/card'; import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Checkbox } from '../../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Progress } from '../../ui/progress'; import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '../../ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { import {
Table, Table,
TableBody, TableBody,
@ -35,8 +35,8 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '../../ui/table'; } from '@/components/ui/table';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface ApplicationDetailsTabsProps { interface ApplicationDetailsTabsProps {
application: any; application: any;
@ -108,38 +108,38 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
} = props; } = props;
return ( return (
<Card> <Card data-testid="onboarding-details-tabs-container">
<Tabs value={activeTab} onValueChange={setActiveTab}> <Tabs value={activeTab} onValueChange={setActiveTab}>
<CardHeader className="pb-4 px-4 sm:px-6"> <CardHeader className="pb-4 px-4 sm:px-6">
<div className="overflow-x-auto custom-scrollbar-x -mx-4 px-4 sm:-mx-6 sm:px-6"> <div className="overflow-x-auto custom-scrollbar-x -mx-4 px-4 sm:-mx-6 sm:px-6">
<TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1"> <TabsList className="w-max min-w-full justify-start h-11 bg-slate-100/80 p-1" data-testid="onboarding-tabs-list">
<TabsTrigger value="questionnaire" className="min-w-[120px]">Questionnaire</TabsTrigger> <TabsTrigger value="questionnaire" className="min-w-[120px]" data-testid="onboarding-tab-trigger-questionnaire">Questionnaire</TabsTrigger>
<TabsTrigger value="progress" className="min-w-[80px]">Progress</TabsTrigger> <TabsTrigger value="progress" className="min-w-[80px]" data-testid="onboarding-tab-trigger-progress">Progress</TabsTrigger>
<TabsTrigger value="documents" className="min-w-[100px]">Documents</TabsTrigger> <TabsTrigger value="documents" className="min-w-[100px]" data-testid="onboarding-tab-trigger-documents">Documents</TabsTrigger>
<TabsTrigger value="interviews" className="min-w-[100px]">Interviews</TabsTrigger> <TabsTrigger value="interviews" className="min-w-[100px]" data-testid="onboarding-tab-trigger-interviews">Interviews</TabsTrigger>
<TabsTrigger value="fdd" className="min-w-[120px]">FDD Audit</TabsTrigger> <TabsTrigger value="fdd" className="min-w-[120px]" data-testid="onboarding-tab-trigger-fdd">FDD Audit</TabsTrigger>
<TabsTrigger value="eor" className="min-w-[120px]">EOR Checklist</TabsTrigger> <TabsTrigger value="eor" className="min-w-[120px]" data-testid="onboarding-tab-trigger-eor">EOR Checklist</TabsTrigger>
<TabsTrigger value="payments" className="min-w-[100px]">Payments</TabsTrigger> <TabsTrigger value="payments" className="min-w-[100px]" data-testid="onboarding-tab-trigger-payments">Payments</TabsTrigger>
<TabsTrigger value="audit" className="min-w-[100px]">Audit Trail</TabsTrigger> <TabsTrigger value="audit" className="min-w-[100px]" data-testid="onboarding-tab-trigger-audit">Audit Trail</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<TabsContent value="questionnaire" className="space-y-6"> <TabsContent value="questionnaire" className="space-y-6" data-testid="onboarding-tab-content-questionnaire">
<QuestionnaireResponseView application={application} /> <QuestionnaireResponseView application={application} />
</TabsContent> </TabsContent>
<TabsContent value="progress" className="space-y-6"> <TabsContent value="progress" className="space-y-6" data-testid="onboarding-tab-content-progress">
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-slate-900">Application Journey</h3> <h3 className="text-slate-900">Application Journey</h3>
<Badge className="bg-amber-600">{application.progress}% Complete</Badge> <Badge className="bg-amber-600" data-testid="onboarding-progress-percentage-badge">{application.progress}% Complete</Badge>
</div> </div>
<Progress value={application.progress} className="h-3 mb-6" /> <Progress value={application.progress} className="h-3 mb-6" data-testid="onboarding-progress-bar" />
</div> </div>
<div className="relative"> <div className="relative" data-testid="onboarding-progress-stages-container">
{(() => { {(() => {
const getApproverStatus = (stageCode: string | number) => { const getApproverStatus = (stageCode: string | number) => {
const stageParticipants = (application.participants || []).filter((p: any) => const stageParticipants = (application.participants || []).filter((p: any) =>
@ -164,7 +164,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
}); });
}; };
const renderApprovers = (stageName: string) => { const renderApprovers = (stageName: string, stageIndex: number) => {
const stageMapping: Record<string, string | number> = { const stageMapping: Record<string, string | number> = {
'1st Level Interview': 1, '1st Level Interview': 1,
'2nd Level Interview': 2, '2nd Level Interview': 2,
@ -180,9 +180,9 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
if (approvers.length === 0) return null; if (approvers.length === 0) return null;
return ( return (
<div className="flex flex-wrap gap-2 mt-3"> <div className="flex flex-wrap gap-2 mt-3" data-testid={`onboarding-stage-approvers-${stageIndex}`}>
{approvers.map((approver, i) => ( {approvers.map((approver: any, i: number) => (
<div key={i} className="group relative flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-full pl-1 pr-2.5 py-0.5 transition-all hover:bg-white hover:shadow-sm"> <div key={i} className="group relative flex items-center gap-1.5 bg-slate-50 border border-slate-200 rounded-full pl-1 pr-2.5 py-0.5 transition-all hover:bg-white hover:shadow-sm" data-testid={`onboarding-stage-approver-${stageIndex}-${i}`}>
<div className={cn( <div className={cn(
"w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white", "w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white",
approver.status === 'approved' ? "bg-green-500" : approver.status === 'rejected' ? "bg-red-500" : "bg-slate-300" approver.status === 'approved' ? "bg-green-500" : approver.status === 'rejected' ? "bg-red-500" : "bg-slate-300"
@ -197,7 +197,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className={cn( <div className={cn(
"absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full border border-white", "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-amber-400"
)} /> )} 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"> <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">
{approver.role}: {approver.status.toUpperCase()} {approver.role}: {approver.status.toUpperCase()}
@ -209,7 +209,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
}; };
return processStages.map((stage, index) => ( return processStages.map((stage, index) => (
<div key={stage.id}> <div key={stage.id} data-testid={`onboarding-progress-stage-${index}`}>
<div className="flex gap-4 pb-8"> <div className="flex gap-4 pb-8">
<div className="relative"> <div className="relative">
<div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 relative ${stage.status === 'completed' <div className={`w-10 h-10 rounded-full flex items-center justify-center border-2 z-10 relative ${stage.status === 'completed'
@ -217,7 +217,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
: stage.status === 'active' : 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-amber-500 border-amber-500 text-white animate-pulse-subtle'
: 'bg-white border-slate-300 text-slate-400 shadow-none' : 'bg-white border-slate-300 text-slate-400 shadow-none'
}`}> }`} data-testid={`onboarding-progress-stage-icon-${index}`}>
{stage.isParallel ? ( {stage.isParallel ? (
<GitBranch className="w-5 h-5" /> <GitBranch className="w-5 h-5" />
) : stage.isLocked ? ( ) : stage.isLocked ? (
@ -247,22 +247,22 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{index < processStages.length - 1 && !stage.isParallel && ( {index < processStages.length - 1 && !stage.isParallel && (
<div className={`absolute top-10 left-1/2 -translate-x-1/2 w-0.5 h-full z-0 ${stage.status === 'completed' ? 'bg-green-500/30' : 'bg-slate-200' <div className={`absolute top-10 left-1/2 -translate-x-1/2 w-0.5 h-full z-0 ${stage.status === 'completed' ? 'bg-green-500/30' : 'bg-slate-200'
}`}></div> }`} data-testid={`onboarding-progress-stage-connector-${index}`}></div>
)} )}
</div> </div>
<div className="flex-1 pt-1"> <div className="flex-1 pt-1">
<p className={cn( <p className={cn(
"font-bold transition-colors", "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-amber-700" : "text-slate-900"
)}>{stage.name}</p> )} data-testid={`onboarding-progress-stage-name-${index}`}>{stage.name}</p>
{stage.description && ( {stage.description && (
<p className="text-slate-600 text-sm mt-0.5 leading-relaxed">{stage.description}</p> <p className="text-slate-600 text-sm mt-0.5 leading-relaxed" data-testid={`onboarding-progress-stage-desc-${index}`}>{stage.description}</p>
)} )}
{renderApprovers(stage.name as string)} {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) && ( {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"> <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}`}>
<User className="w-3 h-3" /> <User className="w-3 h-3" />
Evaluators: {stage.evaluators.join(' + ')} Evaluators: {stage.evaluators.join(' + ')}
</p> </p>
@ -290,7 +290,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
if (expectedCount && actualCount < expectedCount && application.status !== 'Rejected' && isEligibleForWarning) { if (expectedCount && actualCount < expectedCount && application.status !== 'Rejected' && isEligibleForWarning) {
return ( return (
<div className="mt-2"> <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"> <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" /> <AlertCircle className="h-4 w-4 text-amber-600" />
<AlertTitle className="text-xs font-semibold">Missing Evaluators</AlertTitle> <AlertTitle className="text-xs font-semibold">Missing Evaluators</AlertTitle>
@ -304,6 +304,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
size="sm" 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-amber-700 underline"
onClick={handleRetriggerEvaluators} onClick={handleRetriggerEvaluators}
data-testid={`onboarding-progress-stage-retrigger-${index}`}
> >
<RefreshCw className="w-3 h-3 mr-1" /> <RefreshCw className="w-3 h-3 mr-1" />
Re-trigger Assignment Re-trigger Assignment
@ -331,6 +332,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
if (stageDocsCount === 0) setShowUploadForm(true); if (stageDocsCount === 0) 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" 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}`}
> >
<FileText className="w-3.5 h-3.5" /> <FileText className="w-3.5 h-3.5" />
{stageDocsCount > 0 ? `${stageDocsCount} Documents` : 'Upload'} {stageDocsCount > 0 ? `${stageDocsCount} Documents` : 'Upload'}
@ -339,7 +341,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
); );
})()} })()}
<p className="text-slate-500 mt-1 text-xs"> <p className="text-slate-500 mt-1 text-xs" data-testid={`onboarding-progress-stage-status-text-${index}`}>
{stage.status === 'completed' && stage.date && `Completed: ${formatDateTime(stage.date)}`} {stage.status === 'completed' && stage.date && `Completed: ${formatDateTime(stage.date)}`}
{stage.status === 'active' && 'In Progress'} {stage.status === 'active' && 'In Progress'}
{stage.status === 'pending' && 'Pending'} {stage.status === 'pending' && 'Pending'}
@ -348,8 +350,8 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{stage.isParallel && stage.branches && ( {stage.isParallel && stage.branches && (
<div className="ml-5 mb-8"> <div className="ml-5 mb-8" data-testid={`onboarding-progress-parallel-branches-${index}`}>
{stage.branches.map((branch, branchIndex) => { {stage.branches.map((branch: any, branchIndex: number) => {
const branchKey = branch.name.toLowerCase().replace(/\s+/g, '-'); const branchKey = branch.name.toLowerCase().replace(/\s+/g, '-');
const isExpanded = expandedBranches[branchKey]; const isExpanded = expandedBranches[branchKey];
const branchColor = branch.color === 'blue' ? 'blue' : 'green'; const branchColor = branch.color === 'blue' ? 'blue' : 'green';
@ -366,6 +368,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
? 'border-blue-300 bg-blue-50 hover:bg-blue-100' ? 'border-blue-300 bg-blue-50 hover:bg-blue-100'
: 'border-green-300 bg-green-50 hover:bg-green-100' : 'border-green-300 bg-green-50 hover:bg-green-100'
}`} }`}
data-testid={`onboarding-progress-branch-trigger-${branchKey}`}
> >
{isExpanded ? ( {isExpanded ? (
<ChevronDown className={`w-5 h-5 ${branchColor === 'blue' ? 'text-blue-600' : 'text-green-600'}`} /> <ChevronDown className={`w-5 h-5 ${branchColor === 'blue' ? 'text-blue-600' : 'text-green-600'}`} />
@ -388,12 +391,12 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{isExpanded && ( {isExpanded && (
<div className="mt-4 ml-8 border-l-2 border-slate-200 pl-6 space-y-6"> <div className="mt-4 ml-8 border-l-2 border-slate-200 pl-6 space-y-6" data-testid={`onboarding-progress-branch-content-${branchKey}`}>
{branch.stages.map((branchStage) => ( {branch.stages.map((branchStage: any, bsIdx: number) => (
<div key={branchStage.id} className="relative"> <div key={branchStage.id} className="relative">
<div className="flex gap-4 text-xs"> <div className="flex gap-4 text-xs" data-testid={`onboarding-progress-branch-stage-${branchKey}-${bsIdx}`}>
{(() => { {(() => {
const stageDocs = documents.filter(doc => const stageDocs = documents.filter((doc: any) =>
doc.documentType?.toLowerCase().includes(branchStage.name.toLowerCase().split(' ')[0]) || doc.documentType?.toLowerCase().includes(branchStage.name.toLowerCase().split(' ')[0]) ||
doc.stage === branchStage.name doc.stage === branchStage.name
); );
@ -407,7 +410,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
: branchStage.status === 'active' : branchStage.status === 'active'
? 'bg-amber-500 border-amber-500 text-white shadow-sm' ? 'bg-amber-500 border-amber-500 text-white shadow-sm'
: 'bg-white border-slate-300 text-slate-400' : 'bg-white border-slate-300 text-slate-400'
}`}> }`} data-testid={`onboarding-progress-branch-stage-icon-${branchKey}-${bsIdx}`}>
{isDone ? ( {isDone ? (
<Check className="w-4 h-4 text-white" strokeWidth={3} /> <Check className="w-4 h-4 text-white" strokeWidth={3} />
) : branchStage.status === 'active' ? ( ) : branchStage.status === 'active' ? (
@ -418,7 +421,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-slate-800">{branchStage.name}</p> <p className="font-semibold text-slate-800" data-testid={`onboarding-progress-branch-stage-name-${branchKey}-${bsIdx}`}>{branchStage.name}</p>
{branchStage.description && ( {branchStage.description && (
<p className="text-slate-500 text-xs mt-0.5">{branchStage.description}</p> <p className="text-slate-500 text-xs mt-0.5">{branchStage.description}</p>
)} )}
@ -431,12 +434,13 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
if (stageDocs.length === 0) setShowUploadForm(true); if (stageDocs.length === 0) setShowUploadForm(true);
}} }}
className="text-[10px] font-medium text-blue-600 hover:text-blue-800 flex items-center gap-1 transition-colors" className="text-[10px] font-medium text-blue-600 hover:text-blue-800 flex items-center gap-1 transition-colors"
data-testid={`onboarding-progress-branch-stage-docs-${branchKey}-${bsIdx}`}
> >
<FileText className="w-2.5 h-2.5" /> <FileText className="w-2.5 h-2.5" />
{stageDocs.length > 0 ? `${stageDocs.length} Docs` : 'Upload'} {stageDocs.length > 0 ? `${stageDocs.length} Docs` : 'Upload'}
</button> </button>
</div> </div>
<p className="text-slate-400 text-[10px] mt-1"> <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)}` : branchStage.status === 'active' ? 'Evaluating' : 'Pending'}
</p> </p>
</div> </div>
@ -460,10 +464,10 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="documents" className="space-y-4"> <TabsContent value="documents" className="space-y-4" data-testid="onboarding-tab-content-documents">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-slate-900">Uploaded Documents</h3> <h3 className="text-slate-900">Uploaded Documents</h3>
<Button size="sm" className="bg-amber-600 hover:bg-amber-700" onClick={() => { <Button size="sm" className="bg-amber-600 hover:bg-amber-700" data-testid="onboarding-documents-upload-tab-button" onClick={() => {
setSelectedStage(null); setSelectedStage(null);
setShowDocumentsModal(true); setShowDocumentsModal(true);
setShowUploadForm(true); setShowUploadForm(true);
@ -474,7 +478,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table data-testid="onboarding-documents-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="min-w-[200px]">File Name</TableHead> <TableHead className="min-w-[200px]">File Name</TableHead>
@ -486,26 +490,29 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{documents.length === 0 ? ( {documents.length === 0 ? (
<TableRow> <TableRow data-testid="onboarding-documents-empty-row">
<TableCell colSpan={5} className="text-center py-8 text-slate-500"> <TableCell colSpan={5} className="text-center py-8 text-slate-500">
No documents uploaded yet No documents uploaded yet
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
documents.map((doc) => ( documents.map((doc, idx) => (
<TableRow key={doc.id}> <TableRow key={doc.id} data-testid={`onboarding-document-row-${idx}`}>
<TableCell className="flex items-center gap-2"> <TableCell className="flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" /> <FileText className="w-4 h-4 text-slate-400" />
<span className="truncate max-w-[150px] md:max-w-[300px]">{doc.fileName}</span> <span className="truncate max-w-[150px] md:max-w-[300px]" data-testid={`onboarding-document-name-${idx}`}>{doc.fileName}</span>
</TableCell> </TableCell>
<TableCell>{doc.documentType}</TableCell> <TableCell data-testid={`onboarding-document-type-${idx}`}>{doc.documentType}</TableCell>
<TableCell>{formatDateTime(doc.createdAt)}</TableCell> <TableCell>{formatDateTime(doc.createdAt)}</TableCell>
<TableCell> <TableCell data-testid={`onboarding-document-uploader-${idx}`}>
{doc.uploader?.fullName || (doc.uploadedBy ? 'Unknown User' : 'Applicant')} {doc.uploader?.fullName || (doc.uploadedBy ? 'Unknown User' : 'Applicant')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => window.open(`http://localhost:5000/${doc.filePath}`, '_blank')}> <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" />
</Button> </Button>
</div> </div>
@ -517,11 +524,11 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="interviews" className="space-y-6"> <TabsContent value="interviews" className="space-y-6" data-testid="onboarding-tab-content-interviews">
<div> <div>
<h3 className="text-slate-900 mb-4">Scheduled Interviews</h3> <h3 className="text-slate-900 mb-4">Scheduled Interviews</h3>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table data-testid="onboarding-interviews-scheduled-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="min-w-[100px]">Level</TableHead> <TableHead className="min-w-[100px]">Level</TableHead>
@ -535,28 +542,28 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{(!interviews || interviews.length === 0) ? ( {(!interviews || interviews.length === 0) ? (
<TableRow> <TableRow data-testid="onboarding-interviews-empty-row">
<TableCell colSpan={7} className="text-center py-8 text-slate-500"> <TableCell colSpan={7} className="text-center py-8 text-slate-500">
No interviews scheduled yet No interviews scheduled yet
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
(Array.isArray(interviews) ? interviews : []).map((interview) => ( (Array.isArray(interviews) ? interviews : []).map((interview, idx) => (
<TableRow key={interview.id}> <TableRow key={interview.id} data-testid={`onboarding-interview-row-${idx}`}>
<TableCell className="font-medium">Level {interview.level}</TableCell> <TableCell className="font-medium">Level {interview.level}</TableCell>
<TableCell>{interview.scheduleDate ? new Date(interview.scheduleDate).toLocaleString() : 'N/A'}</TableCell> <TableCell>{interview.scheduleDate ? new Date(interview.scheduleDate).toLocaleString() : 'N/A'}</TableCell>
<TableCell className="capitalize">{interview.interviewType}</TableCell> <TableCell className="capitalize">{interview.interviewType}</TableCell>
<TableCell> <TableCell>
{interview.interviewType?.toLowerCase().includes('virtual') ? ( {interview.interviewType?.toLowerCase().includes('virtual') ? (
<a href={interview.linkOrLocation} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline"> <a href={interview.linkOrLocation} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline" data-testid={`onboarding-interview-link-${idx}`}>
Join Meeting Join Meeting
</a> </a>
) : ( ) : (
interview.linkOrLocation <span data-testid={`onboarding-interview-location-${idx}`}>{interview.linkOrLocation}</span>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant={interview.status === 'Completed' ? 'default' : 'secondary'}> <Badge variant={interview.status === 'Completed' ? 'default' : 'secondary'} data-testid={`onboarding-interview-status-${idx}`}>
{interview.status} {interview.status}
</Badge> </Badge>
</TableCell> </TableCell>
@ -567,6 +574,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="text-red-500 hover:text-red-700 hover:bg-red-50 h-8 px-2" 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)} onClick={() => handleCancelInterview(interview.id)}
> >
Cancel Cancel
@ -584,10 +592,10 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div> <div>
<h3 className="text-slate-900 mb-4">Interview Feedback</h3> <h3 className="text-slate-900 mb-4">Interview Feedback</h3>
{(!interviews || interviews.length === 0) ? ( {(!interviews || interviews.length === 0) ? (
<p className="text-slate-500 italic">No interviews scheduled.</p> <p className="text-slate-500 italic" data-testid="onboarding-interviews-no-feedback">No interviews scheduled.</p>
) : ( ) : (
(Array.isArray(interviews) ? interviews : []).map((interview) => ( (Array.isArray(interviews) ? interviews : []).map((interview, iIdx) => (
<div key={interview.id} className="mb-6 border p-4 rounded-lg bg-slate-50/50"> <div key={interview.id} className="mb-6 border p-4 rounded-lg bg-slate-50/50" data-testid={`onboarding-interview-feedback-block-${iIdx}`}>
<h4 className="font-semibold text-slate-800 mb-2"> <h4 className="font-semibold text-slate-800 mb-2">
Level {interview.level} Interview Level {interview.level} Interview
<span className="font-normal text-slate-500 text-sm ml-2"> <span className="font-normal text-slate-500 text-sm ml-2">
@ -595,7 +603,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</span> </span>
</h4> </h4>
{interview.evaluations && interview.evaluations.length > 0 ? ( {interview.evaluations && interview.evaluations.length > 0 ? (
<Table> <Table data-testid={`onboarding-interview-evaluations-table-${iIdx}`}>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Interviewer</TableHead> <TableHead>Interviewer</TableHead>
@ -608,8 +616,8 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{interview.evaluations.map((evalItem: any) => ( {interview.evaluations.map((evalItem: any, eIdx: number) => (
<TableRow key={evalItem.id}> <TableRow key={evalItem.id} data-testid={`onboarding-interview-evaluation-row-${iIdx}-${eIdx}`}>
<TableCell className="font-medium">{evalItem.evaluator?.fullName}</TableCell> <TableCell className="font-medium">{evalItem.evaluator?.fullName}</TableCell>
<TableCell>{evalItem.evaluator?.role?.roleName || 'N/A'}</TableCell> <TableCell>{evalItem.evaluator?.role?.roleName || 'N/A'}</TableCell>
<TableCell> <TableCell>
@ -618,7 +626,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
interview.level === 1 interview.level === 1
? (Number(evalItem.ktMatrixScore) >= 50 ? 'outline' : 'destructive') ? (Number(evalItem.ktMatrixScore) >= 50 ? 'outline' : 'destructive')
: (Number(evalItem.ktMatrixScore) >= 5 ? 'outline' : 'destructive') : (Number(evalItem.ktMatrixScore) >= 5 ? 'outline' : 'destructive')
}> } data-testid={`onboarding-interview-evaluation-score-${iIdx}-${eIdx}`}>
{evalItem.ktMatrixScore}/{interview.level === 1 ? '100' : '10'} {evalItem.ktMatrixScore}/{interview.level === 1 ? '100' : '10'}
</Badge> </Badge>
) : 'N/A'} ) : 'N/A'}
@ -631,6 +639,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-blue-600 text-xs w-fit" className="p-0 h-auto font-normal text-blue-600 text-xs w-fit"
data-testid={`onboarding-interview-evaluation-details-btn-${iIdx}-${eIdx}`}
onClick={() => { onClick={() => {
setSelectedEvaluationForView({ ...evalItem, interview }); setSelectedEvaluationForView({ ...evalItem, interview });
setShowFeedbackDetailsModal(true); setShowFeedbackDetailsModal(true);
@ -644,6 +653,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button <Button
variant="link" variant="link"
className="p-0 h-auto font-normal text-blue-600" className="p-0 h-auto font-normal text-blue-600"
data-testid={`onboarding-interview-evaluation-details-btn-${iIdx}-${eIdx}`}
onClick={() => { onClick={() => {
setSelectedEvaluationForView({ ...evalItem, interview }); setSelectedEvaluationForView({ ...evalItem, interview });
setShowFeedbackDetailsModal(true); setShowFeedbackDetailsModal(true);
@ -655,7 +665,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
evalItem.qualitativeFeedback || '-' evalItem.qualitativeFeedback || '-'
)} )}
</TableCell> </TableCell>
<TableCell>{evalItem.recommendation || '-'}</TableCell> <TableCell data-testid={`onboarding-interview-evaluation-rec-${iIdx}-${eIdx}`}>{evalItem.recommendation || '-'}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -669,7 +679,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{['Level 2 Approved', 'Level 3 Interview Pending', 'Approved', 'Onboarded'].includes(application.status) && ( {['Level 2 Approved', 'Level 3 Interview Pending', 'Approved', 'Onboarded'].includes(application.status) && (
<div> <div data-testid="onboarding-interviews-summary-l2">
<h3 className="text-slate-900 mb-4">Level 2 Interview Summary</h3> <h3 className="text-slate-900 mb-4">Level 2 Interview Summary</h3>
<div className="p-4 bg-slate-50 rounded-lg"> <div className="p-4 bg-slate-50 rounded-lg">
<p className="text-slate-600">Decision: Approved by both ZBH and DD Lead</p> <p className="text-slate-600">Decision: Approved by both ZBH and DD Lead</p>
@ -679,19 +689,19 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="fdd" className="space-y-6"> <TabsContent value="fdd" className="space-y-6" data-testid="onboarding-tab-content-fdd">
{renderFddAuditContent()} {renderFddAuditContent()}
</TabsContent> </TabsContent>
<TabsContent value="eor" className="space-y-4"> <TabsContent value="eor" className="space-y-4" data-testid="onboarding-tab-content-eor">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-slate-900">Essential Operating Requirements</h3> <h3 className="text-slate-900">Essential Operating Requirements</h3>
<Badge className="bg-amber-600">{Math.round(eorProgress)}% Complete</Badge> <Badge className="bg-amber-600" data-testid="onboarding-eor-progress-badge">{Math.round(eorProgress)}% Complete</Badge>
</div> </div>
<Progress value={eorProgress} className="h-3 mb-6" /> <Progress value={eorProgress} className="h-3 mb-6" data-testid="onboarding-eor-progress-bar" />
<div className="space-y-3"> <div className="space-y-3" data-testid="onboarding-eor-checklist">
{(eorData?.items || eorChecklist).map((item: any) => { {(eorData?.items || eorChecklist).map((item: any, idx: number) => {
const docType = item.description || item.item; const docType = item.description || item.item;
const hasDocument = !!item.proofDocument; const hasDocument = !!item.proofDocument;
@ -699,14 +709,17 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div <div
key={item.id} key={item.id}
className="flex items-center gap-3 p-3 bg-slate-50 rounded-xl transition-all border border-transparent hover:border-slate-200 group" className="flex items-center gap-3 p-3 bg-slate-50 rounded-xl transition-all border border-transparent hover:border-slate-200 group"
data-testid={`onboarding-eor-item-${idx}`}
> >
<Checkbox <Checkbox
checked={item.isCompliant || item.completed} checked={item.isCompliant || item.completed}
className="pointer-events-none shrink-0" className="pointer-events-none shrink-0"
data-testid={`onboarding-eor-checkbox-${idx}`}
/> />
<div <div
className="flex flex-col flex-1 min-w-0 cursor-pointer" className="flex flex-col flex-1 min-w-0 cursor-pointer"
data-testid={`onboarding-eor-clickable-${idx}`}
onClick={() => { onClick={() => {
setSelectedStage(`EOR: ${docType}`); setSelectedStage(`EOR: ${docType}`);
setUploadDocType(docType); setUploadDocType(docType);
@ -742,8 +755,9 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<Button <Button
size="sm" size="sm"
className="h-8 px-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg shadow-sm" className="h-8 px-3 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg shadow-sm"
data-testid={`onboarding-eor-verify-btn-${idx}`}
onClick={async () => { onClick={async () => {
await (await import('../../../services/eor.service')).eorService.updateItem(eorData.id, { await (await import('@/services/eor.service')).eorService.updateItem(eorData.id, {
...item, ...item,
isCompliant: true isCompliant: true
}); });
@ -757,8 +771,9 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
size="sm" size="sm"
variant="outline" variant="outline"
className="h-8 px-3 border-red-200 text-red-600 hover:bg-red-50 font-bold rounded-lg" className="h-8 px-3 border-red-200 text-red-600 hover:bg-red-50 font-bold rounded-lg"
data-testid={`onboarding-eor-reject-btn-${idx}`}
onClick={async () => { onClick={async () => {
await (await import('../../../services/eor.service')).eorService.updateItem(eorData.id, { await (await import('@/services/eor.service')).eorService.updateItem(eorData.id, {
...item, ...item,
isCompliant: false, isCompliant: false,
proofDocumentId: null proofDocumentId: null
@ -773,13 +788,13 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
)} )}
{(item.isCompliant || item.completed) && ( {(item.isCompliant || item.completed) && (
<div className="bg-green-100 p-1.5 rounded-full"> <div className="bg-green-100 p-1.5 rounded-full" data-testid={`onboarding-eor-done-icon-${idx}`}>
<CheckCircle className="w-4 h-4 text-green-600" /> <CheckCircle className="w-4 h-4 text-green-600" />
</div> </div>
)} )}
{!hasDocument && ( {!hasDocument && (
<div className="p-2 text-slate-300 group-hover:text-amber-500 transition-colors"> <div className="p-2 text-slate-300 group-hover:text-amber-500 transition-colors" data-testid={`onboarding-eor-upload-hint-${idx}`}>
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
</div> </div>
)} )}
@ -790,7 +805,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{eorProgress === 100 && isAdmin && (application.status === 'EOR In Progress' || application.status === 'LOA Pending') && ( {eorProgress === 100 && isAdmin && (application.status === 'EOR In Progress' || application.status === 'LOA Pending') && (
<div className="mt-8 p-6 bg-green-50 rounded-xl border-2 border-green-200 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="mt-8 p-6 bg-green-50 rounded-xl border-2 border-green-200 animate-in fade-in slide-in-from-bottom-4 duration-500" data-testid="onboarding-eor-complete-banner">
<div className="flex flex-col sm:flex-row items-center gap-4"> <div className="flex flex-col sm:flex-row items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center shrink-0"> <div className="w-12 h-12 rounded-xl bg-green-100 flex items-center justify-center shrink-0">
<ShieldCheck className="w-7 h-7 text-green-600" /> <ShieldCheck className="w-7 h-7 text-green-600" />
@ -801,12 +816,13 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
<Button <Button
className="w-full sm:w-auto bg-green-600 hover:bg-green-700 text-white font-bold h-12 px-8 rounded-xl shadow-lg shadow-green-600/20 transition-all hover:scale-[1.02] active:scale-[0.98]" className="w-full sm:w-auto bg-green-600 hover:bg-green-700 text-white font-bold h-12 px-8 rounded-xl shadow-lg shadow-green-600/20 transition-all hover:scale-[1.02] active:scale-[0.98]"
data-testid="onboarding-eor-submit-audit"
onClick={async () => { onClick={async () => {
try { try {
const checklistId = eorData?.id; const checklistId = eorData?.id;
if (!checklistId) throw new Error('Checklist ID not found'); if (!checklistId) throw new Error('Checklist ID not found');
await (await import('../../../services/eor.service')).eorService.submitAudit(checklistId, { await (await import('@/services/eor.service')).eorService.submitAudit(checklistId, {
status: 'Completed', status: 'Completed',
overallComments: 'EOR Checklist verified and audit completed.' overallComments: 'EOR Checklist verified and audit completed.'
}); });
@ -825,10 +841,10 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
)} )}
</TabsContent> </TabsContent>
<TabsContent value="payments" className="space-y-6"> <TabsContent value="payments" className="space-y-6" data-testid="onboarding-tab-content-payments">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h3 className="text-lg font-semibold text-slate-900">Security Deposits</h3> <h3 className="text-lg font-semibold text-slate-900">Security Deposits</h3>
<Badge variant="outline" className="bg-slate-50 text-slate-500 border-slate-200"> <Badge variant="outline" className="bg-slate-50 text-slate-500 border-slate-200" data-testid="onboarding-payments-count-badge">
{deposits.length} Payment Record(s) {deposits.length} Payment Record(s)
</Badge> </Badge>
</div> </div>
@ -844,7 +860,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
"border-l-4", "border-l-4",
deposit?.status === 'Verified' ? "border-l-green-500" : 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-amber-500"
)}> )} data-testid="onboarding-payment-card-security">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -857,7 +873,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
deposit?.status === 'Verified' ? "bg-green-100 text-green-700 hover:bg-green-100" : 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" : deposit?.status === 'Rejected' ? "bg-red-100 text-red-700 hover:bg-red-100" :
"bg-amber-100 text-amber-700 hover:bg-amber-100" "bg-amber-100 text-amber-700 hover:bg-amber-100"
)}> )} data-testid="onboarding-payment-status-security">
{deposit?.status || 'Awaiting'} {deposit?.status || 'Awaiting'}
</Badge> </Badge>
</div> </div>
@ -865,7 +881,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<span className="text-xs text-slate-500 uppercase font-bold tracking-wider">Amount Received</span> <span className="text-xs text-slate-500 uppercase font-bold tracking-wider">Amount Received</span>
<span className="text-lg font-bold text-slate-900">{Number(deposit?.amount || 0).toLocaleString()}</span> <span className="text-lg font-bold text-slate-900" data-testid="onboarding-payment-amount-security">{Number(deposit?.amount || 0).toLocaleString()}</span>
</div> </div>
<div className="flex justify-between items-baseline border-t border-slate-100 pt-2"> <div className="flex justify-between items-baseline border-t border-slate-100 pt-2">
<span className="text-xs text-slate-500">Expected Total</span> <span className="text-xs text-slate-500">Expected Total</span>
@ -873,14 +889,14 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{deposit?.paymentReference && ( {deposit?.paymentReference && (
<div className="bg-slate-50 p-2 rounded text-xs font-mono text-slate-600 flex justify-between items-center"> <div className="bg-slate-50 p-2 rounded text-xs font-mono text-slate-600 flex justify-between items-center" data-testid="onboarding-payment-ref-security">
<span>Ref: {deposit.paymentReference}</span> <span>Ref: {deposit.paymentReference}</span>
{deposit.verifiedAt && <span>{formatDateTime(deposit.verifiedAt)}</span>} {deposit.verifiedAt && <span>{formatDateTime(deposit.verifiedAt)}</span>}
</div> </div>
)} )}
{deposit?.remarks && ( {deposit?.remarks && (
<div className="text-[11px] text-slate-500 bg-red-50/50 p-2 rounded border border-red-100 italic"> <div className="text-[11px] text-slate-500 bg-red-50/50 p-2 rounded border border-red-100 italic" data-testid="onboarding-payment-remarks-security">
"{deposit.remarks}" "{deposit.remarks}"
</div> </div>
)} )}
@ -889,7 +905,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Verification Documents</p> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Verification Documents</p>
<div className="space-y-2"> <div className="space-y-2">
{documents.filter((d: any) => d.documentType?.toLowerCase().includes('security') && d.documentType?.toLowerCase().includes('deposit')).map((doc: any, idx: number) => ( {documents.filter((d: any) => d.documentType?.toLowerCase().includes('security') && d.documentType?.toLowerCase().includes('deposit')).map((doc: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-2 rounded bg-slate-50/50 border border-slate-100"> <div key={idx} className="flex items-center justify-between p-2 rounded bg-slate-50/50 border border-slate-100" data-testid={`onboarding-payment-doc-security-${idx}`}>
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-3 h-3 text-slate-400" /> <FileText className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-medium text-slate-700 truncate">{doc.fileName || doc.name}</span> <span className="text-[10px] font-medium text-slate-700 truncate">{doc.fileName || doc.name}</span>
@ -898,6 +914,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
variant="ghost" variant="ghost"
size="sm" 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-amber-600 hover:text-amber-700 hover:bg-amber-50"
data-testid={`onboarding-payment-doc-view-security-${idx}`}
onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }}
> >
View View
@ -925,7 +942,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
"border-l-4", "border-l-4",
deposit?.status === 'Verified' ? "border-l-green-500" : 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-amber-500"
)}> )} data-testid="onboarding-payment-card-first-fill">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -938,7 +955,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
deposit?.status === 'Verified' ? "bg-green-100 text-green-700 hover:bg-green-100" : 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" : deposit?.status === 'Rejected' ? "bg-red-100 text-red-700 hover:bg-red-100" :
"bg-amber-100 text-amber-700 hover:bg-amber-100" "bg-amber-100 text-amber-700 hover:bg-amber-100"
)}> )} data-testid="onboarding-payment-status-first-fill">
{deposit?.status || 'Awaiting'} {deposit?.status || 'Awaiting'}
</Badge> </Badge>
</div> </div>
@ -946,7 +963,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<span className="text-xs text-slate-500 uppercase font-bold tracking-wider">Amount Received</span> <span className="text-xs text-slate-500 uppercase font-bold tracking-wider">Amount Received</span>
<span className="text-lg font-bold text-slate-900">{Number(deposit?.amount || 0).toLocaleString()}</span> <span className="text-lg font-bold text-slate-900" data-testid="onboarding-payment-amount-first-fill">{Number(deposit?.amount || 0).toLocaleString()}</span>
</div> </div>
<div className="flex justify-between items-baseline border-t border-slate-100 pt-2"> <div className="flex justify-between items-baseline border-t border-slate-100 pt-2">
<span className="text-xs text-slate-500">Expected Total</span> <span className="text-xs text-slate-500">Expected Total</span>
@ -954,14 +971,14 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
{deposit?.paymentReference && ( {deposit?.paymentReference && (
<div className="bg-slate-50 p-2 rounded text-xs font-mono text-slate-600 flex justify-between items-center"> <div className="bg-slate-50 p-2 rounded text-xs font-mono text-slate-600 flex justify-between items-center" data-testid="onboarding-payment-ref-first-fill">
<span>Ref: {deposit.paymentReference}</span> <span>Ref: {deposit.paymentReference}</span>
{deposit.verifiedAt && <span>{formatDateTime(deposit.verifiedAt)}</span>} {deposit.verifiedAt && <span>{formatDateTime(deposit.verifiedAt)}</span>}
</div> </div>
)} )}
{deposit?.remarks && ( {deposit?.remarks && (
<div className="text-[11px] text-slate-500 bg-red-50/50 p-2 rounded border border-red-100 italic"> <div className="text-[11px] text-slate-500 bg-red-50/50 p-2 rounded border border-red-100 italic" data-testid="onboarding-payment-remarks-first-fill">
"{deposit.remarks}" "{deposit.remarks}"
</div> </div>
)} )}
@ -970,7 +987,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Verification Documents</p> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2">Verification Documents</p>
<div className="space-y-2"> <div className="space-y-2">
{documents.filter((d: any) => d.documentType?.toLowerCase().includes('first') && d.documentType?.toLowerCase().includes('fill')).map((doc: any, idx: number) => ( {documents.filter((d: any) => d.documentType?.toLowerCase().includes('first') && d.documentType?.toLowerCase().includes('fill')).map((doc: any, idx: number) => (
<div key={idx} className="flex items-center justify-between p-2 rounded bg-slate-50/50 border border-slate-100"> <div key={idx} className="flex items-center justify-between p-2 rounded bg-slate-50/50 border border-slate-100" data-testid={`onboarding-payment-doc-first-fill-${idx}`}>
<div className="flex items-center gap-2 overflow-hidden"> <div className="flex items-center gap-2 overflow-hidden">
<FileText className="w-3 h-3 text-slate-400" /> <FileText className="w-3 h-3 text-slate-400" />
<span className="text-[10px] font-medium text-slate-700 truncate">{doc.fileName || doc.name}</span> <span className="text-[10px] font-medium text-slate-700 truncate">{doc.fileName || doc.name}</span>
@ -979,6 +996,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-6 px-2 text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50" className="h-6 px-2 text-[10px] text-blue-600 hover:text-blue-700 hover:bg-blue-50"
data-testid={`onboarding-payment-doc-view-first-fill-${idx}`}
onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }} onClick={() => { setPreviewDoc(doc); setShowPreviewModal(true); }}
> >
View View
@ -998,23 +1016,24 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="audit"> <TabsContent value="audit" data-testid="onboarding-tab-content-audit">
<ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50"> <ScrollArea className="h-[30rem] rounded-md border border-slate-100 bg-slate-50/50">
<div className="space-y-2.5 p-3 pr-4"> <div className="space-y-2.5 p-3 pr-4" data-testid="onboarding-audit-logs-container">
{auditLoading ? ( {auditLoading ? (
<div className="flex items-center justify-center py-10"> <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-amber-600" />
<span className="ml-2 text-sm text-slate-500">Loading audit trail</span> <span className="ml-2 text-sm text-slate-500">Loading audit trail</span>
</div> </div>
) : auditLogs.length === 0 ? ( ) : auditLogs.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-200 bg-white py-10 text-center text-sm text-slate-500"> <div className="rounded-lg border border-dashed border-slate-200 bg-white py-10 text-center text-sm text-slate-500" data-testid="onboarding-audit-empty">
No audit logs recorded yet for this application. No audit logs recorded yet for this application.
</div> </div>
) : ( ) : (
auditLogs.map((log: any) => ( auditLogs.map((log: any, idx: number) => (
<div <div
key={log.id} key={log.id}
className="rounded-lg border border-slate-200/90 bg-white p-3 text-sm shadow-sm" className="rounded-lg border border-slate-200/90 bg-white p-3 text-sm shadow-sm"
data-testid={`onboarding-audit-log-item-${idx}`}
> >
<div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5"> <div className="flex flex-wrap items-start justify-between gap-x-3 gap-y-1.5">
<div className="flex min-w-0 flex-wrap items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
@ -1024,6 +1043,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
'shrink-0 text-[10px] font-semibold uppercase tracking-wide', 'shrink-0 text-[10px] font-semibold uppercase tracking-wide',
auditLogActionBadgeClass(log.action) auditLogActionBadgeClass(log.action)
)} )}
data-testid={`onboarding-audit-log-action-${idx}`}
> >
{String(log.action || 'EVENT').replace(/_/g, ' ')} {String(log.action || 'EVENT').replace(/_/g, ' ')}
</Badge> </Badge>
@ -1031,6 +1051,7 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<span <span
className="max-w-[200px] truncate text-[11px] text-slate-500" className="max-w-[200px] truncate text-[11px] text-slate-500"
title={log.stage} title={log.stage}
data-testid={`onboarding-audit-log-stage-${idx}`}
> >
{log.stage} {log.stage}
</span> </span>
@ -1039,21 +1060,22 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
<time <time
className="shrink-0 text-xs tabular-nums text-slate-400" className="shrink-0 text-xs tabular-nums text-slate-400"
dateTime={log.timestamp} dateTime={log.timestamp}
data-testid={`onboarding-audit-log-time-${idx}`}
> >
{formatDateTime(log.timestamp)} {formatDateTime(log.timestamp)}
</time> </time>
</div> </div>
<p className="mt-2 text-[13px] leading-relaxed text-slate-800"> <p className="mt-2 text-[13px] leading-relaxed text-slate-800" data-testid={`onboarding-audit-log-desc-${idx}`}>
{log.description || '—'} {log.description || '—'}
</p> </p>
<div className="mt-2 flex items-center gap-1.5 text-xs text-slate-500"> <div className="mt-2 flex items-center gap-1.5 text-xs text-slate-500">
<User className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden /> <User className="h-3.5 w-3.5 shrink-0 text-slate-400" aria-hidden />
<span className="min-w-0 truncate"> <span className="min-w-0 truncate">
<span className="font-medium text-slate-600"> <span className="font-medium text-slate-600" data-testid={`onboarding-audit-log-user-${idx}`}>
{log.userName || 'System'} {log.userName || 'System'}
</span> </span>
{log.userEmail ? ( {log.userEmail ? (
<span className="text-slate-400"> · {log.userEmail}</span> <span className="text-slate-400" data-testid={`onboarding-audit-log-email-${idx}`}> · {log.userEmail}</span>
) : null} ) : null}
</span> </span>
</div> </div>
@ -1068,3 +1090,5 @@ export function ApplicationDetailsTabs(props: ApplicationDetailsTabsProps) {
</Card> </Card>
); );
} }

View File

@ -1,6 +1,6 @@
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
interface UseApplicationDetailsAdminActionsParams { interface UseApplicationDetailsAdminActionsParams {
application: any; application: any;
@ -547,3 +547,4 @@ export function useApplicationDetailsAdminActions(params: UseApplicationDetailsA
handleRetriggerEvaluators, handleRetriggerEvaluators,
}; };
} }

View File

@ -1,9 +1,9 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Application, ApplicationStatus } from '../../../lib/mock-data'; import { Application, ApplicationStatus } from '@/lib/mock-data';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { eorService } from '../../../services/eor.service'; import { eorService } from '@/services/eor.service';
import { auditService } from '../../../services/audit.service'; import { auditService } from '@/services/audit.service';
import { collaborationService } from '../../../services/collaboration.service'; import { collaborationService } from '@/services/collaboration.service';
interface UseApplicationDetailsDataParams { interface UseApplicationDetailsDataParams {
applicationId: string; applicationId: string;

View File

@ -1,7 +1,7 @@
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { KT_MATRIX_CRITERIA } from './applicationDetails.shared'; import { KT_MATRIX_CRITERIA } from '@/features/onboarding/components/application-details/applicationDetails.shared';
interface UseApplicationDetailsFeedbackActionsParams { interface UseApplicationDetailsFeedbackActionsParams {
ktMatrixScores: Record<string, number>; ktMatrixScores: Record<string, number>;
@ -209,3 +209,4 @@ export function useApplicationDetailsFeedbackActions({
handleSubmitLevel3Feedback, handleSubmitLevel3Feedback,
}; };
} }

View File

@ -1,6 +1,6 @@
import { Dispatch, SetStateAction } from 'react'; import { Dispatch, SetStateAction } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { onboardingService } from '../../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
interface UseApplicationDetailsLocalActionsParams { interface UseApplicationDetailsLocalActionsParams {
application: any; application: any;
@ -118,3 +118,4 @@ export function useApplicationDetailsLocalActions({
handleAssignAgency, handleAssignAgency,
}; };
} }

View File

@ -1,4 +1,4 @@
import { ProcessStage } from './applicationDetails.shared'; import { ProcessStage } from '@/features/onboarding/components/application-details/applicationDetails.shared';
interface UseApplicationDetailsStageDataParams { interface UseApplicationDetailsStageDataParams {
application: any; application: any;

View File

@ -1,16 +1,16 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { ApplicationCard } from './ApplicationCard'; import { ApplicationCard } from '@/features/onboarding/components/ApplicationCard';
import { locations, states, ApplicationStatus, Application } from '../../lib/mock-data'; import { locations, states, ApplicationStatus, Application } from '@/lib/mock-data';
import { onboardingService } from '../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '../ui/select'; } from '@/components/ui/select';
import { import {
Search, Search,
Download, Download,
@ -20,8 +20,8 @@ import {
CheckCircle, CheckCircle,
AlertCircle AlertCircle
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Table, Table,
TableBody, TableBody,
@ -29,13 +29,13 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '../ui/table'; } from '@/components/ui/table';
import { Progress } from '../ui/progress'; import { Progress } from '@/components/ui/progress';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
interface AllApplicationsPageProps { interface AllApplicationsPageProps {
onViewDetails: (id: string) => void; onViewDetails: (id: string) => void;
@ -226,7 +226,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Info Banner */} {/* Info Banner */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4"> <div className="bg-amber-50 border border-amber-200 rounded-lg p-4" data-testid="onboarding-all-apps-banner">
<div className="flex items-start gap-3"> <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-amber-600 flex-shrink-0 mt-0.5" />
<div> <div>
@ -252,11 +252,12 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
data-testid="onboarding-all-apps-search-input"
/> />
</div> </div>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full md:w-48"> <SelectTrigger className="w-full md:w-48" data-testid="onboarding-all-apps-status-filter">
<SelectValue placeholder="Filter by status" /> <SelectValue placeholder="Filter by status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -268,7 +269,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
</Select> </Select>
<Select value={stateFilter} onValueChange={setStateFilter}> <Select value={stateFilter} onValueChange={setStateFilter}>
<SelectTrigger className="w-full md:w-48"> <SelectTrigger className="w-full md:w-48" data-testid="onboarding-all-apps-state-filter">
<SelectValue placeholder="Filter by state" /> <SelectValue placeholder="Filter by state" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -280,7 +281,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
</Select> </Select>
<Select value={locationFilter} onValueChange={setLocationFilter}> <Select value={locationFilter} onValueChange={setLocationFilter}>
<SelectTrigger className="w-full md:w-48"> <SelectTrigger className="w-full md:w-48" data-testid="onboarding-all-apps-location-filter">
<SelectValue placeholder="Filter by location" /> <SelectValue placeholder="Filter by location" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -300,6 +301,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
size="sm" size="sm"
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={viewMode === 'grid' ? 'bg-amber-600 hover:bg-amber-700' : ''} className={viewMode === 'grid' ? 'bg-amber-600 hover:bg-amber-700' : ''}
data-testid="onboarding-all-apps-grid-view-btn"
> >
<Grid3x3 className="w-4 h-4 mr-2" /> <Grid3x3 className="w-4 h-4 mr-2" />
Grid Grid
@ -309,13 +311,14 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
size="sm" size="sm"
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
className={viewMode === 'table' ? 'bg-amber-600 hover:bg-amber-700' : ''} className={viewMode === 'table' ? 'bg-amber-600 hover:bg-amber-700' : ''}
data-testid="onboarding-all-apps-table-view-btn"
> >
<List className="w-4 h-4 mr-2" /> <List className="w-4 h-4 mr-2" />
Table Table
</Button> </Button>
</div> </div>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm" data-testid="onboarding-all-apps-export-btn">
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Export Export
</Button> </Button>
@ -326,6 +329,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleBulkReminders} onClick={handleBulkReminders}
data-testid="onboarding-all-apps-reminders-btn"
> >
<Mail className="w-4 h-4 mr-2" /> <Mail className="w-4 h-4 mr-2" />
Send Reminders ({selectedIds.length}) Send Reminders ({selectedIds.length})
@ -335,6 +339,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
size="sm" size="sm"
onClick={handleShortlist} onClick={handleShortlist}
className="bg-green-600 hover:bg-green-700" className="bg-green-600 hover:bg-green-700"
data-testid="onboarding-all-apps-shortlist-btn"
> >
<CheckCircle className="w-4 h-4 mr-2" /> <CheckCircle className="w-4 h-4 mr-2" />
Shortlist ({selectedIds.length}) Shortlist ({selectedIds.length})
@ -343,7 +348,7 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
)} )}
<div className="ml-auto"> <div className="ml-auto">
<Badge variant="outline" className="text-slate-600"> <Badge variant="outline" className="text-slate-600" data-testid="onboarding-all-apps-pending-badge">
{filteredApplications.length} pending shortlisting {filteredApplications.length} pending shortlisting
</Badge> </Badge>
</div> </div>
@ -353,19 +358,20 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{/* Applications Grid/Table */} {/* Applications Grid/Table */}
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4" data-testid="onboarding-all-apps-grid-container">
{filteredApplications.map((app) => ( {filteredApplications.map((app, idx) => (
<div key={app.id} className="relative"> <div key={app.id} className="relative" data-testid={`onboarding-all-apps-grid-item-${idx}`}>
<div className="absolute top-4 left-4 z-10"> <div className="absolute top-4 left-4 z-10">
<Checkbox <Checkbox
checked={selectedIds.includes(app.id)} checked={selectedIds.includes(app.id)}
onCheckedChange={(checked) => handleSelectOne(app.id, checked as boolean)} onCheckedChange={(checked) => handleSelectOne(app.id, checked as boolean)}
className="bg-white" className="bg-white"
data-testid={`onboarding-all-apps-grid-checkbox-${idx}`}
/> />
</div> </div>
{app.isShortlisted && ( {app.isShortlisted && (
<div className="absolute top-4 right-4 z-10"> <div className="absolute top-4 right-4 z-10">
<Badge className="bg-green-600">Shortlisted</Badge> <Badge className="bg-green-600" data-testid={`onboarding-all-apps-grid-shortlisted-badge-${idx}`}>Shortlisted</Badge>
</div> </div>
)} )}
<ApplicationCard <ApplicationCard
@ -377,13 +383,14 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
</div> </div>
) : ( ) : (
<div className="bg-white rounded-lg border border-slate-200"> <div className="bg-white rounded-lg border border-slate-200">
<Table> <Table data-testid="onboarding-all-apps-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">
<Checkbox <Checkbox
checked={selectedIds.length === filteredApplications.length && filteredApplications.length > 0} checked={selectedIds.length === filteredApplications.length && filteredApplications.length > 0}
onCheckedChange={handleSelectAll} onCheckedChange={handleSelectAll}
data-testid="onboarding-all-apps-header-checkbox"
/> />
</TableHead> </TableHead>
<TableHead>Registration</TableHead> <TableHead>Registration</TableHead>
@ -396,47 +403,49 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredApplications.map((app) => ( {filteredApplications.map((app, idx) => (
<TableRow <TableRow
key={app.id} key={app.id}
className="cursor-pointer hover:bg-slate-50" className="cursor-pointer hover:bg-slate-50"
onClick={() => onViewDetails(app.id)} onClick={() => onViewDetails(app.id)}
data-testid={`onboarding-all-apps-row-${idx}`}
> >
<TableCell onClick={(e) => e.stopPropagation()}> <TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox <Checkbox
checked={selectedIds.includes(app.id)} checked={selectedIds.includes(app.id)}
onCheckedChange={(checked) => handleSelectOne(app.id, checked as boolean)} onCheckedChange={(checked) => handleSelectOne(app.id, checked as boolean)}
data-testid={`onboarding-all-apps-checkbox-${idx}`}
/> />
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-slate-900">{app.registrationNumber}</span> <span className="text-slate-900" data-testid={`onboarding-all-apps-reg-id-${idx}`}>{app.registrationNumber}</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-slate-900">{app.name}</span> <span className="text-slate-900" data-testid={`onboarding-all-apps-name-${idx}`}>{app.name}</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-slate-600">{app.preferredLocation}</span> <span className="text-slate-600" data-testid={`onboarding-all-apps-location-${idx}`}>{app.preferredLocation}</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={getStatusColor(app.status)}> <Badge className={getStatusColor(app.status)} data-testid={`onboarding-all-apps-status-${idx}`}>
{app.status} {app.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
{app.isShortlisted ? ( {app.isShortlisted ? (
<Badge className="bg-green-600">Yes</Badge> <Badge className="bg-green-600" data-testid={`onboarding-all-apps-shortlisted-yes-${idx}`}>Yes</Badge>
) : ( ) : (
<Badge variant="outline">No</Badge> <Badge variant="outline" data-testid={`onboarding-all-apps-shortlisted-no-${idx}`}>No</Badge>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress value={app.progress} className="w-20" /> <Progress value={app.progress} className="w-20" data-testid={`onboarding-all-apps-progress-bar-${idx}`} />
<span className="text-slate-600">{app.progress}%</span> <span className="text-slate-600" data-testid={`onboarding-all-apps-progress-text-${idx}`}>{app.progress}%</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-slate-600">{formatDateTime(app.submissionDate)}</span> <span className="text-slate-600" data-testid={`onboarding-all-apps-date-${idx}`}>{formatDateTime(app.submissionDate)}</span>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@ -447,22 +456,24 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
{/* Shortlist Modal */} {/* Shortlist Modal */}
<Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}> <Dialog open={showShortlistModal} onOpenChange={setShowShortlistModal}>
<DialogContent> <DialogContent data-testid="onboarding-all-apps-shortlist-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Shortlist Applications</DialogTitle> <DialogTitle data-testid="onboarding-all-apps-shortlist-title">Shortlist Applications</DialogTitle>
<DialogDescription> <DialogDescription data-testid="onboarding-all-apps-shortlist-desc">
You are about to shortlist {selectedIds.length} application(s). These applications will be moved to the Dealership Requests page. You are about to shortlist {selectedIds.length} application(s). These applications will be moved to the Dealership Requests page.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label>Shortlisting Remark (Optional)</Label> <Label htmlFor="shortlist-remark">Shortlisting Remark (Optional)</Label>
<Textarea <Textarea
id="shortlist-remark"
placeholder="Enter reason for shortlisting these applications..." placeholder="Enter reason for shortlisting these applications..."
value={shortlistRemark} value={shortlistRemark}
onChange={(e) => setShortlistRemark(e.target.value)} onChange={(e) => setShortlistRemark(e.target.value)}
className="mt-2" className="mt-2"
rows={4} rows={4}
data-testid="onboarding-all-apps-shortlist-remark"
/> />
</div> </div>
<div className="flex gap-3"> <div className="flex gap-3">
@ -470,12 +481,14 @@ export function AllApplicationsPage({ onViewDetails, initialFilter = 'all' }: Al
variant="outline" variant="outline"
className="flex-1" className="flex-1"
onClick={() => setShowShortlistModal(false)} onClick={() => setShowShortlistModal(false)}
data-testid="onboarding-all-apps-shortlist-cancel"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
className="flex-1 bg-green-600 hover:bg-green-700" className="flex-1 bg-green-600 hover:bg-green-700"
onClick={confirmShortlist} onClick={confirmShortlist}
data-testid="onboarding-all-apps-shortlist-confirm"
> >
Confirm Shortlist Confirm Shortlist
</Button> </Button>

View File

@ -1,23 +1,23 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useParams, useNavigate, useLocation } from 'react-router-dom'; import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { onboardingService } from '../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { ApplicationDetailsHeader } from './application-details/ApplicationDetailsHeader'; import { ApplicationDetailsHeader } from '@/features/onboarding/components/application-details/ApplicationDetailsHeader';
import { ApplicantInformationCard } from './application-details/ApplicantInformationCard'; import { ApplicantInformationCard } from '@/features/onboarding/components/application-details/ApplicantInformationCard';
import { ApplicationDetailsTabs } from './application-details/ApplicationDetailsTabs'; import { ApplicationDetailsTabs } from '@/features/onboarding/components/application-details/ApplicationDetailsTabs';
import { ApplicationDetailsSidebar } from './application-details/ApplicationDetailsSidebar'; import { ApplicationDetailsSidebar } from '@/features/onboarding/components/application-details/ApplicationDetailsSidebar';
import { ApplicationDetailsActionModals } from './application-details/ApplicationDetailsActionModals'; import { ApplicationDetailsActionModals } from '@/features/onboarding/components/application-details/ApplicationDetailsActionModals';
import { ApplicationDetailsExtendedModals } from './application-details/ApplicationDetailsExtendedModals'; import { ApplicationDetailsExtendedModals } from '@/features/onboarding/components/application-details/ApplicationDetailsExtendedModals';
import { ApplicationDetailsFddAuditContent } from './application-details/ApplicationDetailsFddAuditContent'; import { ApplicationDetailsFddAuditContent } from '@/features/onboarding/components/application-details/ApplicationDetailsFddAuditContent';
import { KT_MATRIX_CRITERIA, auditLogActionBadgeClass } from './application-details/applicationDetails.shared'; import { KT_MATRIX_CRITERIA, auditLogActionBadgeClass } from '@/features/onboarding/components/application-details/applicationDetails.shared';
import { useApplicationDetailsPermissions } from './application-details/useApplicationDetailsPermissions'; import { useApplicationDetailsPermissions } from '@/features/onboarding/hooks/useApplicationDetailsPermissions';
import { useApplicationDetailsUIState } from './application-details/useApplicationDetailsUIState'; import { useApplicationDetailsUIState } from '@/features/onboarding/hooks/useApplicationDetailsUIState';
import { useApplicationDetailsFeedbackActions } from './application-details/useApplicationDetailsFeedbackActions'; import { useApplicationDetailsFeedbackActions } from '@/features/onboarding/hooks/useApplicationDetailsFeedbackActions';
import { useApplicationDetailsAdminActions } from './application-details/useApplicationDetailsAdminActions'; import { useApplicationDetailsAdminActions } from '@/features/onboarding/hooks/useApplicationDetailsAdminActions';
import { useApplicationDetailsData } from './application-details/useApplicationDetailsData'; import { useApplicationDetailsData } from '@/features/onboarding/hooks/useApplicationDetailsData';
import { useApplicationDetailsLocalActions } from './application-details/useApplicationDetailsLocalActions'; import { useApplicationDetailsLocalActions } from '@/features/onboarding/hooks/useApplicationDetailsLocalActions';
import { useApplicationDetailsStageData } from './application-details/useApplicationDetailsStageData'; import { useApplicationDetailsStageData } from '@/features/onboarding/hooks/useApplicationDetailsStageData';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '../../store'; import { RootState } from '@/store';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';

View File

@ -1,23 +1,23 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { locations, ApplicationStatus, Application } from '../../lib/mock-data'; import { locations, ApplicationStatus, Application } from '@/lib/mock-data';
import { formatDateTime } from '../ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { onboardingService } from '../../services/onboarding.service'; import { onboardingService } from '@/services/onboarding.service';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '../ui/input'; import { Input } from '@/components/ui/input';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectItem, SelectItem,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '../ui/select'; } from '@/components/ui/select';
import { import {
Search, Search,
Download, Download,
Mail Mail
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { Checkbox } from '../ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Table, Table,
TableBody, TableBody,
@ -25,12 +25,12 @@ import {
TableHead, TableHead,
TableHeader, TableHeader,
TableRow, TableRow,
} from '../ui/table'; } from '@/components/ui/table';
import { Progress } from '../ui/progress'; import { Progress } from '@/components/ui/progress';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '../../store'; import { RootState } from '@/store';
interface ApplicationsPageProps { interface ApplicationsPageProps {
onViewDetails: (id: string) => void; onViewDetails: (id: string) => void;
@ -195,13 +195,8 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Info Banner - Only visible for DD users */}
{/* Note: This page shows only applications that have been shortlisted */}
{/* Filters and Actions Bar */}
<div className="bg-white rounded-lg border border-slate-200 p-4"> <div className="bg-white rounded-lg border border-slate-200 p-4">
<div className="flex flex-col lg:flex-row gap-4"> <div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input <Input
@ -210,12 +205,12 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10" className="pl-10"
data-testid="onboarding-applications-search-input"
/> />
</div> </div>
{/* Location Filter */}
<Select value={locationFilter} onValueChange={setLocationFilter}> <Select value={locationFilter} onValueChange={setLocationFilter}>
<SelectTrigger className="w-full lg:w-48"> <SelectTrigger className="w-full lg:w-48" data-testid="onboarding-applications-location-filter">
<SelectValue placeholder="All Locations" /> <SelectValue placeholder="All Locations" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -228,9 +223,8 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</SelectContent> </SelectContent>
</Select> </Select>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full lg:w-48"> <SelectTrigger className="w-full lg:w-48" data-testid="onboarding-applications-status-filter">
<SelectValue placeholder="All Statuses" /> <SelectValue placeholder="All Statuses" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -246,19 +240,18 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</SelectContent> </SelectContent>
</Select> </Select>
{/* My Assignments Filter */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="my-assignments" id="my-assignments"
checked={showMyAssignments} checked={showMyAssignments}
onCheckedChange={(checked) => setShowMyAssignments(checked as boolean)} onCheckedChange={(checked) => setShowMyAssignments(checked as boolean)}
data-testid="onboarding-applications-assignments-checkbox"
/> />
<Label htmlFor="my-assignments">My Assignments Only</Label> <Label htmlFor="my-assignments" data-testid="onboarding-applications-assignments-label">My Assignments Only</Label>
</div> </div>
{/* Sort By */}
<Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}> <Select value={sortBy} onValueChange={(v) => setSortBy(v as any)}>
<SelectTrigger className="w-full lg:w-40"> <SelectTrigger className="w-full lg:w-40" data-testid="onboarding-applications-sort-select">
<SelectValue placeholder="Sort By" /> <SelectValue placeholder="Sort By" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -267,12 +260,12 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</Select> </Select>
</div> </div>
{/* Action Buttons */}
<div className="flex flex-wrap items-center gap-3 mt-4"> <div className="flex flex-wrap items-center gap-3 mt-4">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleExport} onClick={handleExport}
data-testid="onboarding-applications-export-button"
> >
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
Export Export
@ -283,27 +276,28 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
variant="outline" variant="outline"
size="sm" size="sm"
onClick={handleBulkReminders} onClick={handleBulkReminders}
data-testid="onboarding-applications-reminders-button"
> >
<Mail className="w-4 h-4 mr-2" /> <Mail className="w-4 h-4 mr-2" />
Send Reminders ({selectedIds.length}) Send Reminders ({selectedIds.length})
</Button> </Button>
)} )}
<div className="ml-auto text-slate-600"> <div className="ml-auto text-slate-600" data-testid="onboarding-applications-count-text">
{filteredApplications.length} application{filteredApplications.length !== 1 ? 's' : ''} {filteredApplications.length} application{filteredApplications.length !== 1 ? 's' : ''}
</div> </div>
</div> </div>
</div> </div>
{/* Applications Table */}
<div className="bg-white rounded-lg border border-slate-200"> <div className="bg-white rounded-lg border border-slate-200">
<Table> <Table data-testid="onboarding-applications-table">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-12"> <TableHead className="w-12">
<Checkbox <Checkbox
checked={selectedIds.length === filteredApplications.length} checked={selectedIds.length === filteredApplications.length}
onCheckedChange={toggleSelectAll} onCheckedChange={toggleSelectAll}
data-testid="onboarding-applications-header-checkbox"
/> />
</TableHead> </TableHead>
<TableHead>ID</TableHead> <TableHead>ID</TableHead>
@ -317,32 +311,33 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredApplications.map((app) => ( {filteredApplications.map((app, idx) => (
<TableRow key={app.id}> <TableRow key={app.id} data-testid={`onboarding-application-row-${idx}`}>
<TableCell> <TableCell>
<Checkbox <Checkbox
checked={selectedIds.includes(app.id)} checked={selectedIds.includes(app.id)}
onCheckedChange={() => toggleSelection(app.id)} onCheckedChange={() => toggleSelection(app.id)}
data-testid={`onboarding-application-checkbox-${idx}`}
/> />
</TableCell> </TableCell>
<TableCell>{app.registrationNumber}</TableCell> <TableCell data-testid={`onboarding-application-id-${idx}`}>{app.registrationNumber}</TableCell>
<TableCell>{app.name}</TableCell> <TableCell data-testid={`onboarding-application-name-${idx}`}>{app.name}</TableCell>
<TableCell>{app.preferredLocation}</TableCell> <TableCell data-testid={`onboarding-application-pref-location-${idx}`}>{app.preferredLocation}</TableCell>
<TableCell> <TableCell>
<Badge className={getStatusColor(app.status)}> <Badge className={getStatusColor(app.status)} data-testid={`onboarding-application-status-${idx}`}>
{app.status} {app.status}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-slate-600 max-w-xs truncate"> <TableCell className="text-slate-600 max-w-xs truncate" data-testid={`onboarding-application-addr-${idx}`}>
{app.residentialAddress} {app.residentialAddress}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress value={app.progress} className="h-2 w-20" /> <Progress value={app.progress} className="h-2 w-20" data-testid={`onboarding-application-progress-bar-${idx}`} />
<span className="text-slate-600">{app.progress}%</span> <span className="text-slate-600" data-testid={`onboarding-application-progress-text-${idx}`}>{app.progress}%</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell data-testid={`onboarding-application-date-${idx}`}>
{formatDateTime(app.submissionDate)} {formatDateTime(app.submissionDate)}
</TableCell> </TableCell>
<TableCell> <TableCell>
@ -350,6 +345,7 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
size="sm" size="sm"
variant="outline" variant="outline"
onClick={() => onViewDetails(app.id)} onClick={() => onViewDetails(app.id)}
data-testid={`onboarding-application-view-btn-${idx}`}
> >
View View
</Button> </Button>
@ -360,9 +356,8 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</Table> </Table>
</div> </div>
{/* New Application Modal */}
<Dialog open={showNewApplicationModal} onOpenChange={setShowNewApplicationModal}> <Dialog open={showNewApplicationModal} onOpenChange={setShowNewApplicationModal}>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl" data-testid="onboarding-new-app-modal">
<DialogHeader> <DialogHeader>
<DialogTitle>Add New Application (Admin)</DialogTitle> <DialogTitle>Add New Application (Admin)</DialogTitle>
</DialogHeader> </DialogHeader>
@ -371,20 +366,20 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<Label>Name</Label> <Label>Name</Label>
<Input placeholder="Full Name" /> <Input placeholder="Full Name" data-testid="onboarding-new-app-name" />
</div> </div>
<div> <div>
<Label>Email</Label> <Label>Email</Label>
<Input type="email" placeholder="email@example.com" /> <Input type="email" placeholder="email@example.com" data-testid="onboarding-new-app-email" />
</div> </div>
<div> <div>
<Label>Phone</Label> <Label>Phone</Label>
<Input placeholder="+91 XXXXX XXXXX" /> <Input placeholder="+91 XXXXX XXXXX" data-testid="onboarding-new-app-phone" />
</div> </div>
<div> <div>
<Label>Preferred Location</Label> <Label>Preferred Location</Label>
<Select> <Select>
<SelectTrigger> <SelectTrigger data-testid="onboarding-new-app-location-select">
<SelectValue placeholder="Select location" /> <SelectValue placeholder="Select location" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -396,10 +391,10 @@ export function ApplicationsPage({ onViewDetails, initialFilter }: ApplicationsP
</div> </div>
</div> </div>
<div className="flex justify-end gap-3"> <div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => setShowNewApplicationModal(false)}> <Button variant="outline" onClick={() => setShowNewApplicationModal(false)} data-testid="onboarding-new-app-cancel">
Cancel Cancel
</Button> </Button>
<Button className="bg-amber-600 hover:bg-amber-700"> <Button className="bg-amber-600 hover:bg-amber-700" data-testid="onboarding-new-app-submit">
Create Application Create Application
</Button> </Button>
</div> </div>

View File

@ -1,10 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { API } from '../../api/API'; import { API } from '@/api/API';
import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '../ui/badge'; import { Badge } from '@/components/ui/badge';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { RootState } from '../../store'; import { RootState } from '@/store';
import { import {
ArrowLeft, ArrowLeft,
FileText, FileText,
@ -16,7 +16,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { WorkNotesPage } from './WorkNotesPage'; import { WorkNotesPage } from './WorkNotesPage';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { DocumentPreviewModal } from '../ui/DocumentPreviewModal'; import { DocumentPreviewModal } from '@/components/ui/DocumentPreviewModal';
import { formatDateTime } from '@/components/ui/utils'; import { formatDateTime } from '@/components/ui/utils';
import { import {
Dialog, Dialog,
@ -25,11 +25,11 @@ import {
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogFooter DialogFooter
} from '../ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '../ui/button'; import { Button } from '@/components/ui/button';
import { AlertTriangle, Info, ShieldCheck } from 'lucide-react'; import { AlertTriangle, Info, ShieldCheck } from 'lucide-react';
import { Label } from '../ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '../ui/textarea'; import { Textarea } from '@/components/ui/textarea';
export function FDDApplicationDetails() { export function FDDApplicationDetails() {
@ -158,7 +158,7 @@ export function FDDApplicationDetails() {
return ( return (
<div className="flex flex-col gap-6 max-w-7xl mx-auto mb-10"> <div className="flex flex-col gap-6 max-w-7xl mx-auto mb-10">
{application?.statutoryStatus === 'Flagged' && ( {application?.statutoryStatus === 'Flagged' && (
<div className="bg-red-50 border border-red-200 p-4 rounded-xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500"> <div className="bg-red-50 border border-red-200 p-4 rounded-xl flex items-center gap-4 animate-in fade-in slide-in-from-top-4 duration-500" data-testid="onboarding-fdd-details-flag-banner">
<div className="bg-red-100 p-2 rounded-lg"> <div className="bg-red-100 p-2 rounded-lg">
<AlertTriangle className="w-5 h-5 text-red-600" /> <AlertTriangle className="w-5 h-5 text-red-600" />
</div> </div>
@ -173,6 +173,7 @@ export function FDDApplicationDetails() {
<button <button
onClick={() => navigate('/fdd-dashboard')} onClick={() => navigate('/fdd-dashboard')}
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 font-medium transition-all group" className="flex items-center gap-2 text-slate-600 hover:text-slate-900 font-medium transition-all group"
data-testid="onboarding-fdd-details-back-btn"
> >
<div className="p-2 rounded-full group-hover:bg-slate-100 transition-colors"> <div className="p-2 rounded-full group-hover:bg-slate-100 transition-colors">
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
@ -181,7 +182,7 @@ export function FDDApplicationDetails() {
</button> </button>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{isNotReachedYet ? ( {isNotReachedYet ? (
<div className="flex items-center gap-2 px-4 py-2 bg-slate-100 border border-slate-200 text-slate-500 font-bold text-[10px] uppercase tracking-[0.1em] rounded-lg"> <div className="flex items-center gap-2 px-4 py-2 bg-slate-100 border border-slate-200 text-slate-500 font-bold text-[10px] uppercase tracking-[0.1em] rounded-lg" data-testid="onboarding-fdd-details-awaiting-badge">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
Awaiting Previous Stages Awaiting Previous Stages
</div> </div>
@ -192,6 +193,7 @@ export function FDDApplicationDetails() {
disabled={uploading} disabled={uploading}
onClick={() => setShowFlagModal(true)} onClick={() => setShowFlagModal(true)}
className="px-4 py-2 bg-red-50 text-red-600 font-bold text-xs uppercase tracking-wider hover:bg-red-100 rounded-lg transition-all flex items-center gap-2 border border-red-100 shadow-sm" className="px-4 py-2 bg-red-50 text-red-600 font-bold text-xs uppercase tracking-wider hover:bg-red-100 rounded-lg transition-all flex items-center gap-2 border border-red-100 shadow-sm"
data-testid="onboarding-fdd-details-flag-btn"
> >
<AlertTriangle className="w-4 h-4" /> <AlertTriangle className="w-4 h-4" />
Flag Non-Responsive Flag Non-Responsive
@ -199,7 +201,7 @@ export function FDDApplicationDetails() {
)} )}
</> </>
) : ( ) : (
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 border border-green-200 text-green-700 font-bold text-[10px] uppercase tracking-[0.1em] rounded-lg shadow-inner"> <div className="flex items-center gap-2 px-4 py-2 bg-green-50 border border-green-200 text-green-700 font-bold text-[10px] uppercase tracking-[0.1em] rounded-lg shadow-inner" data-testid="onboarding-fdd-details-submitted-badge">
<CheckCircle2 className="w-4 h-4" /> <CheckCircle2 className="w-4 h-4" />
Final Audit Report Submitted Final Audit Report Submitted
</div> </div>
@ -208,21 +210,21 @@ export function FDDApplicationDetails() {
</div> </div>
{/* Header Card */} {/* Header Card */}
<Card className="border border-slate-200 shadow-sm bg-white"> <Card className="border border-slate-200 shadow-sm bg-white" data-testid="onboarding-fdd-details-header">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-14 h-14 bg-slate-900 text-white rounded-lg flex items-center justify-center font-bold text-xl"> <div className="w-14 h-14 bg-slate-900 text-white rounded-lg flex items-center justify-center font-bold text-xl" data-testid="onboarding-fdd-details-avatar">
{application.applicantName.charAt(0)} {application.applicantName.charAt(0)}
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">{application.applicantName}</h1> <h1 className="text-2xl font-bold text-slate-900 tracking-tight" data-testid="onboarding-fdd-details-name">{application.applicantName}</h1>
<Badge variant="outline" className="text-slate-500 font-medium px-2 py-0"> <Badge variant="outline" className="text-slate-500 font-medium px-2 py-0" data-testid="onboarding-fdd-details-id-badge">
{application.applicationId} {application.applicationId}
</Badge> </Badge>
</div> </div>
<div className="flex items-center gap-3 text-sm text-slate-500"> <div className="flex items-center gap-3 text-sm text-slate-500" data-testid="onboarding-fdd-details-meta">
<span>{application.city}, {application.state}</span> <span>{application.city}, {application.state}</span>
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
<span>{application.businessType || 'Dealership'}</span> <span>{application.businessType || 'Dealership'}</span>
@ -240,12 +242,13 @@ export function FDDApplicationDetails() {
</Card> </Card>
{/* Navigation Tabs */} {/* Navigation Tabs */}
<div className="flex items-center gap-8 border-b border-slate-200"> <div className="flex items-center gap-8 border-b border-slate-200" data-testid="onboarding-fdd-details-tabs">
<button <button
onClick={() => setActiveTab('details')} onClick={() => setActiveTab('details')}
className={`pb-3 text-sm font-semibold transition-all relative ${ className={`pb-3 text-sm font-semibold transition-all relative ${
activeTab === 'details' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700' activeTab === 'details' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`} }`}
data-testid="onboarding-fdd-details-tab-workspace"
> >
Workspace Workspace
{activeTab === 'details' && <div className="absolute bottom-[-1px] left-0 right-0 h-0.5 bg-blue-600" />} {activeTab === 'details' && <div className="absolute bottom-[-1px] left-0 right-0 h-0.5 bg-blue-600" />}
@ -255,6 +258,7 @@ export function FDDApplicationDetails() {
className={`pb-3 text-sm font-semibold transition-all relative ${ className={`pb-3 text-sm font-semibold transition-all relative ${
activeTab === 'worknotes' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700' activeTab === 'worknotes' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`} }`}
data-testid="onboarding-fdd-details-tab-worknotes"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
Work Notes Work Notes
@ -267,7 +271,7 @@ export function FDDApplicationDetails() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column: Financial Data & Uploads */} {/* Left Column: Financial Data & Uploads */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
<Card className="border border-slate-200 shadow-sm bg-white"> <Card className="border border-slate-200 shadow-sm bg-white" data-testid="onboarding-fdd-details-workspace-card">
<CardHeader className="border-b border-slate-100 px-6 py-4"> <CardHeader className="border-b border-slate-100 px-6 py-4">
<CardTitle className="text-base font-bold flex items-center gap-2 text-slate-800"> <CardTitle className="text-base font-bold flex items-center gap-2 text-slate-800">
<Upload className="w-4 h-4 text-slate-500" /> <Upload className="w-4 h-4 text-slate-500" />
@ -276,7 +280,7 @@ export function FDDApplicationDetails() {
</CardHeader> </CardHeader>
<CardContent className="p-6"> <CardContent className="p-6">
{isNotReachedYet && ( {isNotReachedYet && (
<div className="mb-6 p-8 bg-slate-50 border border-dashed border-slate-200 rounded-xl flex flex-col items-center justify-center text-center"> <div className="mb-6 p-8 bg-slate-50 border border-dashed border-slate-200 rounded-xl flex flex-col items-center justify-center text-center" data-testid="onboarding-fdd-details-not-active">
<div className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-slate-300 mb-4 shadow-sm"> <div className="w-16 h-16 bg-white rounded-full flex items-center justify-center text-slate-300 mb-4 shadow-sm">
<Clock className="w-8 h-8" /> <Clock className="w-8 h-8" />
</div> </div>
@ -288,7 +292,7 @@ export function FDDApplicationDetails() {
</div> </div>
)} )}
{isCompleted && ( {isCompleted && (
<div className="mb-6 p-4 bg-green-50/50 border border-green-100 rounded-xl flex items-center gap-4"> <div className="mb-6 p-4 bg-green-50/50 border border-green-100 rounded-xl flex items-center gap-4" data-testid="onboarding-fdd-details-completed-alert">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center text-green-600 shrink-0"> <div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center text-green-600 shrink-0">
<CheckCircle2 className="w-5 h-5" /> <CheckCircle2 className="w-5 h-5" />
</div> </div>
@ -299,14 +303,14 @@ export function FDDApplicationDetails() {
</div> </div>
)} )}
{!isCompleted && !isNotReachedYet && ( {!isCompleted && !isNotReachedYet && (
<div className="p-10 border-2 border-dashed border-slate-200 rounded-lg flex flex-col items-center justify-center text-center"> <div className="p-10 border-2 border-dashed border-slate-200 rounded-lg flex flex-col items-center justify-center text-center" data-testid="onboarding-fdd-details-upload-section">
<div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center mb-4"> <div className="w-12 h-12 bg-slate-50 text-slate-400 rounded-full flex items-center justify-center mb-4">
<FileText className="w-6 h-6" /> <FileText className="w-6 h-6" />
</div> </div>
<p className="text-slate-600 font-medium mb-1"> <p className="text-slate-600 font-medium mb-1" data-testid="onboarding-fdd-details-upload-title">
{isFddRole ? 'Select and upload the due diligence report' : 'View Authorized Documents'} {isFddRole ? 'Select and upload the due diligence report' : 'View Authorized Documents'}
</p> </p>
<p className="text-slate-400 text-xs mb-6"> <p className="text-slate-400 text-xs mb-6" data-testid="onboarding-fdd-details-upload-hint">
{isFddRole ? 'PDF or JPG formats accepted (Max 10MB)' : 'You are in View-Only mode for this Audit'} {isFddRole ? 'PDF or JPG formats accepted (Max 10MB)' : 'You are in View-Only mode for this Audit'}
</p> </p>
@ -316,6 +320,7 @@ export function FDDApplicationDetails() {
value={selectedDocType} value={selectedDocType}
onChange={(e) => setSelectedDocType(e.target.value)} onChange={(e) => setSelectedDocType(e.target.value)}
className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded text-sm font-medium text-slate-700 outline-none focus:ring-1 focus:ring-blue-500 transition-all" className="w-full px-3 py-2 bg-slate-50 border border-slate-200 rounded text-sm font-medium text-slate-700 outline-none focus:ring-1 focus:ring-blue-500 transition-all"
data-testid="onboarding-fdd-details-doc-type-select"
> >
<option value="">Select Document Category...</option> <option value="">Select Document Category...</option>
<option value="FDD Final Audit Report">FDD Final Audit Report</option> <option value="FDD Final Audit Report">FDD Final Audit Report</option>
@ -328,7 +333,7 @@ export function FDDApplicationDetails() {
<div className="relative"> <div className="relative">
{uploading ? ( {uploading ? (
<div className="w-full py-2.5 bg-slate-100 rounded flex items-center justify-center gap-2"> <div className="w-full py-2.5 bg-slate-100 rounded flex items-center justify-center gap-2" data-testid="onboarding-fdd-details-uploading-state">
<Loader2 className="w-4 h-4 animate-spin text-slate-400" /> <Loader2 className="w-4 h-4 animate-spin text-slate-400" />
<span className="text-slate-500 text-xs font-bold uppercase tracking-wider">Uploading...</span> <span className="text-slate-500 text-xs font-bold uppercase tracking-wider">Uploading...</span>
</div> </div>
@ -339,10 +344,11 @@ export function FDDApplicationDetails() {
className="absolute inset-0 opacity-0 cursor-pointer" className="absolute inset-0 opacity-0 cursor-pointer"
onChange={handleFileUpload} onChange={handleFileUpload}
disabled={!selectedDocType} disabled={!selectedDocType}
data-testid="onboarding-fdd-details-file-input"
/> />
<div className={`w-full py-2.5 text-center font-bold uppercase tracking-wider text-xs rounded transition-all ${ <div className={`w-full py-2.5 text-center font-bold uppercase tracking-wider text-xs rounded transition-all ${
!selectedDocType ? 'bg-slate-100 text-slate-300' : 'bg-slate-900 text-white hover:bg-slate-800' !selectedDocType ? 'bg-slate-100 text-slate-300' : 'bg-slate-900 text-white hover:bg-slate-800'
}`}> }`} data-testid="onboarding-fdd-details-browse-btn">
Browse & Upload Browse & Upload
</div> </div>
</> </>
@ -354,28 +360,28 @@ export function FDDApplicationDetails() {
)} )}
{/* List of Uploaded Documents */} {/* List of Uploaded Documents */}
<div className="mt-8 border-t border-slate-100 pt-8"> <div className="mt-8 border-t border-slate-100 pt-8" data-testid="onboarding-fdd-details-documents-section">
<h3 className="text-sm font-bold text-slate-800 mb-4 px-1">Submitted Documentation</h3> <h3 className="text-sm font-bold text-slate-800 mb-4 px-1">Submitted Documentation</h3>
<div className="space-y-6"> <div className="space-y-6">
{/* SECTION 1: APPLICANT DOCUMENTS */} {/* SECTION 1: APPLICANT DOCUMENTS */}
<div> <div data-testid="onboarding-fdd-details-applicant-docs">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-2"> <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-blue-500"></div> <div className="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
Applicant's KYC & Financials Applicant's KYC & Financials
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{application.uploadedDocuments?.filter((d: any) => !d.uploader || d.uploader.roleCode !== 'FDD').map((doc: any, i: number) => ( {application.uploadedDocuments?.filter((d: any) => !d.uploader || d.uploader.roleCode !== 'FDD').map((doc: any, i: number) => (
<div key={i} className="p-3 border border-slate-100 rounded flex items-center justify-between hover:bg-slate-50 transition-all group"> <div key={i} className="p-3 border border-slate-100 rounded flex items-center justify-between hover:bg-slate-50 transition-all group" data-testid={`onboarding-fdd-details-applicant-doc-row-${i}`}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-slate-100 flex items-center justify-center text-slate-400 group-hover:bg-white transition-colors"> <div className="w-8 h-8 rounded bg-slate-100 flex items-center justify-center text-slate-400 group-hover:bg-white transition-colors">
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-xs font-bold text-slate-900">{doc.originalName || doc.fileName}</p> <p className="text-xs font-bold text-slate-900" data-testid={`onboarding-fdd-details-applicant-doc-name-${i}`}>{doc.originalName || doc.fileName}</p>
<span className="text-[8px] bg-slate-100 text-slate-500 px-1 py-0.5 rounded uppercase font-bold tracking-tighter">APPLICANT</span> <span className="text-[8px] bg-slate-100 text-slate-500 px-1 py-0.5 rounded uppercase font-bold tracking-tighter">APPLICANT</span>
</div> </div>
<p className="text-[10px] text-slate-400 font-medium"> <p className="text-[10px] text-slate-400 font-medium" data-testid={`onboarding-fdd-details-applicant-doc-meta-${i}`}>
{doc.documentType} {formatDateTime(doc.createdAt)} {doc.documentType} {formatDateTime(doc.createdAt)}
{doc.uploader?.fullName && ` • by ${doc.uploader.fullName}`} {doc.uploader?.fullName && ` • by ${doc.uploader.fullName}`}
</p> </p>
@ -386,6 +392,7 @@ export function FDDApplicationDetails() {
type="button" type="button"
onClick={() => handlePreview(doc)} onClick={() => handlePreview(doc)}
className="p-1.5 hover:bg-white rounded text-slate-400 hover:text-blue-600 transition-all" className="p-1.5 hover:bg-white rounded text-slate-400 hover:text-blue-600 transition-all"
data-testid={`onboarding-fdd-details-applicant-doc-preview-${i}`}
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</button> </button>
@ -393,30 +400,30 @@ export function FDDApplicationDetails() {
</div> </div>
))} ))}
{application.uploadedDocuments?.filter((d: any) => !d.uploader || d.uploader.roleCode !== 'FDD').length === 0 && ( {application.uploadedDocuments?.filter((d: any) => !d.uploader || d.uploader.roleCode !== 'FDD').length === 0 && (
<p className="text-[10px] text-slate-400 italic px-1">No documents from applicant yet.</p> <p className="text-[10px] text-slate-400 italic px-1" data-testid="onboarding-fdd-details-applicant-docs-empty">No documents from applicant yet.</p>
)} )}
</div> </div>
</div> </div>
{/* SECTION 2: MY SUBMISSIONS */} {/* SECTION 2: MY SUBMISSIONS */}
<div> <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"> <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-amber-500"></div>
My Uploaded Reports My Uploaded Reports
</p> </p>
<div className="space-y-2"> <div className="space-y-2">
{application.uploadedDocuments?.filter((d: any) => d.uploader?.roleCode === 'FDD').map((doc: any, i: number) => ( {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"> <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 className="flex items-center gap-3"> <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-amber-100 flex items-center justify-center text-amber-500">
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-xs font-bold text-slate-900">{doc.originalName || doc.fileName}</p> <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-amber-500 text-white px-1 py-0.5 rounded uppercase font-bold tracking-tighter">YOUR AUDIT REPORT</span>
</div> </div>
<p className="text-[10px] text-slate-400 font-medium"> <p className="text-[10px] text-slate-400 font-medium" data-testid={`onboarding-fdd-details-my-report-meta-${i}`}>
{doc.documentType} {formatDateTime(doc.createdAt)} {doc.documentType} {formatDateTime(doc.createdAt)}
{doc.uploader?.fullName && ` • by ${doc.uploader.fullName}`} {doc.uploader?.fullName && ` • by ${doc.uploader.fullName}`}
</p> </p>
@ -427,6 +434,7 @@ export function FDDApplicationDetails() {
type="button" type="button"
onClick={() => handlePreview(doc)} 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-amber-600 transition-all"
data-testid={`onboarding-fdd-details-my-report-preview-${i}`}
> >
<Eye className="w-4 h-4" /> <Eye className="w-4 h-4" />
</button> </button>
@ -434,7 +442,7 @@ export function FDDApplicationDetails() {
</div> </div>
))} ))}
{application.uploadedDocuments?.filter((d: any) => d.uploader?.roleCode === 'FDD').length === 0 && ( {application.uploadedDocuments?.filter((d: any) => d.uploader?.roleCode === 'FDD').length === 0 && (
<div className="text-center py-4 bg-slate-50 border border-dashed border-slate-200 rounded-lg"> <div className="text-center py-4 bg-slate-50 border border-dashed border-slate-200 rounded-lg" data-testid="onboarding-fdd-details-my-submissions-empty">
<p className="text-slate-400 text-[10px]">No audit reports uploaded yet.</p> <p className="text-slate-400 text-[10px]">No audit reports uploaded yet.</p>
</div> </div>
)} )}
@ -448,16 +456,16 @@ export function FDDApplicationDetails() {
{/* Right Column: Applicant Meta & Guidelines */} {/* Right Column: Applicant Meta & Guidelines */}
<div className="space-y-6"> <div className="space-y-6">
<Card className="border border-slate-200 shadow-sm bg-white"> <Card className="border border-slate-200 shadow-sm bg-white" data-testid="onboarding-fdd-details-profile-card">
<CardHeader className="border-b border-slate-100 px-6 py-4"> <CardHeader className="border-b border-slate-100 px-6 py-4">
<CardTitle className="text-xs font-bold uppercase tracking-wider text-slate-500">Applicant Profile</CardTitle> <CardTitle className="text-xs font-bold uppercase tracking-wider text-slate-500">Applicant Profile</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-6 space-y-4"> <CardContent className="p-6 space-y-4">
<div className="space-y-1 pb-4 border-b border-slate-50"> <div className="space-y-1 pb-4 border-b border-slate-50" data-testid="onboarding-fdd-details-target-loc">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Target Location</p> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Target Location</p>
<p className="text-sm font-extrabold text-slate-900">{application.city}, {application.state}</p> <p className="text-sm font-extrabold text-slate-900">{application.city}, {application.state}</p>
</div> </div>
<div className="grid grid-cols-2 gap-4 text-xs"> <div className="grid grid-cols-2 gap-4 text-xs" data-testid="onboarding-fdd-details-profile-meta">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Education</p> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Education</p>
<p className="font-bold text-slate-800">{application.education || 'N/A'}</p> <p className="font-bold text-slate-800">{application.education || 'N/A'}</p>
@ -476,7 +484,7 @@ export function FDDApplicationDetails() {
</div> </div>
</div> </div>
<div className="space-y-1 pt-4 border-t border-slate-50 text-xs"> <div className="space-y-1 pt-4 border-t border-slate-50 text-xs" data-testid="onboarding-fdd-details-communication">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Communication</p> <p className="text-[10px] font-bold text-slate-400 uppercase tracking-wider">Communication</p>
<p className="font-bold text-slate-800">{application.email}</p> <p className="font-bold text-slate-800">{application.email}</p>
<p className="text-slate-500 font-medium">{application.phone}</p> <p className="text-slate-500 font-medium">{application.phone}</p>
@ -484,7 +492,7 @@ export function FDDApplicationDetails() {
</CardContent> </CardContent>
</Card> </Card>
<div className="p-6 bg-slate-900 rounded-lg text-white font-medium"> <div className="p-6 bg-slate-900 rounded-lg text-white font-medium" data-testid="onboarding-fdd-details-instructions">
<h4 className="text-sm font-bold mb-2">Instructions</h4> <h4 className="text-sm font-bold mb-2">Instructions</h4>
<ul className="text-xs text-slate-300 space-y-2 list-disc pl-4"> <ul className="text-xs text-slate-300 space-y-2 list-disc pl-4">
<li>Bank statements must cover 12 months.</li> <li>Bank statements must cover 12 months.</li>
@ -508,7 +516,7 @@ export function FDDApplicationDetails() {
{/* Finalize Confirmation Modal */} {/* Finalize Confirmation Modal */}
<Dialog open={showFinalizeModal} onOpenChange={setShowFinalizeModal}> <Dialog open={showFinalizeModal} onOpenChange={setShowFinalizeModal}>
<DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl"> <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="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="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"> <div className="w-16 h-16 bg-amber-600/20 rounded-full flex items-center justify-center animate-pulse relative z-10">
@ -517,13 +525,13 @@ export function FDDApplicationDetails() {
</div> </div>
<div className="p-8 space-y-4"> <div className="p-8 space-y-4">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-bold text-slate-900 text-center">Submit Audit Report</DialogTitle> <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"> <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-amber-500 decoration-2">notify the Admin</span> for review and approval.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-amber-50 p-4 rounded-xl flex gap-3 border border-amber-100 italic"> <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" /> <Info className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 leading-normal"> <p className="text-xs text-amber-800 leading-normal">
Once submitted, you cannot edit the findings. Ensure all documents are uploaded. Once submitted, you cannot edit the findings. Ensure all documents are uploaded.
@ -539,6 +547,7 @@ export function FDDApplicationDetails() {
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-amber-500 text-sm resize-none"
value={fddAuditFindings} value={fddAuditFindings}
onChange={(e) => setFddAuditFindings(e.target.value)} onChange={(e) => setFddAuditFindings(e.target.value)}
data-testid="onboarding-fdd-details-finalize-remarks"
/> />
</div> </div>
</div> </div>
@ -549,11 +558,13 @@ export function FDDApplicationDetails() {
className="w-full sm:flex-1 h-12 rounded-xl font-bold text-slate-600 hover:bg-slate-50 border-slate-200" className="w-full sm:flex-1 h-12 rounded-xl font-bold text-slate-600 hover:bg-slate-50 border-slate-200"
onClick={() => setShowFinalizeModal(false)} onClick={() => setShowFinalizeModal(false)}
disabled={uploading} disabled={uploading}
data-testid="onboarding-fdd-details-finalize-cancel"
> >
Cancel Cancel
</Button> </Button>
<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-amber-600"
data-testid="onboarding-fdd-details-finalize-confirm"
onClick={async () => { onClick={async () => {
try { try {
if (!fddAuditFindings.trim()) { if (!fddAuditFindings.trim()) {
@ -592,7 +603,7 @@ export function FDDApplicationDetails() {
{/* Flag Non-Responsive Confirmation Modal */} {/* Flag Non-Responsive Confirmation Modal */}
<Dialog open={showFlagModal} onOpenChange={setShowFlagModal}> <Dialog open={showFlagModal} onOpenChange={setShowFlagModal}>
<DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl"> <DialogContent className="max-w-md p-0 overflow-hidden border-none shadow-2xl" data-testid="onboarding-fdd-details-flag-modal">
<div className="bg-slate-950 p-6 flex items-center justify-center relative overflow-hidden"> <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-red-600/20 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-br from-red-600/20 to-transparent" />
<div className="w-16 h-16 bg-red-600/20 rounded-full flex items-center justify-center relative z-10"> <div className="w-16 h-16 bg-red-600/20 rounded-full flex items-center justify-center relative z-10">
@ -601,14 +612,14 @@ export function FDDApplicationDetails() {
</div> </div>
<div className="p-8 space-y-4"> <div className="p-8 space-y-4">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-bold text-slate-900 text-center">Flag Applicant</DialogTitle> <DialogTitle className="text-2xl font-bold text-slate-900 text-center" data-testid="onboarding-fdd-details-flag-modal-title">Flag Applicant</DialogTitle>
<DialogDescription className="text-slate-500 text-center pt-2 leading-relaxed text-base"> <DialogDescription className="text-slate-500 text-center pt-2 leading-relaxed text-base" data-testid="onboarding-fdd-details-flag-modal-desc">
Are you sure you want to flag this applicant as <span className="font-bold text-red-600">Non-Responsive</span>? Are you sure you want to flag this applicant as <span className="font-bold text-red-600">Non-Responsive</span>?
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="bg-red-50 p-4 rounded-xl flex gap-3 border border-red-100 italic"> <div className="bg-red-50 p-4 rounded-xl flex gap-3 border border-red-100 italic">
<p className="text-xs text-red-800 leading-normal text-center w-full"> <p className="text-xs text-red-800 leading-normal text-center w-full" data-testid="onboarding-fdd-details-flag-modal-text">
"Applicant is non-responsive to FDD queries." "Applicant is non-responsive to FDD queries."
</p> </p>
</div> </div>
@ -619,11 +630,13 @@ export function FDDApplicationDetails() {
className="w-full sm:flex-1 h-12 rounded-xl font-bold text-slate-600 hover:bg-slate-50 border-slate-200" className="w-full sm:flex-1 h-12 rounded-xl font-bold text-slate-600 hover:bg-slate-50 border-slate-200"
onClick={() => setShowFlagModal(false)} onClick={() => setShowFlagModal(false)}
disabled={uploading} disabled={uploading}
data-testid="onboarding-fdd-details-flag-modal-cancel"
> >
Go Back Go Back
</Button> </Button>
<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-red-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-red-600"
data-testid="onboarding-fdd-details-flag-modal-confirm"
onClick={async () => { onClick={async () => {
try { try {
setUploading(true); setUploading(true);
@ -652,3 +665,4 @@ export function FDDApplicationDetails() {
</div> </div>
); );
} }

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