enhanced the questionnaraire ui and added upto interview lvel 3 implemented

This commit is contained in:
laxmanhalaki 2026-02-18 20:03:11 +05:30
parent 0ff5412d04
commit e786ea12cf
11 changed files with 2017 additions and 422 deletions

View File

@ -2,15 +2,18 @@ import { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from './store';
import { setCredentials, logout as logoutAction, initializeAuth } from './store/slices/authSlice';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { RoleGuard } from './components/auth/RoleGuard';
import { Routes, Route, Navigate, useLocation, useNavigate, Outlet } from 'react-router-dom';
import { ApplicationFormPage } from './components/public/ApplicationFormPage';
import PublicQuestionnairePage from './pages/public/PublicQuestionnairePage';
import { LoginPage } from './components/auth/LoginPage';
import { ProspectiveLoginPage } from './components/auth/ProspectiveLoginPage';
import { Sidebar } from './components/layout/Sidebar';
import { Header } from './components/layout/Header';
import { Dashboard } from './components/dashboard/Dashboard';
import { FinanceDashboard } from './components/dashboard/FinanceDashboard';
import { DealerDashboard } from './components/dashboard/DealerDashboard';
import { ProspectiveDashboardPage } from './components/dashboard/ProspectiveDashboardPage';
import { ApplicationsPage } from './components/applications/ApplicationsPage';
import { AllApplicationsPage } from './components/applications/AllApplicationsPage';
import { OpportunityRequestsPage } from './components/applications/OpportunityRequestsPage';
@ -44,14 +47,14 @@ import { toast } from 'sonner';
import { API } from './api/API';
// Layout Component
const AppLayout = ({ children, onLogout, title }: { children: React.ReactNode, onLogout: () => void, title: string }) => {
const AppLayout = ({ onLogout, title }: { onLogout: () => void, title: string }) => {
return (
<div className="flex h-screen bg-slate-50">
<Sidebar onLogout={onLogout} />
<div className="flex-1 flex flex-col overflow-hidden">
<Header title={title} onRefresh={() => window.location.reload()} />
<main className="flex-1 overflow-y-auto p-6">
{children}
<Outlet />
</main>
</div>
<Toaster />
@ -103,6 +106,16 @@ export default function App() {
navigate('/');
};
// Listen for 401 logout events from API client
useEffect(() => {
const onLogout = () => {
handleLogout();
toast.error('Session expired. Please login again.');
};
window.addEventListener('auth:logout', onLogout);
return () => window.removeEventListener('auth:logout', onLogout);
}, [dispatch, navigate]);
// Helper to determine page title based on path
const getPageTitle = (pathname: string) => {
if (pathname.startsWith('/applications/') && pathname.length > 14) return 'Application Details';
@ -148,6 +161,7 @@ export default function App() {
<>
<Routes>
<Route path="/admin-login" element={<LoginPage onLogin={handleLogin} />} />
<Route path="/prospective-login" element={<ProspectiveLoginPage />} />
<Route path="/questionnaire/:applicationId" element={<PublicQuestionnairePage />} />
<Route path="*" element={showAdminLogin ? <LoginPage onLogin={handleLogin} /> :
<ApplicationFormPage onAdminLogin={() => setShowAdminLogin(true)} />}
@ -160,8 +174,23 @@ export default function App() {
// Protected Routes
return (
<AppLayout onLogout={handleLogout} title={getPageTitle(location.pathname)}>
<Routes>
<Routes>
{/* Prospective Dealer Route - STRICTLY ISOLATED */}
<Route
path="/prospective-dashboard"
element={
<RoleGuard allowedRoles={['Prospective Dealer']}>
<ProspectiveDashboardPage />
</RoleGuard>
}
/>
{/* Internal & Dealer Routes - EXCLUDES Prospective Dealers */}
<Route element={
<RoleGuard excludeRoles={['Prospective Dealer']} redirectTo="/prospective-dashboard">
<AppLayout onLogout={handleLogout} title={getPageTitle(location.pathname)} />
</RoleGuard>
}>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* Dashboards */}
@ -257,8 +286,7 @@ export default function App() {
{/* Fallback */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</AppLayout>
</Route>
</Routes>
);
}

View File

@ -35,6 +35,12 @@ export const API = {
getAllQuestionnaires: () => client.get('/onboarding/questionnaires'),
getQuestionnaireById: (id: string) => client.get(`/onboarding/questionnaires/${id}`),
// Documents
uploadDocument: (id: string, data: any) => client.post(`/onboarding/applications/${id}/documents`, data, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
getDocuments: (id: string) => client.get(`/onboarding/applications/${id}/documents`),
// Public Questionnaire
getPublicQuestionnaire: (appId: string) => axios.get(`http://localhost:5000/api/questionnaire/public/${appId}`), // Direct axios to bypass interceptors if client has auth
submitPublicResponse: (data: any) => axios.post('http://localhost:5000/api/questionnaire/public/submit', data),
@ -44,6 +50,9 @@ export const API = {
scheduleInterview: (data: any) => client.post('/assessment/interviews', data),
updateInterview: (id: string, data: any) => client.put(`/assessment/interviews/${id}`, data),
submitEvaluation: (id: string, data: any) => client.post(`/assessment/interviews/${id}/evaluation`, data),
submitKTMatrix: (data: any) => client.post('/assessment/kt-matrix', data),
submitLevel2Feedback: (data: any) => client.post('/assessment/level2-feedback', data),
getInterviews: (applicationId: string) => client.get(`/assessment/interviews/${applicationId}`),
// Collaboration & Participants
getWorknotes: (requestId: string, requestType: string) => client.get('/collaboration/worknotes', { params: { requestId, requestType } }),
@ -57,6 +66,10 @@ export const API = {
updateUser: (id: string, data: any) => client.put(`/admin/users/${id}`, data),
updateUserStatus: (id: string, data: any) => client.patch(`/admin/users/${id}/status`, data),
deleteUser: (id: string) => client.delete(`/admin/users/${id}`),
// Prospective Login
sendOtp: (phone: string) => client.post('/prospective-login/send-otp', { phone }),
verifyOtp: (phone: string, otp: string) => client.post('/prospective-login/verify-otp', { phone, otp }),
};
export default API;

View File

@ -24,7 +24,8 @@ client.addResponseTransform((response) => {
if (!response.ok) {
if (response.status === 401) {
console.error('Unauthorized access - potential token expiration');
// Potential logic to logout user or refresh token
// Dispatch global event for App to handle logout
window.dispatchEvent(new Event('auth:logout'));
}
}
});

File diff suppressed because it is too large Load Diff

View File

@ -198,6 +198,25 @@ export function LoginPage({ onLogin }: LoginPageProps) {
'Login'
)}
</Button>
<div className="text-center">
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-slate-200" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-white px-2 text-slate-500">Or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full border-amber-600 text-amber-600 hover:bg-amber-50 h-11"
onClick={() => window.location.href = '/prospective-login'}
>
Prospective User Login
</Button>
</div>
</form>
) : (
<form onSubmit={handleForgotPassword} className="space-y-6">

View File

@ -0,0 +1,235 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { AlertCircle, ArrowLeft, Smartphone } from 'lucide-react';
import { toast } from 'sonner';
import { API } from '../../api/API';
import { useDispatch } from 'react-redux';
import { setCredentials } from '../../store/slices/authSlice';
export function ProspectiveLoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
const [step, setStep] = useState<'PHONE' | 'OTP'>('PHONE');
const [phone, setPhone] = useState('');
const [otp, setOtp] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault();
if (!phone || phone.length < 10) {
setError('Please enter a valid 10-digit phone number');
return;
}
setIsLoading(true);
setError('');
try {
const response = await API.sendOtp(phone) as any;
if (response.ok) {
setStep('OTP');
toast.success('OTP sent successfully!');
} else {
console.error('Send OTP error response:', response);
const errorMessage = response.data?.message || 'Failed to send OTP';
setError(errorMessage);
toast.error(errorMessage);
}
} catch (err: any) {
console.error('Send OTP network error:', err);
setError('Network error. Please try again.');
toast.error('Network error. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault();
if (!otp || otp.length < 6) {
setError('Please enter a valid 6-digit OTP');
return;
}
setIsLoading(true);
setError('');
try {
const response = await API.verifyOtp(phone, otp) as any;
if (response.ok && response.data) {
// Fix: Extract from nested data object
const { token, user } = response.data.data || response.data;
if (!token || !user) {
throw new Error('Invalid response format');
}
// Dispatch login action
dispatch(setCredentials({ user, token }));
// Store token in localStorage
localStorage.setItem('token', token);
toast.success('Logged in successfully!');
navigate('/prospective-dashboard');
} else {
const errorMessage = response.data?.message || 'Invalid OTP';
setError(errorMessage);
toast.error(errorMessage);
}
} catch (err: any) {
console.error('Verify OTP error:', err);
setError('An unexpected error occurred');
toast.error('An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 p-4 overflow-y-auto">
<div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-40 -right-40 w-80 h-80 bg-amber-600/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-amber-600/10 rounded-full blur-3xl"></div>
</div>
<div className="relative w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-20 h-20 bg-amber-600 rounded-full mb-4">
<svg viewBox="0 0 24 24" className="w-12 h-12 text-white" fill="currentColor">
<path d="M12 2L4 6v6c0 5.5 3.8 10.7 8 12 4.2-1.3 8-6.5 8-12V6l-8-4zm0 2.2l6 3v4.8c0 4.5-3.1 8.7-6 10-2.9-1.3-6-5.5-6-10V7.2l6-3z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
</div>
<h1 className="text-white mb-2">Royal Enfield</h1>
<p className="text-slate-400">Dealer Login</p>
</div>
<div className="bg-white rounded-lg shadow-2xl p-8">
<div className="mb-6">
<Button
variant="ghost"
className="px-0 flex items-center gap-2 text-slate-600 hover:text-slate-900 mb-4 hover:bg-transparent"
onClick={() => step === 'OTP' ? setStep('PHONE') : navigate('/admin-login')}
>
<ArrowLeft className="w-4 h-4" />
{step === 'OTP' ? 'Change Phone Number' : 'Back to Login'}
</Button>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 rounded-lg">
<Smartphone className="w-6 h-6 text-amber-600" />
</div>
<div>
<h2 className="text-slate-900 text-lg font-semibold">Dealer Login</h2>
<p className="text-slate-600 text-sm">Login with your registered phone number</p>
</div>
</div>
</div>
{step === 'PHONE' ? (
<form onSubmit={handleSendOtp} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="phone">Registered Phone Number</Label>
<Input
id="phone"
type="tel"
placeholder="Enter 10-digit phone number"
maxLength={10}
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
className="w-full"
disabled={isLoading}
/>
<p className="text-slate-500 text-xs">Enter the phone number you used during application</p>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-red-600 font-medium text-sm">{error}</span>
</div>
)}
<Button
type="submit"
className="w-full bg-amber-600 hover:bg-amber-700 h-9"
disabled={isLoading || phone.length < 10}
>
{isLoading ? 'Sending...' : 'Send OTP'}
</Button>
<div className="text-center text-slate-500 text-xs">
<p>You will receive a 6-digit OTP on your registered mobile number</p>
</div>
</form>
) : (
<form onSubmit={handleVerifyOtp} className="space-y-6">
<div className="p-4 bg-green-50 border border-green-200 rounded-lg">
<p className="text-green-800 text-center text-sm">OTP sent to +91 {phone}</p>
</div>
<div className="space-y-2">
<Label htmlFor="otp">Enter OTP</Label>
<Input
id="otp"
type="text"
placeholder="Enter 6-digit OTP"
maxLength={6}
value={otp}
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ''))}
className="w-full text-center text-2xl tracking-widest"
disabled={isLoading}
/>
<p className="text-slate-500 text-xs text-center">Check your SMS for the OTP</p>
</div>
{error && (
<div className="flex items-center gap-2 p-3 bg-red-50 border border-red-200 rounded-md">
<AlertCircle className="w-4 h-4 text-red-600" />
<span className="text-red-600 font-medium text-sm">{error}</span>
</div>
)}
<Button
type="submit"
className="w-full bg-amber-600 hover:bg-amber-700 h-9"
disabled={isLoading || otp.length < 6}
>
{isLoading ? 'Verifying...' : 'Verify OTP'}
</Button>
<div className="text-center text-sm">
<button
type="button"
className="text-amber-600 hover:text-amber-700 font-medium"
onClick={() => setStep('PHONE')}
>
Change Phone Number
</button>
<span className="mx-2 text-slate-400">|</span>
<button
type="button"
className="text-amber-600 hover:text-amber-700 font-medium"
onClick={handleSendOtp}
disabled={isLoading}
>
Resend OTP
</button>
</div>
</form>
)}
</div>
<div className="text-center mt-6 text-slate-400">
<p>© 2025 Royal Enfield. All rights reserved.</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
interface RoleGuardProps {
children: React.ReactNode;
allowedRoles?: string[];
excludeRoles?: string[];
redirectTo?: string;
}
export const RoleGuard: React.FC<RoleGuardProps> = ({
children,
allowedRoles,
excludeRoles,
redirectTo
}) => {
const { user, isAuthenticated, loading } = useSelector((state: RootState) => state.auth);
const location = useLocation();
if (loading) {
return <div className="flex h-screen items-center justify-center">Loading...</div>;
}
if (!isAuthenticated) {
return <Navigate to="/admin-login" state={{ from: location }} replace />;
}
// Check excluded roles first (e.g. Block Prospective Dealer from main dashboard)
if (excludeRoles && user && excludeRoles.includes(user.role)) {
// If prospective dealer is excluded, redirect to their dashboard
if (user.role === 'Prospective Dealer') {
return <Navigate to="/prospective-dashboard" replace />;
}
return <Navigate to={redirectTo || '/dashboard'} replace />;
}
// Check allowed roles (e.g. Only Prospective Dealer can see their dashboard)
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
// If regular dealer tries to access prospective dashboard
return <Navigate to={redirectTo || '/dashboard'} replace />;
}
return <>{children}</>;
};

View File

@ -0,0 +1,332 @@
import { useState, useEffect } from 'react';
import {
LayoutDashboard,
FileText,
UserMinus,
RefreshCcw,
MapPin,
LogOut,
Search,
ChevronLeft,
ChevronRight,
Bell,
User,
RefreshCw,
HelpCircle,
Upload,
Clock,
CheckCircle,
File,
X
} from 'lucide-react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { RootState } from '../../store';
import { logout } from '../../store/slices/authSlice';
import { toast } from 'sonner';
import { API } from '../../api/API';
export function ProspectiveDashboardPage() {
const dispatch = useDispatch();
const navigate = useNavigate();
const { user } = useSelector((state: RootState) => state.auth);
const [collapsed, setCollapsed] = useState(false);
const [activeTab, setActiveTab] = useState('applicant');
// Document State
const [documents, setDocuments] = useState<any[]>([]);
const [selectedDocType, setSelectedDocType] = useState('');
const [file, setFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (user?.id) {
fetchDocuments();
}
}, [user?.id]);
const fetchDocuments = async () => {
try {
const response: any = await API.getDocuments(user.id);
if (response.ok && response.data) {
setDocuments(response.data.data);
}
} catch (error) {
console.error('Failed to fetch documents', error);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
}
};
const handleUpload = async () => {
if (!file || !selectedDocType) {
toast.error('Please select a document type and file');
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('documentType', selectedDocType);
setIsUploading(true);
try {
// Using user.id as it corresponds to Application ID (UUID) from login response
const response: any = await API.uploadDocument(user.id, formData);
if (response.ok) {
toast.success('Document uploaded successfully');
setFile(null);
setSelectedDocType('');
// Reset file input manually if needed, or rely on state
const fileInput = document.getElementById('file-upload') as HTMLInputElement;
if (fileInput) fileInput.value = '';
fetchDocuments();
} else {
toast.error(response.data?.message || 'Upload failed');
}
} catch (error) {
console.error('Upload error:', error);
toast.error('Upload failed');
} finally {
setIsUploading(false);
}
};
const handleLogout = () => {
dispatch(logout());
toast.info('Logged out successfully');
navigate('/');
};
return (
<div className="flex h-screen bg-slate-50">
{/* Sidebar */}
<div className={`bg-slate-900 text-white h-screen flex flex-col transition-all duration-300 ${collapsed ? 'w-20' : 'w-64'}`}>
<div className="p-4 border-b border-slate-800">
<div className="flex items-center justify-between">
{!collapsed && (
<div className="flex items-center gap-2">
<div className="w-10 h-10 bg-amber-600 rounded-lg flex items-center justify-center">
<FileText className="w-6 h-6 text-white" />
</div>
<span className="text-amber-600 font-bold">Applicant Portal</span>
</div>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-1 hover:bg-slate-800 rounded transition-colors"
>
{collapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronLeft className="w-5 h-5" />}
</button>
</div>
</div>
{!collapsed && (
<div className="p-4 border-b border-slate-800">
{/* Search Removed/hidden */}
</div>
)}
<nav className="flex-1 p-4 space-y-2">
<div>
<button
onClick={() => setActiveTab('applicant')}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${activeTab === 'applicant' ? 'bg-amber-600 text-white' : 'text-slate-300 hover:bg-slate-800 hover:text-white'}`}
>
<FileText className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="flex-1 text-left">My Application</span>}
</button>
</div>
</nav>
<div className="p-4 border-t border-slate-800 space-y-2">
{!collapsed && (
<div className="px-4 py-2 bg-slate-800 rounded-lg mb-2">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-600 rounded-full flex items-center justify-center">
<span>{user?.name?.charAt(0) || 'A'}</span>
</div>
<div className="flex-1 min-w-0">
<p className="truncate">{user?.name || 'Amit Sharma'}</p>
<p className="text-slate-400 truncate">{user?.role || 'Prospective'}</p>
</div>
</div>
</div>
)}
<button
onClick={handleLogout}
className={`w-full flex items-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all h-9 px-4 py-2 text-slate-300 hover:bg-slate-800 hover:text-white ${collapsed ? 'justify-center' : 'justify-start'}`}
>
<LogOut className="w-5 h-5 flex-shrink-0" />
{!collapsed && <span className="ml-3">Logout</span>}
</button>
</div>
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col overflow-hidden">
<header className="bg-white border-b border-slate-200 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-slate-900 text-xl font-semibold">Applicant Management</h1>
<p className="text-slate-600 text-sm">Manage and track dealership applications</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 px-3 py-2 bg-slate-100 rounded-lg">
<div className="w-8 h-8 bg-amber-600 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<div className="text-left">
<p className="text-slate-900 text-sm font-medium">{user?.name || 'Applicant'}</p>
<p className="text-slate-600 text-xs">{user?.role || 'User'}</p>
</div>
</div>
<button className="p-2 rounded-md hover:bg-slate-100" title="Refresh">
<RefreshCw className="w-4 h-4 text-slate-600" />
</button>
<button className="p-2 rounded-md hover:bg-slate-100" title="Help">
<HelpCircle className="w-4 h-4 text-slate-600" />
</button>
<button className="relative p-2 rounded-md hover:bg-slate-100">
<Bell className="w-4 h-4 text-slate-600" />
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
</button>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto p-6">
{activeTab === 'applicant' ? (
<div className="space-y-6">
<div>
<h1 className="text-slate-900 text-2xl font-bold mb-2">Applicant Portal</h1>
<p className="text-slate-600">Upload required documents for verification</p>
</div>
{/* Document Upload Card */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="p-6 border-b border-slate-200">
<h4 className="flex items-center gap-2 text-lg font-semibold text-slate-900">
<Upload className="w-5 h-5 text-blue-600" />
Document Upload
</h4>
<p className="text-slate-500 mt-1">Upload all required documents for your dealership application</p>
</div>
<div className="p-6 space-y-6">
<div className="space-y-4 p-4 bg-slate-50 rounded-lg border border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">Select Document Type</label>
<select
className="flex h-10 w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-400 disabled:cursor-not-allowed disabled:opacity-50"
value={selectedDocType}
onChange={(e) => setSelectedDocType(e.target.value)}
disabled={isUploading}
>
<option value="">Choose document type...</option>
<option value="PAN Card">PAN Card</option>
<option value="GST Certificate">GST Certificate</option>
<option value="Aadhaar Card">Aadhaar Card</option>
<option value="Trade License">Trade License</option>
<option value="Bank Statement">Bank Statement</option>
<option value="Property Document">Property Document</option>
<option value="Other">Other</option>
</select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-900">Upload File</label>
<div className="flex items-center gap-3">
<input
id="file-upload"
type="file"
className="block w-full text-sm text-slate-500 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-amber-600 file:text-white hover:file:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed"
onChange={handleFileChange}
disabled={isUploading}
/>
</div>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleUpload}
disabled={!file || !selectedDocType || isUploading}
className="px-4 py-2 bg-amber-600 text-white rounded-md hover:bg-amber-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{isUploading ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Uploading...
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload Document
</>
)}
</button>
</div>
<p className="text-slate-500 text-sm">Accepted formats: PDF, JPG, PNG (Max size: 10MB)</p>
</div>
<div className="space-y-3">
<h3 className="font-medium text-slate-900">Uploaded Documents ({documents.length})</h3>
{documents.length === 0 ? (
<div className="text-center py-8 text-slate-500 bg-slate-50 rounded-lg border border-dashed border-slate-300">
<FileText className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p>No documents uploaded yet</p>
</div>
) : (
<div className="space-y-2">
{documents.map((doc) => (
<div key={doc.id} className="flex items-center justify-between p-4 bg-white rounded-lg border border-slate-200 hover:border-amber-300 transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
<File className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-slate-900">{doc.documentType}</p>
<p className="text-slate-500 text-sm">{doc.fileName}</p>
<p className="text-slate-400 text-xs mt-1">Uploaded on {new Date(doc.createdAt).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium ${doc.status === 'Approved' ? 'bg-green-100 text-green-700 border-green-200' :
doc.status === 'Rejected' ? 'bg-red-100 text-red-700 border-red-200' :
'bg-yellow-100 text-yellow-700 border-yellow-200'
}`}>
{doc.status === 'Approved' ? <CheckCircle className="w-3 h-3 mr-1" /> : <Clock className="w-3 h-3 mr-1" />}
{doc.status || 'Pending'}
</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<h2 className="text-2xl font-semibold text-slate-900 mb-2">
{activeTab === 'dashboard' ? 'Dashboard' :
activeTab === 'resignations' ? 'My Resignations' :
activeTab === 'constitutional' ? 'Constitutional Change' :
'Relocation Requests'}
</h2>
<p className="text-slate-500">Coming soon...</p>
</div>
</div>
)}
</main>
</div >
</div >
);
}

View File

@ -16,7 +16,8 @@ export type UserRole =
| 'DDL'
| 'Finance'
| 'Finance Admin'
| 'Dealer';
| 'Dealer'
| 'Prospective Dealer';
export type ApplicationStatus =
| 'Submitted'
@ -48,6 +49,7 @@ export type ApplicationStatus =
| 'Statutory MSD'
| 'Statutory LOI Ack'
| 'EOR In Progress'
| 'EOR Complete'
| 'LOA Pending'
| 'Inauguration'
| 'Approved'

View File

@ -1,93 +1,368 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import QuestionnaireForm from '../../components/dealer/QuestionnaireForm';
import { useParams, useNavigate } from 'react-router-dom';
import { API } from '../../api/API';
import { toast } from 'sonner';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import {
User, RefreshCw, HelpCircle, Bell, ArrowLeft, Bike,
Users, Target, FileText, Award, ChevronLeft, ChevronRight,
CheckCircle, AlertCircle
} from 'lucide-react';
const PublicQuestionnairePage: React.FC = () => {
const { applicationId } = useParams<{ applicationId: string }>();
const [isValid, setIsValid] = useState<boolean | null>(null);
const [appName, setAppName] = useState('');
const navigate = useNavigate();
const { user } = useSelector((state: RootState) => state.auth);
const [loading, setLoading] = useState(true);
const [questions, setQuestions] = useState<any[]>([]);
const [sections, setSections] = useState<string[]>([]);
const [activeSection, setActiveSection] = useState<string>('');
const [responses, setResponses] = useState<Record<string, any>>({});
const [submitting, setSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
useEffect(() => {
const checkValidity = async () => {
const fetchQuestionnaire = async () => {
if (!applicationId) return;
try {
// We use the public fetch to verify existence
// Determine if we need public or private fetch based on auth
// For prospective users, we might want to use the public endpoint or a specific one
const res = await API.getPublicQuestionnaire(applicationId);
if (res.data.success) {
setIsValid(true);
setAppName(res.data.data.applicationName);
setQuestions(res.data.data.questions || []);
} else {
setIsValid(false);
const fetchedQuestions = res.data.data.questions || [];
setQuestions(fetchedQuestions);
// Extract unique sections
const uniqueSections = Array.from(new Set(fetchedQuestions.map((q: any) => q.sectionName)));
setSections(uniqueSections as string[]);
if (uniqueSections.length > 0) {
setActiveSection(uniqueSections[0] as string);
}
// Check if already submitted (optional, backend might handle)
}
} catch (error: any) {
console.error("Error fetching questionnaire:", error);
if (error.response?.data?.code === 'ALREADY_SUBMITTED') {
setIsValid('submitted' as any); // Use a string or enum for distinct state
setIsSubmitted(true);
} else {
setIsValid(false);
toast.error("Failed to load questionnaire");
}
} finally {
setLoading(false);
}
};
checkValidity();
fetchQuestionnaire();
}, [applicationId]);
if (isValid === null) return <div className="p-8 text-center">Checking application link...</div>;
const handleInputChange = (questionId: string, value: any) => {
setResponses(prev => ({ ...prev, [questionId]: value }));
};
if (isValid === 'submitted' as any) {
const handleNextSection = () => {
const currentIndex = sections.indexOf(activeSection);
if (currentIndex < sections.length - 1) {
setActiveSection(sections[currentIndex + 1]);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handlePrevSection = () => {
const currentIndex = sections.indexOf(activeSection);
if (currentIndex > 0) {
setActiveSection(sections[currentIndex - 1]);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
};
const handleSubmit = async () => {
// Validate all mandatory questions
const missing = questions.filter(q => q.isMandatory && !responses[q.id]);
if (missing.length > 0) {
toast.error(`Please answer all mandatory questions. Missing: ${missing.length}`);
// Provide visual feedback or navigation to missing section?
return;
}
try {
setSubmitting(true);
const payload = Object.entries(responses).map(([qId, val]) => ({
questionId: qId,
value: val
}));
await API.submitPublicResponse({
applicationId: applicationId!,
responses: payload
});
toast.success('Responses submitted successfully');
setIsSubmitted(true);
setTimeout(() => navigate('/prospective-dashboard'), 3000);
} catch (error) {
console.error(error);
toast.error('Failed to submit responses');
} finally {
setSubmitting(false);
}
};
if (loading) return (
<div className="flex items-center justify-center h-screen bg-slate-50">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-600"></div>
</div>
);
if (isSubmitted) {
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center p-6">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
<div className="min-h-screen bg-slate-50 flex flex-col items-center justify-center p-6">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center border-t-4 border-amber-600">
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-4">
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<CheckCircle className="w-8 h-8" />
</div>
<h2 className="text-2xl font-bold mb-2">Assessment Submitted</h2>
<p className="text-gray-600 mb-6">
Thank you! Your assessment has already been submitted successfully.
We will review your application and get back to you shortly.
<h2 className="text-2xl font-bold mb-2 text-slate-900">Assessment Submitted</h2>
<p className="text-slate-600 mb-6">
Thank you! Your assessment has been submitted successfully.
Redirecting to dashboard...
</p>
<button
onClick={() => navigate('/prospective-dashboard')}
className="w-full py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-colors"
>
Return to Dashboard
</button>
</div>
</div>
);
}
if (isValid === false) return <div className="p-8 text-center text-red-500 text-xl font-bold">Invalid or Expired Link</div>;
const activeQuestions = questions.filter(q => q.sectionName === activeSection);
const currentSectionIndex = sections.indexOf(activeSection);
const totalMarks = questions.reduce((sum, q) => sum + (Number(q.weight) || 0), 0);
return (
<div className="min-h-screen bg-gray-50 flex flex-col items-center py-10">
<div className="w-full max-w-4xl bg-white rounded-lg shadow-lg overflow-hidden">
<div className="bg-black text-white p-6">
<h1 className="text-2xl font-bold">Dealer Application Assessment</h1>
<p className="opacity-80 mt-1">Applicant: {appName}</p>
<p className="opacity-60 text-sm">ID: {applicationId}</p>
<div className="flex-1 flex flex-col overflow-hidden h-screen bg-slate-50">
{/* Header */}
<header className="bg-white border-b border-slate-200 px-6 py-4 flex-shrink-0">
<div className="flex items-center justify-between">
<div>
<h1 className="text-slate-900 font-bold text-xl">Dealer Questionnaire Form</h1>
<p className="text-slate-600 text-sm">Manage and track dealership applications</p>
</div>
<div className="flex items-center gap-3">
{user && (
<div className="flex items-center gap-3 px-3 py-2 bg-slate-100 rounded-lg">
<div className="w-8 h-8 bg-amber-600 rounded-full flex items-center justify-center">
<User className="w-4 h-4 text-white" />
</div>
<div className="text-left">
<p className="text-slate-900 text-sm font-medium">{user.name || 'Applicant'}</p>
<p className="text-slate-600 text-xs">{user.role || 'Prospective Dealer'}</p>
</div>
</div>
)}
<button className="p-2 text-slate-500 hover:bg-slate-100 rounded-lg" title="Refresh" onClick={() => window.location.reload()}>
<RefreshCw className="w-4 h-4" />
</button>
<button className="p-2 text-slate-500 hover:bg-slate-100 rounded-lg" title="Help">
<HelpCircle className="w-4 h-4" />
</button>
</div>
</div>
</header>
<main className="flex-1 overflow-y-auto">
<div className="bg-white border-b border-slate-200 sticky top-0 z-20 shadow-sm">
<div className="max-w-5xl mx-auto px-8 py-3">
<button
onClick={() => navigate('/prospective-dashboard')}
className="inline-flex items-center gap-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors px-3 py-2 rounded-lg hover:bg-slate-100"
>
<ArrowLeft className="w-4 h-4" />
Back to Applicant Portal
</button>
</div>
</div>
<div className="p-6">
<div className="bg-yellow-50 border-l-4 border-yellow-400 p-4 mb-6">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-yellow-700">
Please complete all mandatory fields. You can submit this form only once.
<div className="max-w-5xl mx-auto py-8 px-6">
{/* Hero Section */}
<div className="bg-gradient-to-br from-slate-900 via-slate-800 to-amber-900 rounded-t-lg overflow-hidden shadow-xl">
<div className="relative px-8 py-12">
<div className="absolute inset-0 opacity-10 pointer-events-none">
<div className="absolute top-0 right-0 w-64 h-64 bg-amber-500 rounded-full blur-3xl"></div>
<div className="absolute bottom-0 left-0 w-64 h-64 bg-amber-600 rounded-full blur-3xl"></div>
</div>
<div className="relative z-10 text-center">
<div className="inline-flex items-center justify-center mb-6">
<div className="w-20 h-20 bg-amber-600 rounded-full flex items-center justify-center shadow-xl">
<Bike className="w-10 h-10 text-white" />
</div>
</div>
<h1 className="text-white text-3xl mb-3 font-serif tracking-wide">ROYAL ENFIELD</h1>
<div className="h-1 w-24 bg-amber-600 mx-auto mb-4"></div>
<h2 className="text-amber-400 text-xl mb-4 font-light">Dealership Partner Application</h2>
<p className="text-slate-300 max-w-2xl mx-auto leading-relaxed text-sm">
Thank you for your interest in becoming a Royal Enfield business partner.
Please complete this questionnaire to help us understand your profile and aspirations.
</p>
<div className="flex items-center justify-center gap-8 mt-8 border-t border-slate-700/50 pt-6 inline-flex mx-auto">
<div className="text-center px-4">
<div className="text-amber-400 text-2xl font-bold">{questions.length}</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Questions</div>
</div>
<div className="h-10 w-px bg-slate-700"></div>
<div className="text-center px-4">
<div className="text-amber-400 text-2xl font-bold">{sections.length}</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Sections</div>
</div>
<div className="h-10 w-px bg-slate-700"></div>
<div className="text-center px-4">
<div className="text-amber-400 text-2xl font-bold">{totalMarks}</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Total Marks</div>
</div>
</div>
</div>
</div>
{/* Section Tabs */}
<div className="bg-slate-800/50 backdrop-blur-sm border-t border-slate-700">
<div className="flex items-center gap-2 overflow-x-auto scrollbar-hide px-8 py-4 no-scrollbar">
{sections.map((section, idx) => (
<button
key={section}
onClick={() => setActiveSection(section)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg whitespace-nowrap transition-all text-sm font-medium
${activeSection === section
? 'bg-amber-600 text-white shadow-lg'
: 'bg-slate-700/50 text-slate-300 hover:bg-slate-700 hover:text-white'
}`}
>
<FileText className="w-4 h-4" />
<span>{section}</span>
{activeSection === section && (
<span className="ml-2 bg-white/20 px-2 py-0.5 rounded text-xs">
{questions.filter(q => q.sectionName === section).length}
</span>
)}
</button>
))}
</div>
</div>
</div>
<QuestionnaireForm
applicationId={applicationId!}
publicMode={true}
initialQuestions={questions}
onComplete={() => {
toast.success("Thank you! Your assessment has been submitted.");
setTimeout(() => window.location.reload(), 2000);
}}
/>
{/* Question Content */}
<div className="bg-white rounded-b-lg shadow-xl border border-slate-200 border-t-0 min-h-[400px]">
<div className="p-8">
<div className="flex items-start gap-4 pb-6 border-b-2 border-amber-100 mb-8">
<div className="w-12 h-12 bg-amber-50 rounded-lg flex items-center justify-center flex-shrink-0 text-amber-600">
<Users className="w-6 h-6" />
</div>
<div className="flex-1">
<h3 className="text-slate-900 text-xl font-bold mb-1">{activeSection}</h3>
<p className="text-slate-500 text-sm">
Section {currentSectionIndex + 1} of {sections.length} {activeQuestions.length} questions
</p>
</div>
</div>
<div className="space-y-10">
{activeQuestions.map((q, idx) => (
<div key={q.id} className="group animate-in fade-in duration-500" style={{ animationDelay: `${idx * 100}ms` }}>
<div className="flex items-start gap-5">
<div className="w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center flex-shrink-0 group-hover:bg-amber-100 transition-colors text-slate-600 group-hover:text-amber-700 font-semibold text-sm">
{idx + 1}
</div>
<div className="flex-1 space-y-3">
<div className="flex items-start justify-between gap-4">
<label className="text-sm font-medium text-slate-900 leading-relaxed block">
{q.questionText}
{q.isMandatory && <span className="text-red-500 ml-1">*</span>}
</label>
{q.weight > 0 && (
<span className="shrink-0 px-2 py-1 bg-slate-100 text-slate-600 text-xs font-semibold rounded border border-slate-200">
{q.weight} Marks
</span>
)}
</div>
<div className="max-w-xl">
{(q.inputType === 'text' || q.inputType === 'email' || q.inputType === 'number') && (
<input
type={q.inputType}
className="w-full h-10 px-3 rounded-lg border border-slate-300 focus:border-amber-500 focus:ring-2 focus:ring-amber-200 outline-none transition-all placeholder:text-slate-400"
placeholder="Type your answer here..."
value={responses[q.id] || ''}
onChange={(e) => handleInputChange(q.id, e.target.value)}
/>
)}
{(q.inputType === 'select' || q.inputType === 'yesno') && (
<select
className="w-full h-10 px-3 rounded-lg border border-slate-300 focus:border-amber-500 focus:ring-2 focus:ring-amber-200 outline-none transition-all bg-white"
value={responses[q.id] || ''}
onChange={(e) => handleInputChange(q.id, e.target.value)}
>
<option value="">Select an option...</option>
{(q.questionOptions || (q.inputType === 'yesno' ? [{ optionText: 'Yes' }, { optionText: 'No' }] : [])).map((opt: any, i: number) => (
<option key={i} value={opt.optionText || opt.text}>
{opt.optionText || opt.text}
</option>
))}
</select>
)}
</div>
</div>
</div>
</div>
))}
{activeQuestions.length === 0 && (
<div className="text-center py-10 text-slate-500 italic">
No questions in this section.
</div>
)}
</div>
<div className="flex items-center justify-between pt-10 mt-10 border-t border-slate-100">
<button
onClick={handlePrevSection}
disabled={currentSectionIndex === 0}
className="px-6 py-2.5 rounded-lg text-sm font-medium border border-slate-300 text-slate-700 hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Previous Section
</button>
{currentSectionIndex < sections.length - 1 ? (
<button
onClick={handleNextSection}
className="px-6 py-2.5 rounded-lg text-sm font-medium bg-amber-600 text-white hover:bg-amber-700 flex items-center gap-2 transition-colors shadow-md hover:shadow-lg"
>
Next Section
<ChevronRight className="w-4 h-4" />
</button>
) : (
<button
onClick={handleSubmit}
disabled={submitting}
className="px-8 py-2.5 rounded-lg text-sm font-medium bg-green-600 text-white hover:bg-green-700 flex items-center gap-2 transition-colors shadow-md hover:shadow-lg disabled:bg-slate-400"
>
{submitting ? 'Submitting...' : 'Submit Application'}
{!submitting && <CheckCircle className="w-4 h-4" />}
</button>
)}
</div>
</div>
</div>
<div className="mt-8 text-center text-slate-500 text-sm">
© 2026 Royal Enfield. All rights reserved.
</div>
</div>
</div>
</main>
</div>
);
};

View File

@ -3,7 +3,7 @@ import { API } from '../api/API';
export const onboardingService = {
submitApplication: async (data: any) => {
try {
const response = await API.submitApplication(data);
const response: any = await API.submitApplication(data);
return response.data;
} catch (error) {
console.error('Submit application error:', error);
@ -12,7 +12,7 @@ export const onboardingService = {
},
getApplications: async () => {
try {
const response = await API.getApplications();
const response: any = await API.getApplications();
return response.data?.data || response.data;
} catch (error) {
console.error('Get applications error:', error);
@ -21,7 +21,7 @@ export const onboardingService = {
},
shortlistApplications: async (applicationIds: string[], assignedTo: string[], remarks?: string) => {
try {
const response = await API.shortlistApplications({ applicationIds, assignedTo, remarks });
const response: any = await API.shortlistApplications({ applicationIds, assignedTo, remarks });
return response.data;
} catch (error) {
console.error('Shortlist applications error:', error);
@ -30,7 +30,7 @@ export const onboardingService = {
},
getApplicationById: async (id: string) => {
try {
const response = await API.getApplicationById(id);
const response: any = await API.getApplicationById(id);
return response.data?.data || response.data;
} catch (error) {
console.error('Get application by id error:', error);
@ -39,7 +39,7 @@ export const onboardingService = {
},
getUsers: async () => {
try {
const response = await API.getUsers();
const response: any = await API.getUsers();
return response.data?.data || response.data;
} catch (error) {
console.error('Get users error:', error);
@ -48,7 +48,7 @@ export const onboardingService = {
},
addParticipant: async (data: any) => {
try {
const response = await API.addParticipant(data);
const response: any = await API.addParticipant(data);
return response.data;
} catch (error) {
console.error('Add participant error:', error);
@ -57,11 +57,64 @@ export const onboardingService = {
},
scheduleInterview: async (data: any) => {
try {
const response = await API.scheduleInterview(data);
const response: any = await API.scheduleInterview(data);
return response.data;
} catch (error) {
console.error('Schedule interview error:', error);
throw error;
}
},
getInterviews: async (applicationId: string) => {
try {
const response: any = await API.getInterviews(applicationId);
return response.data?.data || response.data;
} catch (error) {
console.error('Get interviews error:', error);
throw error;
}
},
getDocuments: async (id: string) => {
try {
const response: any = await API.getDocuments(id);
return response.data?.data || response.data;
} catch (error) {
console.error('Get documents error:', error);
throw error;
}
},
uploadDocument: async (id: string, data: any) => {
try {
const response: any = await API.uploadDocument(id, data);
return response.data;
} catch (error) {
console.error('Upload document error:', error);
throw error;
}
},
submitKTMatrix: async (data: any) => {
try {
const response: any = await API.submitKTMatrix(data);
if (response.ok) {
return response.data;
} else {
throw new Error(response.problem || 'Failed to submit KT Matrix');
}
} catch (error) {
console.error('Submit KT Matrix error:', error);
throw error;
}
},
submitLevel2Feedback: async (data: any) => {
try {
const response: any = await API.submitLevel2Feedback(data);
if (response.ok) {
return response.data;
} else {
throw new Error(response.problem || 'Failed to submit Level 2 Feedback');
}
} catch (error) {
console.error('Submit Level 2 Feedback error:', error);
throw error;
}
}
};