diff --git a/src/App.tsx b/src/App.tsx index ebc3865..9db7228 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -4,6 +4,7 @@ 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 { ApplicationFormPage } from './components/public/ApplicationFormPage'; +import PublicQuestionnairePage from './pages/public/PublicQuestionnairePage'; import { LoginPage } from './components/auth/LoginPage'; import { Sidebar } from './components/layout/Sidebar'; import { Header } from './components/layout/Header'; @@ -36,6 +37,7 @@ import { DealerResignationPage } from './components/dealer/DealerResignationPage import { DealerConstitutionalChangePage } from './components/dealer/DealerConstitutionalChangePage'; import { DealerRelocationPage } from './components/dealer/DealerRelocationPage'; import QuestionnaireBuilder from './components/admin/QuestionnaireBuilder'; +import QuestionnaireList from './components/admin/QuestionnaireList'; import { Toaster } from './components/ui/sonner'; import { User } from './lib/mock-data'; import { toast } from 'sonner'; @@ -143,12 +145,16 @@ export default function App() { // Public Routes if (!isAuthenticated) { return ( - - } /> - : - <> setShowAdminLogin(true)} />} - /> - + <> + + } /> + } /> + : + setShowAdminLogin(true)} />} + /> + + + ) } @@ -182,8 +188,10 @@ export default function App() { {/* Other Modules */} } /> } /> - } /> + } /> } /> + } /> + } /> {/* HR/Finance Modules (Simplified for brevity, following pattern) */} navigate(`/resignation/${id}`)} />} /> diff --git a/src/api/API.ts b/src/api/API.ts index 9c4e9e9..49a9ea7 100644 --- a/src/api/API.ts +++ b/src/api/API.ts @@ -1,4 +1,5 @@ import client from './client'; +import axios from 'axios'; export const API = { // Auth routes @@ -30,6 +31,12 @@ export const API = { getLatestQuestionnaire: () => client.get('/questionnaire/latest'), createQuestionnaireVersion: (data: any) => client.post('/questionnaire/version', data), submitQuestionnaireResponse: (data: any) => client.post('/questionnaire/response', data), + getAllQuestionnaires: () => client.get('/onboarding/questionnaires'), + getQuestionnaireById: (id: string) => client.get(`/onboarding/questionnaires/${id}`), + + // 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), // User management routes getUsers: () => client.get('/admin/users'), diff --git a/src/components/admin/QuestionnaireBuilder.tsx b/src/components/admin/QuestionnaireBuilder.tsx index a1dbca9..ff67dd2 100644 --- a/src/components/admin/QuestionnaireBuilder.tsx +++ b/src/components/admin/QuestionnaireBuilder.tsx @@ -1,10 +1,11 @@ import React, { useState, useEffect } from 'react'; import { API } from '../../api/API'; -import { toast } from 'sonner'; // Assuming hot-toast is used -import { Trash2, Plus, Save } from 'lucide-react'; // Assuming lucide-react icons +import { toast } from 'sonner'; +import { Trash2, Plus, Save, AlertCircle, ArrowLeft } from 'lucide-react'; +import { useParams, useNavigate } from 'react-router-dom'; interface Question { - courseId?: string; // Legacy? + id?: string; sectionName: string; questionText: string; inputType: 'text' | 'yesno' | 'file' | 'number'; @@ -14,18 +15,66 @@ interface Question { isMandatory: boolean; } -const SECTIONS = ['General', 'Financial', 'Infrastructure', 'Experience', 'Market Knowledge']; +const SECTIONS = [ + 'Personal Information', + 'Financial Information', + 'Location & Background', + 'Motivation', + 'Business Structure', + 'Professional Background', + 'Infrastructure', + 'Financial Planning', + 'Growth & Expansion', + 'Brand Affinity', + 'Vision & Strategy' +]; const QuestionnaireBuilder: React.FC = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); const [version, setVersion] = useState(`v${new Date().toISOString().split('T')[0]}`); const [questions, setQuestions] = useState([ - { sectionName: 'General', questionText: '', inputType: 'text', weight: 0, order: 1, isMandatory: true } + { sectionName: 'Personal Information', questionText: '', inputType: 'text', weight: 0, order: 1, isMandatory: true } ]); const [loading, setLoading] = useState(false); + const [fetching, setFetching] = useState(!!id); + + useEffect(() => { + if (id) { + fetchQuestionnaire(id); + } + }, [id]); + + const fetchQuestionnaire = async (questionnaireId: string) => { + try { + setFetching(true); + const response = await API.getQuestionnaireById(questionnaireId) as any; + if (response.data?.success) { + const data = response.data.data; + setVersion(`${data.version} (Copy)`); // Default to making a copy + if (data.questions && data.questions.length > 0) { + setQuestions(data.questions.map((q: any) => ({ + ...q, + weight: parseFloat(q.weight) // Ensure weight is number + }))); + } + } else { + toast.error('Failed to load questionnaire'); + navigate('/questionnaires'); + } + } catch (error) { + console.error(error); + toast.error('Error fetching questionnaire'); + } finally { + setFetching(false); + } + }; + + const totalWeight = questions.reduce((sum, q) => sum + (q.weight || 0), 0); const addQuestion = () => { setQuestions([...questions, { - sectionName: 'General', + sectionName: 'Personal Information', questionText: '', inputType: 'text', weight: 0, @@ -53,6 +102,11 @@ const QuestionnaireBuilder: React.FC = () => { return; } + if (totalWeight !== 100) { + toast.error(`Total weightage must be exactly 100. Current total: ${totalWeight}`); + return; + } + try { setLoading(true); await API.createQuestionnaireVersion({ @@ -60,6 +114,7 @@ const QuestionnaireBuilder: React.FC = () => { questions }); toast.success('Questionnaire version created successfully'); + navigate('/questionnaires'); // Redirect to list after save } catch (error) { console.error(error); toast.error('Failed to create questionnaire'); @@ -68,95 +123,138 @@ const QuestionnaireBuilder: React.FC = () => { } }; + if (fetching) { + return ( +
+
+
+ ); + } + return ( -
-
-

Questionnaire Builder

-
- setVersion(e.target.value)} - className="border p-2 rounded" - placeholder="Version Name" - /> +
+
+
+
+

+ {id ? 'Edit Questionnaire Version' : 'Create New Version'} +

+

+ {id ? 'Modify existing template and save as new version' : 'Define questions, logic and weightage'} +

+
+
+ +
+
+ {totalWeight !== 100 && } + Total Score: {totalWeight}/100 +
+ +
+ setVersion(e.target.value)} + className="border border-slate-300 p-2 rounded-lg w-full md:w-48 text-sm focus:ring-2 focus:ring-amber-500 outline-none" + placeholder="Version Name (e.g. v2.0)" + /> + +
{questions.map((q, index) => ( -
-
-
- +
+
+
+ {index + 1} +
+
+ +
+
+ updateQuestion(index, 'questionText', e.target.value)} - className="w-full border p-2 rounded" - placeholder="Enter question..." + className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none transition-shadow" + placeholder="Enter your question here..." />
-
- +
+
-
- +
+
-
- - updateQuestion(index, 'weight', parseFloat(e.target.value))} - className="w-full border p-2 rounded" - /> -
+
+
+
+
+ updateQuestion(index, 'weight', parseFloat(e.target.value))} + className="w-full border border-slate-300 p-2.5 rounded-lg focus:ring-2 focus:ring-amber-500 outline-none pl-3 pr-8" + title="Weightage" + /> + % +
+
-
- updateQuestion(index, 'isMandatory', e.target.checked)} - className="mr-2" - /> - + {/*
updateQuestion(index, 'isMandatory', !q.isMandatory)} + > +
+ {q.isMandatory &&
} +
+ Req. +
*/} +
))} @@ -164,9 +262,9 @@ const QuestionnaireBuilder: React.FC = () => {
); diff --git a/src/components/admin/QuestionnaireList.tsx b/src/components/admin/QuestionnaireList.tsx new file mode 100644 index 0000000..77899d2 --- /dev/null +++ b/src/components/admin/QuestionnaireList.tsx @@ -0,0 +1,120 @@ +import React, { useState, useEffect } from 'react'; +import { API } from '../../api/API'; +import { toast } from 'sonner'; +import { useNavigate } from 'react-router-dom'; +import { Plus, Edit2, Calendar, CheckCircle, XCircle } from 'lucide-react'; +import { format } from 'date-fns'; + +interface QuestionnaireVersion { + id: string; + version: string; + isActive: boolean; + createdAt: string; +} + +const QuestionnaireList: React.FC = () => { + const [versions, setVersions] = useState([]); + const [loading, setLoading] = useState(true); + const navigate = useNavigate(); + + useEffect(() => { + fetchVersions(); + }, []); + + const fetchVersions = async () => { + try { + setLoading(true); + const response = await API.getAllQuestionnaires() as any; + if (response.data?.success) { + setVersions(response.data.data); + } else { + toast.error('Failed to load questionnaire versions'); + } + } catch (error) { + console.error(error); + toast.error('Error fetching versions'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Questionnaire Versions

+

Manage your questionnaire templates and versions

+
+ +
+ + {loading ? ( +
+
+
+ ) : versions.length === 0 ? ( +
+

No questionnaire versions found.

+ +
+ ) : ( +
+ + + + + + + + + + + {versions.map((v) => ( + + + + + + + ))} + +
Version NameStatusCreated AtActions
{v.version} + {v.isActive ? ( + + Active + + ) : ( + + Inactive + + )} + +
+ + {format(new Date(v.createdAt), 'MMM dd, yyyy HH:mm')} +
+
+ +
+
+ )} +
+ ); +}; + +export default QuestionnaireList; diff --git a/src/components/dealer/QuestionnaireForm.tsx b/src/components/dealer/QuestionnaireForm.tsx index a8d41f2..1a45522 100644 --- a/src/components/dealer/QuestionnaireForm.tsx +++ b/src/components/dealer/QuestionnaireForm.tsx @@ -18,16 +18,27 @@ interface QuestionnaireFormProps { onComplete?: () => void; readOnly?: boolean; existingResponses?: any[]; + publicMode?: boolean; // New prop + initialQuestions?: Question[]; // New prop to avoid re-fetching } -const QuestionnaireForm: React.FC = ({ applicationId, onComplete, readOnly = false, existingResponses }) => { - const [questions, setQuestions] = useState([]); +const QuestionnaireForm: React.FC = ({ + applicationId, + onComplete, + readOnly = false, + existingResponses, + publicMode = false, + initialQuestions +}) => { + const [questions, setQuestions] = useState(initialQuestions || []); const [responses, setResponses] = useState>({}); - const [loading, setLoading] = useState(true); + const [loading, setLoading] = useState(!initialQuestions); const [submitting, setSubmitting] = useState(false); useEffect(() => { - fetchQuestionnaire(); + if (!initialQuestions) { + fetchQuestionnaire(); + } if (existingResponses) { const initialResponses: any = {}; existingResponses.forEach(r => { @@ -35,10 +46,18 @@ const QuestionnaireForm: React.FC = ({ applicationId, on }); setResponses(initialResponses); } - }, [existingResponses]); + }, [existingResponses, initialQuestions]); const fetchQuestionnaire = async () => { try { + // In public mode, we shouldn't fetch "latest" as it requires auth. + // Public page should provide initialQuestions. + // But if we ever needed to fetch, we'd need a public endpoint for just questions or rely on the parent. + if (publicMode) { + setLoading(false); + return; + } + const res = await API.getLatestQuestionnaire(); if (res.data && res.data.data && res.data.data.questions) { setQuestions(res.data.data.questions); @@ -70,10 +89,18 @@ const QuestionnaireForm: React.FC = ({ applicationId, on value: val })); - await API.submitQuestionnaireResponse({ - applicationId, - responses: payload - }); + if (publicMode) { + await API.submitPublicResponse({ + applicationId, + responses: payload + }); + } else { + await API.submitQuestionnaireResponse({ + applicationId, + responses: payload + }); + } + toast.success('Responses submitted successfully'); if (onComplete) onComplete(); } catch (error) { diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 75eb882..edf536f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -13,7 +13,8 @@ import { FolderOpen, Settings, RefreshCcw, - MapPin + MapPin, + ClipboardList } from 'lucide-react'; import { useState } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; @@ -92,6 +93,7 @@ export function Sidebar({ onLogout }: SidebarProps) { if (currentUser?.role === 'Super Admin') { menuItems.push({ id: 'users', label: 'User Management', icon: Users }); + menuItems.push({ id: 'questionnaires', label: 'Questionnaire Templates', icon: ClipboardList }); } const handleSearch = (e: React.FormEvent) => { diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index 385055c..347fa2b 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -191,10 +191,17 @@ export const mockUsers: User[] = [ { id: '16', name: 'Yashwin', - email: 'yashwin@royalenfield.com', - password: 'password', + email: 'yashwin@gmail.com', + password: 'Admin@123', role: 'ZBH', - } + }, + { + id: '17', + name: 'Kenil', + email: 'kenil@gmail.com', + password: 'Admin@123', + role: 'DD Lead', + }, ]; // Mock current user (default) diff --git a/src/pages/public/PublicQuestionnairePage.tsx b/src/pages/public/PublicQuestionnairePage.tsx new file mode 100644 index 0000000..5efcf18 --- /dev/null +++ b/src/pages/public/PublicQuestionnairePage.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import QuestionnaireForm from '../../components/dealer/QuestionnaireForm'; +import { API } from '../../api/API'; +import { toast } from 'sonner'; + +const PublicQuestionnairePage: React.FC = () => { + const { applicationId } = useParams<{ applicationId: string }>(); + const [isValid, setIsValid] = useState(null); + const [appName, setAppName] = useState(''); + const [questions, setQuestions] = useState([]); + + useEffect(() => { + const checkValidity = async () => { + if (!applicationId) return; + try { + // We use the public fetch to verify existence + 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); + } + } catch (error) { + setIsValid(false); + } + }; + checkValidity(); + }, [applicationId]); + + if (isValid === null) return
Checking application link...
; + if (isValid === false) return
Invalid or Expired Link
; + + return ( +
+
+
+

Dealer Application Assessment

+

Applicant: {appName}

+

ID: {applicationId}

+
+ +
+
+
+
+

+ Please complete all mandatory fields. You can submit this form only once. +

+
+
+
+ + { + toast.success("Thank you! Your assessment has been submitted."); + setTimeout(() => window.location.reload(), 2000); + }} + /> +
+
+
+ ); +}; + +export default PublicQuestionnairePage;